本文为Google Translate英译中结果,DrGraph在此基础上加了一些校正。英文原版页面:Best practices — Godot Engine (stable) documentation in English
介绍¶
本系列是一系列最佳实践,可帮助您高效地使用 Godot。
Godot 在构建项目代码库并将其分解为场景方面提供了极大的灵活性。每种方法都有其优点和缺点,在您使用该引擎足够长的时间之前,很难权衡它们。
总是有很多方法来构建代码和解决特定的编程问题。不可能在这里涵盖所有内容。
这就是为什么每篇文章都从一个现实世界的问题开始。我们将分解基本问题中的每个问题,提出解决方案,分析每个选项的优缺点,并强调针对手头问题的最佳行动方案。
您应该首先阅读在 Godot 中应用面向对象的原则。它解释了 Godot 的节点和场景如何与其他面向对象编程语言中的类和对象相关联。它将帮助您理解本系列的其余部分。
注:Godot 中的最佳实践依赖于面向对象的设计原则。我们使用单一职责原则和 封装等工具。
在 Godot 中应用面向对象的原则¶
该引擎提供了两种创建可重用对象的主要方式:脚本和场景。这些都没有在技术上定义引擎下的类。
不过,许多使用 Godot 的最佳实践涉及将面向对象的编程原则应用于构成游戏的脚本和场景。这就是为什么理解我们如何将它们视为类是有用的。
本指南简要说明了脚本和场景在引擎核心中的工作原理,以帮助您了解它们在引擎下的工作原理。
脚本在引擎中的工作方式¶
该引擎提供内置类,如Node。您可以扩展它们以使用脚本创建派生类型。
这些脚本在技术上不是类。相反,它们是告诉引擎要对引擎的一个内置类执行一系列初始化的资源。
Godot 的内部类具有将类的数据注册到ClassDB的方法。该数据库提供对类信息的运行时访问。ClassDB
包含有关类的信息,例如:
-
特性。
-
方法。
-
常数。
-
信号。
这ClassDB
是对象在执行访问属性或调用方法等操作时要检查的内容。它检查数据库的记录和对象的基本类型的记录,以查看对象是否支持该操作。
将脚本附加到您的对象可扩展ClassDB
.
注:即使不使用extends
关键字的脚本也隐式继承自引擎的基 RefCounted类。因此,您可以在没有 extends
代码关键字的情况下实例化脚本。由于它们会扩展RefCounted
,因此您不能将它们附加到Node。
场景¶
场景的行为与类有很多相似之处,因此将场景视为类是有道理的。场景是可重用、可实例化和可继承的节点组。创建场景类似于创建节点并将它们添加为子节点的脚本add_child()
。
我们经常将场景与使用场景节点的脚本化根节点配对。因此,脚本通过命令式代码添加行为来扩展场景。
场景的内容有助于定义:
-
脚本可以使用哪些节点
-
他们是如何组织的
-
它们是如何初始化的
-
他们之间有什么信号连接
为什么这些对场景组织很重要?因为场景的实例是对象。因此,许多适用于编写代码的面向对象原则也适用于场景:单一职责、封装等。
场景始终是附加到其根节点的脚本的扩展,因此您可以将其解释为类的一部分。
本最佳实践系列中介绍的大多数技术都建立在这一点之上。
场景组织¶
本文涵盖与场景内容的有效组织相关的主题。应该使用哪些节点?应该把它们放在哪里?他们应该如何互动?
如何有效地建立关系¶
当 Godot 用户开始制作自己的场景时,他们经常会遇到以下问题:
他们创建了他们的第一个场景并用内容填充它,但最终最终将他们场景的分支保存到单独的场景中,因为他们应该把事情分开的唠叨感开始累积。然而,他们随后注意到他们之前能够依赖的硬引用不再可能了。在多个地方重新使用场景会产生问题,因为节点路径找不到它们的目标,并且在编辑器中建立的信号连接中断。
要解决这些问题,必须实例化子场景而不需要有关其环境的详细信息。人们需要能够相信子场景会自行创建,而不会挑剔人们如何使用它。
在 OOP 中要考虑的最重要的事情之一是维护 与代码库的其他部分松散耦合的集中的、单一用途的类。这使对象的大小保持较小(为了可维护性)并提高了它们的可重用性。
这些 OOP 最佳实践对场景结构和脚本使用方面的最佳实践有多种影响。
如果可能的话,应该将场景设计成没有依赖关系。 也就是说,人们应该创造出将他们所需要的一切都保留在自己内部的场景。
如果场景必须与外部上下文交互,有经验的开发人员建议使用 依赖注入。此技术涉及让高级 API 提供低级 API 的依赖项。为什么要这样做?因为依赖外部环境的类可能会无意中触发错误和意外行为。
为此,必须公开数据,然后依靠父上下文对其进行初始化:
-
连接到信号。非常安全,但应该只用于“响应”行为,而不是启动它。请注意,信号名称通常是过去式动词,例如“entered”、“skill_activated”或“item_collected”。
# Parent $Child.signal_name.connect(method_on_the_object) # Child signal_name.emit() # Triggers parent-defined behavior.
-
调用一个方法。用于启动行为。
# 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).
-
初始化Callable属性。比方法更安全,因为不需要方法的所有权。用于启动行为。
# Parent $Child.func_property = object_with_method.method_on_the_object # Child func_property.call() # Call parent-defined method (can come from anywhere).
-
初始化节点或其他对象引用。
# Parent $Child.target = self # Child print(target) # Use parent-defined node.
-
初始化一个节点路径。
# 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()
. 从中返回一个非空字符串将使场景停靠栏生成一个警告图标,该节点将字符串作为工具提示。当Area2D节点没有定义子 CollisionShape2D节点时,该图标与 Area2D 节点等节点显示的图标相同 。剪辑师然后通过脚本代码对场景进行自我记录。无需通过文档复制内容。
像这样的 GUI 可以更好地告知项目用户有关节点的关键信息。它有外部依赖性吗?这些依赖关系是否得到满足?其他程序员,尤其是设计师和作家,将需要消息中的明确说明,告诉他们如何配置它。
那么,为什么所有这些复杂的开关都有效?好吧,因为场景在单独运行时效果最佳。如果无法单独工作,那么与他人匿名合作(具有最小的硬依赖性,即松散耦合)是次佳选择。不可避免地,可能需要对类进行更改,如果这些更改导致它以无法预料的方式与其他场景交互,那么事情就会开始崩溃。所有这些间接的全部意义在于避免出现更改一个类会对依赖它的其他类产生不利影响的情况。
脚本和场景作为引擎类的扩展,应该遵守所有的OOP原则。例子包括...
-
SOLID
-
DRY
-
KISS
-
YAGNI
-
选择节点树结构¶
因此,开发人员开始开发游戏只是为了止步于摆在他们面前的巨大可能性。他们可能知道他们想做什么,他们想要什么样的系统,但是把它们都放在哪里?好吧,一个人如何制作他们的游戏总是取决于他们。可以用无数种方式构造节点树。但是,对于那些不确定的人来说,这个有用的指南可以为他们提供一个体面的结构样本作为开始。
游戏应该总是有一种“切入点”;在某个地方,开发人员可以明确地跟踪事情从哪里开始,以便他们可以在其他地方继续进行时遵循逻辑。这个地方也可以作为程序中所有其他数据和逻辑的鸟瞰图。对于传统应用程序,这将是“主要”功能。在这种情况下,它将是一个主节点。
节点“主要”(main.gd)
然后脚本main.gd
将作为游戏的主要控制器。
然后一个人拥有他们实际的游戏“世界”(2D 或 3D 世界)。这可以是 Main 的孩子。此外,他们的游戏需要一个主要的 GUI 来管理项目所需的各种菜单和小部件。
节点“主要”(main.gd)
Node2D/Node3D“世界”(game_world.gd)
控制“GUI”(gui.gd)
改变关卡时,可以换出“世界”节点的子节点。 手动更改场景让用户可以完全控制他们的游戏世界如何转换。
下一步是考虑项目需要什么样的游戏系统。如果一个人有一个系统......
-
在内部跟踪其所有数据
-
应该是全球可访问的
-
应该孤立存在
...然后应该创建一个自动加载“单例”节点。
笔记
对于较小的游戏,一个控制较少的更简单的替代方案是拥有一个“游戏”单例,它只调用 SceneTree.change_scene_to_file ()方法来换出主场景的内容。这种结构或多或少保持了“世界”作为主要游戏节点。
任何 GUI 也需要是单例的;成为“世界”的短暂部分;或手动添加为根的直接子项。否则,GUI 节点也会在场景转换期间自行删除。
如果一个系统修改了其他系统的数据,则应该将这些系统定义为它们自己的脚本或场景,而不是自动加载。有关原因的更多信息,请参阅 自动加载与常规节点 文档。
游戏中的每个子系统都应该在 SceneTree 中有自己的部分。仅当节点是其父节点的有效元素时,才应使用父子关系。移除父母是否合理地意味着也应该移除孩子?如果不是,那么它应该在层次结构中作为兄弟或其他关系有自己的位置。
笔记
在某些情况下,需要这些分离的节点也将它们自己相对于彼此定位。为此,可以使用 RemoteTransform / RemoteTransform2D节点。它们将允许目标节点有条件地从 Remote* 节点继承选定的变换元素。要分配target
NodePath,请使用以下之一:
-
一个可靠的第三方,可能是父节点,来调解分配。
-
一个组,可以轻松地提取对所需节点的引用(假设只有一个目标)。
什么时候应该这样做?好吧,这是主观的。当节点必须在 SceneTree 周围移动以保护自身时,当必须进行微观管理时,就会出现困境。例如...
-
将“玩家”节点添加到“房间”。
-
需要换房间,所以必须删除当前房间。
-
在删除房间之前,必须保留和/或移动玩家。
记忆是一个问题吗?
-
如果没有,可以只创建两个房间,移动播放器并删除旧的。没问题。
如果是这样,一个人将需要...
-
将玩家移动到树中的其他地方。
-
删除房间。
-
实例化并添加新房间。
-
重新添加播放器。
-
问题是这里的玩家是一个“特例”;开发人员必须知道他们需要为项目以这种方式处理播放器。因此,作为一个团队可靠地共享此信息的唯一方法是将其记录下来。然而,将实现细节保留在文档中是危险的。这是一种维护负担,会影响代码的可读性,并且会不必要地膨胀项目的知识内容。
在具有更大资产的更复杂的游戏中,将玩家完全保留在 SceneTree 中的其他位置可能是一个更好的主意。这导致:
-
更多的一致性。
-
没有必须在某处记录和维护的“特殊情况”。
-
没有机会发生错误,因为没有考虑到这些细节。
相反,如果需要一个不继承父节点变换的子节点,则有以下选项:
-
声明式解决方案:在它们之间放置一个节点。作为没有转换的节点,节点不会将此类信息传递给它们的子节点。
-
命令式解决方案:使用CanvasItem或 Node3D
top_level
节点的属性 。这将使节点忽略其继承的变换。
笔记
如果构建网络游戏,请记住哪些节点和游戏系统与所有玩家相关,哪些仅与权威服务器相关。例如,用户并不都需要拥有每个玩家的“PlayerController”逻辑的副本。相反,他们只需要自己的。因此,将它们保存在与“世界”不同的分支中可以帮助简化游戏连接等的管理。
场景组织的关键是以关系术语而不是空间术语来考虑 SceneTree。节点是否依赖于其父节点的存在?如果没有,那么他们可以在其他地方独自茁壮成长。如果他们是受抚养人,那么按理说他们应该是那个父母的孩子(如果他们还不是的话,很可能是那个父母场景的一部分)。
这是否意味着节点本身就是组件?一点也不。Godot 的节点树形成一种聚合关系,而不是一种组合关系。但是,尽管仍然可以灵活地移动节点,但最好还是在默认情况下不需要此类移动。
何时使用场景与脚本¶
我们已经介绍了场景和脚本的不同之处。脚本使用命令式代码定义引擎类扩展,使用声明式代码定义场景。
因此,每个系统的功能都不同。场景可以定义扩展类如何初始化,但不能定义它的实际行为。场景通常与脚本结合使用,场景声明节点的组合,脚本使用命令式代码添加行为。
匿名类型¶
仅使用脚本就可以完全定义场景的内容。本质上,这就是 Godot 编辑器所做的,只是在其对象的 C++ 构造函数中。
但是,选择使用哪一个可能是一个难题。创建脚本实例与创建引擎内类相同,而处理场景需要更改 API:
const MyNode = preload("my_node.gd") const MyScene = preload("my_scene.tscn") var node = Node.new() var my_node = MyNode.new() # Same method call var my_scene = MyScene.instantiate() # Different method call var my_inherited_scene = MyScene.instantiate(PackedScene.GEN_EDIT_STATE_MAIN) # Create scene inheriting from MyScene
此外,由于引擎和脚本代码之间的速度差异,脚本的运行速度会比场景慢一些。节点越大越复杂,就越有理由将其构建为场景。
命名类型¶
脚本可以在编辑器本身中注册为新类型。这会将其显示为带有可选图标的节点或资源创建对话框中的新类型。这样,用户使用脚本的能力就大大简化了。而不是必须...
-
了解他们想要使用的脚本的基本类型。
-
创建该基类型的实例。
-
将脚本添加到节点。
使用已注册的脚本,脚本类型反而成为像系统中的其他节点和资源一样的创建选项。创建对话框甚至有一个搜索栏,可以按名称查找类型。
有两种注册类型的系统:
-
自定义类型
-
仅限编辑器。类型名称在运行时不可访问。
-
不支持继承的自定义类型。
-
一个初始化工具。使用脚本创建节点。而已。
-
编辑器对脚本或其与其他引擎类型或脚本的关系没有类型意识。
-
允许用户定义图标。
-
适用于所有脚本语言,因为它抽象地处理脚本资源。
-
使用EditorPlugin.add_custom_type进行设置。
-
-
脚本类
-
编辑器和运行时可访问。
-
完整显示继承关系。
-
使用脚本创建节点,但也可以从编辑器更改类型或扩展类型。
-
编辑器知道脚本、脚本类和引擎 C++ 类之间的继承关系。
-
允许用户定义图标。
-
引擎开发人员必须手动添加对语言的支持(名称公开和运行时可访问性)。
-
仅限 Godot 3.1+。
-
编辑器扫描项目文件夹并为所有脚本语言注册任何暴露的名称。每种脚本语言都必须实现自己对公开此信息的支持。
-
这两种方法都将名称添加到创建对话框中,但特别是脚本类还允许用户在不加载脚本资源的情况下访问类型名称。从任何地方创建实例和访问常量或静态方法都是可行的。
有了这些功能,人们可能希望他们的类型是没有场景的脚本,因为它赋予用户易用性。那些开发插件或创建供设计师使用的内部工具的人会发现这种方式更容易。
不利的一面是,这也意味着必须大量使用命令式编程。
脚本与 PackedScene 的性能¶
选择场景和脚本时要考虑的最后一个方面是执行速度。
随着对象大小的增加,脚本创建和初始化它们所需的大小也变得更大。创建节点层次结构证明了这一点。每个节点的逻辑可能有几百行代码。
下面的代码示例创建一个新节点Node
,更改其名称,为其分配脚本,将其未来的父节点设置为其所有者,以便将其与它一起保存到磁盘,最后将其添加为节点的子节点Main
:
# main.gd extends Node func _init(): var child = Node.new() child.name = "Child" child.script = preload("child.gd") child.owner = self add_child(child)
像这样的脚本代码比引擎端的 C++ 代码慢得多。每条指令都会调用脚本 API,这会导致在后端进行多次“查找”以找到要执行的逻辑。
场景有助于避免这种性能问题。PackedScene是场景继承自的基本类型,它定义了使用序列化数据创建对象的资源。该引擎可以在后端批量处理场景,并提供比脚本更好的性能。
结论¶
最后,最好的方法是考虑以下几点:
-
如果一个人希望创建一个将在几个不同的项目中重复使用并且所有技能水平的人都可能使用的基本工具(包括那些不将自己标记为“程序员”的人),那么它很可能是可能应该是一个脚本,可能是一个带有自定义名称/图标的脚本。
-
如果有人希望创建一个特定于他们的游戏的概念,那么它应该始终是一个场景。场景比脚本更容易跟踪/编辑并提供更高的安全性。
-
如果有人想给场景命名,那么他们仍然可以在 3.1 中通过声明脚本类并将场景作为常量给它来实现。该脚本实际上变成了一个命名空间:
# game.gd class_name Game # extends RefCounted, so it won't show up in the node creation dialog extends RefCounted const MyScene = preload("my_scene.tscn") # main.gd extends Node func _ready(): add_child(Game.MyScene.instantiate())
自动加载与常规节点¶
Godot 提供了自动加载项目根节点的功能,允许您全局访问它们,可以完成单例的角色:Singletons ( Autoload)。当您使用SceneTree.change_scene_to_file从代码更改场景时,不会释放这些自动加载的节点。
在本指南中,您将了解何时使用自动加载功能,以及可以用来避免它的技巧。
切割音频问题¶
其他引擎可以鼓励使用创建管理器类,将大量功能组织到全局可访问对象中的单例。由于节点树和信号,Godot 提供了许多避免全局状态的方法。
例如,假设我们正在构建一个平台游戏并且想要收集播放音效的硬币。有一个节点:AudioStreamPlayer。但是如果我们在它已经在播放声音时调用它AudioStreamPlayer
,新的声音会打断第一个声音。
一种解决方案是编写一个全局的、自动加载的声音管理器类。它会生成一个节点池,AudioStreamPlayer
当每个新的音效请求进来时循环通过。假设我们调用该类Sound
,您可以通过调用从项目的任何地方使用它Sound.play("coin_pickup.ogg")
。这在短期内解决了问题,但会导致更多问题:
-
全局状态:一个对象现在负责所有对象的数据。如果该类
Sound
有错误或没有可用的 AudioStreamPlayer,则调用它的所有节点都可能中断。 -
全局访问:现在任何对象都可以
Sound.play(sound_path)
从任何地方调用,不再有一种简单的方法来找到错误的来源。 -
全局资源分配
AudioStreamPlayer
:从一开始就存储了一个节点池,你要么太少而面临错误,要么太多而使用比你需要的更多的内存。
注:关于全局访问,问题是任何地方的任何代码都可能将错误的数据传递给Sound
我们示例中的自动加载。因此,要探索修复错误的领域跨越整个项目。
当您将代码保存在场景中时,音频中可能只涉及一两个脚本。
将此与每个场景AudioStreamPlayer
在其内部保留尽可能多的节点进行对比,所有这些问题都会消失:
-
每个场景管理自己的状态信息。如果数据有问题,只会导致那个场景出现问题。
-
每个场景只访问它自己的节点。现在,如果有错误,很容易找到哪个节点有问题。
-
每个场景都准确分配它需要的资源量。
管理共享功能或数据¶
使用自动加载的另一个原因可能是您希望在多个场景中重复使用相同的方法或数据。
在函数的情况下,您可以Node
使用GDScript 中的class_name关键字创建一个为单个场景提供该功能的新类型。
在数据方面,您可以:
-
创建一种新型资源以共享数据。
-
将数据存储在每个节点都可以访问的对象中,例如使用属性
owner
访问场景的根节点。
什么时候应该使用自动加载¶
在某些情况下,自动加载节点可以简化您的代码:
-
静态数据:如果你需要一个类独有的数据,比如数据库,那么自动加载是一个很好的工具。Godot 中没有脚本 API 来创建和管理静态数据。
-
静态函数:创建一个只返回值的函数库。
-
范围广泛的系统:如果单例管理自己的信息而不侵犯其他对象的数据,那么这是创建处理范围广泛的任务的系统的好方法。例如,任务或对话系统。
在 Godot 3.1 之前,另一个用途只是为了方便:autoloads 有一个在 GDScript 中生成的名称的全局变量,允许您从项目中的任何脚本文件调用它们。但是现在,您可以使用class_name
关键字来为整个项目中的类型获取自动完成功能。
注:Autoload 不完全是单例。没有什么能阻止您实例化自动加载节点的副本。它只是一个工具,可以使节点自动加载为场景树根的子节点,而不管游戏的节点结构或运行哪个场景,例如通过按键F6。
因此,您可以 Sound
通过调用获取自动加载的节点,例如名为get_node("/root/Sound")
的自动加载。
何时以及如何避免对所有内容使用节点¶
节点的生产成本很低,但即使是它们也有其局限性。一个项目可能有几万个节点都在做事情。但是,他们的行为越复杂,每个人对项目绩效造成的压力就越大。
Godot 提供了更轻量级的对象来创建节点使用的 API。在设计您希望如何构建项目功能时,请务必将这些作为选项牢记在心。
-
Object : 终极轻量级对象,原始Object必须使用手动内存管理。话虽如此,创建自己的自定义数据结构并不太难,甚至节点结构也比 Node类更轻。
-
示例:查看树节点。它支持对具有任意行数和列数的目录进行高级定制。它用于生成可视化的数据实际上是一棵TreeItem对象树。
-
优点:将 API 简化为更小范围的对象有助于提高其可访问性并缩短迭代时间。与其使用整个节点库,不如创建一组简化的对象,节点可以从中生成和管理适当的子节点。
注:处理它们时应该小心。可以将对象存储到变量中,但这些引用可能会在没有警告的情况下变得无效。例如,如果对象的创建者决定莫名其妙地删除它,这将在下一次访问它时触发错误状态。
-
-
RefCounted:只比 Object 复杂一点。它们跟踪对自身的引用,仅在不存在对自身的进一步引用时才删除加载的内存。在大多数需要自定义类中的数据的情况下,这些都是有用的。
-
示例:参见FileAccess对象。它的功能就像一个普通的对象,只是不需要自己删除它。
-
优点:和Object一样。
-
-
资源:只比 RefCounted 稍微复杂一点。它们具有将对象属性序列化/反序列化(即保存和加载)到/从 Godot 资源文件的天生能力。
-
示例:脚本、PackedScene(用于场景文件)和其他类型,例如每个AudioEffect类。这些中的每一个都可以保存和加载,因此它们从资源扩展。
-
优势: 关于Resource相对于传统数据存储方法的优势,已经说了很多 。不过,在使用 Resources over Nodes 的上下文中,它们的主要优势在于 Inspector 兼容性。虽然几乎与 Object/RefCounted 一样轻巧,但它们仍然可以在检查器中显示和导出属性。这使它们能够实现类似于可用性方面的子节点的目的,但如果计划在其场景中拥有许多此类资源/节点,则还可以提高性能。
-
Godot接口¶
人们通常需要依赖其他对象的脚本来获得特性。这个过程有两个部分:
-
获取对可能具有这些特征的对象的引用。
-
从对象访问数据或逻辑。
本教程的其余部分概述了执行所有这些操作的各种方法。
获取对象引用¶
对于所有Object,引用它们的最基本方法是从另一个获取的实例中获取对现有对象的引用。
var obj = node.object # Property access. var obj = node.get_object() # Method access.
同样的原则适用于RefCounted对象。虽然用户经常以这种方式访问 Node和 Resource,但可以使用替代措施。
可以通过加载访问来获取资源,而不是访问属性或方法。
var preres = preload(path) # Load resource during scene load var res = load(path) # Load resource when program reaches statement # Note that users load scenes and scripts, by convention, with PascalCase # names (like typenames), often into constants. const MyScene : = preload("my_scene.tscn") as PackedScene # Static load const MyScript : = preload("my_script.gd") as Script # This type's value varies, i.e. it is a variable, so it uses snake_case. export(Script) var script_type: Script # If need an "export const var" (which doesn't exist), use a conditional # setter for a tool script that checks if it's executing in the editor. tool # Must place at top of file. # Must configure from the editor, defaults to null. export(Script) var const_script setget set_const_script func set_const_script(value): if Engine.is_editor_hint(): const_script = value # Warn users if the value hasn't been set. func _get_configuration_warning(): if not const_script: return "Must initialize property 'const_script'." return ""
请注意以下事项:
-
一种语言可以通过多种方式加载此类资源。
-
在设计对象访问数据的方式时,不要忘记也可以将资源作为引用传递。
-
请记住,加载资源会获取引擎维护的缓存资源实例。要获得新对象,必须 复制现有引用或使用
new()
.
节点同样有一个替代访问点:SceneTree。
extends Node # Slow. func dynamic_lookup_with_dynamic_nodepath(): print(get_node("Child")) # Faster. GDScript only. func dynamic_lookup_with_cached_nodepath(): print($Child) # Fastest. Doesn't break if node moves later. # Note that `@onready` annotation is GDScript only. # Other languages must do... # var child # func _ready(): # child = get_node("Child") @onready var child = $Child func lookup_and_cache_for_future_access(): print(child) # Delegate reference assignment to an external source. # Con: need to perform a validation check. # Pro: node makes no requirements of its external structure. # 'prop' can come from anywhere. var prop func call_me_after_prop_is_initialized_by_parent(): # Validate prop in one of three ways. # Fail with no notification. if not prop: return # Fail with an error message. if not prop: printerr("'prop' wasn't initialized") return # Fail and terminate. # Note: Scripts run from a release export template don't # run `assert` statements. assert(prop, "'prop' wasn't initialized") # Use an autoload. # Dangerous for typical nodes, but useful for true singleton nodes # that manage their own data and don't interfere with other objects. func reference_a_global_autoloaded_variable(): print(globals) print(globals.prop) print(globals.my_getter())
从对象访问数据或逻辑¶
Godot 的脚本 API 是DUCK类型的。这意味着如果脚本执行一个操作,Godot 不会验证它是否支持类型操作。相反,它会检查对象是否实现了单独的方法。
例如,CanvasItem类有一个visible
属性。暴露给脚本 API 的所有属性实际上都是绑定到名称的 setter 和 getter 对。如果有人试图访问 CanvasItem.visible,那么 Godot 会按顺序进行以下检查:
-
如果对象附加了脚本,它将尝试通过脚本设置属性。这为脚本提供了通过覆盖属性的 setter 方法来覆盖在基础对象上定义的属性的机会。
-
如果脚本没有该属性,它会在 ClassDB 中针对 CanvasItem 类及其所有继承类型执行 HashMap 查找以查找“可见”属性。如果找到,它将调用绑定的 setter 或 getter。有关 HashMap 的更多信息,请参阅 数据首选项文档。
-
如果未找到,它会进行显式检查以查看用户是否想要访问“script”或“meta”属性。
-
如果不是,它会检查CanvasItem 及其继承类型中的
_set
/实现(取决于访问类型)。_get
这些方法可以执行逻辑,给人以对象具有属性的印象。方法也是如此_get_property_list
。-
请注意,即使对于非法符号名称(例如TileSet的“1/tile_name”属性)也会发生这种情况。这是指 ID 为 1 的 tile 的名称,即
TileSet.tile_get_name(1)
.
-
结果,这个DUCK类型的系统可以在脚本、对象的类或对象继承的任何类中定位属性,但仅限于扩展对象的东西。
Godot 提供了多种选项来对这些访问执行运行时检查:
-
鸭式属性访问。这些将是财产检查(如上所述)。如果对象不支持该操作,则执行将停止。
# All Objects have duck-typed get, set, and call wrapper methods. get_parent().set("visible", false) # Using a symbol accessor, rather than a string in the method call, # will implicitly call the `set` method which, in turn, calls the # setter method bound to the property through the property lookup # sequence. get_parent().visible = false # Note that if one defines a _set and _get that describe a property's # existence, but the property isn't recognized in any _get_property_list # method, then the set() and get() methods will work, but the symbol # access will claim it can't find the property.
-
方法检查。在CanvasItem.visible的情况下 ,可以访问这些方法,
set_visible
就像is_visible
任何其他方法一样。var child = get_child(0) # Dynamic lookup. child.call("set_visible", false) # Symbol-based dynamic lookup. # GDScript aliases this into a 'call' method behind the scenes. child.set_visible(false) # Dynamic lookup, checks for method existence first. if child.has_method("set_visible"): child.set_visible(false) # Cast check, followed by dynamic lookup. # Useful when you make multiple "safe" calls knowing that the class # implements them all. No need for repeated checks. # Tricky if one executes a cast check for a user-defined type as it # forces more dependencies. if child is CanvasItem: child.set_visible(false) child.show_on_top = true # If one does not wish to fail these checks without notifying users, # one can use an assert instead. These will trigger runtime errors # immediately if not true. assert(child.has_method("set_visible")) assert(child.is_in_group("offer")) assert(child is CanvasItem) # Can also use object labels to imply an interface, i.e. assume it # implements certain methods. # There are two types, both of which only exist for Nodes: Names and # Groups. # Assuming... # A "Quest" object exists and 1) that it can "complete" or "fail" and # that it will have text available before and after each state... # 1. Use a name. var quest = $Quest print(quest.text) quest.complete() # or quest.fail() print(quest.text) # implied new text content # 2. Use a group. for a_child in get_children(): if a_child.is_in_group("quest"): print(quest.text) quest.complete() # or quest.fail() print(quest.text) # implied new text content # Note that these interfaces are project-specific conventions the team # defines (which means documentation! But maybe worth it?). # Any script that conforms to the documented "interface" of the name or # group can fill in for it.
-
外包对Callable的访问。在需要最大限度地摆脱依赖性的情况下,这些可能很有用。在这种情况下,依赖于外部上下文来设置方法。
# child.gd extends Node var fn = null func my_method(): if fn: fn.call() # parent.gd extends Node @onready var child = $Child func _ready(): child.fn = print_me child.my_method() func print_me(): print(name)
这些策略有助于 Godot 的灵活设计。在它们之间,用户拥有广泛的工具来满足他们的特定需求。
Godot通知¶
Godot 中的每个对象都实现了一个 _notification方法。它的目的是让对象响应各种可能与其相关的引擎级回调。例如,如果引擎告诉 CanvasItem去“绘制”,它会调用 _notification(NOTIFICATION_DRAW)
.
其中一些通知(如绘制)对于在脚本中覆盖很有用。如此之多以至于 Godot 公开了许多具有专用功能的函数:
-
_ready()
:NOTIFICATION_READY -
_enter_tree()
:NOTIFICATION_ENTER_TREE -
_exit_tree()
:NOTIFICATION_EXIT_TREE -
_process(delta)
:NOTIFICATION_PROCESS -
_physics_process(delta)
:NOTIFICATION_PHYSICS_PROCESS -
_draw()
:NOTIFICATION_DRAW
用户可能没有意识到的是,通知存在于除 Node 之外的其他类型,例如:
-
Object::NOTIFICATION_POSTINITIALIZE:在对象初始化期间触发的回调。脚本无法访问。
-
Object::NOTIFICATION_PREDELETE:在引擎删除对象之前触发的回调,即“析构函数”。
Nodes 中确实存在的许多回调没有任何专用方法,但仍然非常有用。
-
Node::NOTIFICATION_PARENTED:任何时候将子节点添加到另一个节点时触发的回调。
-
Node::NOTIFICATION_UNPARENTED:任何时候从另一个节点删除子节点时触发的回调。
可以从通用 _notification
方法访问所有这些自定义通知。
注:文档中标记为“虚拟”的方法也旨在被脚本覆盖。
一个经典的例子是 Object 中的_init方法。虽然没有 NOTIFICATION_*
等效项,但引擎仍会调用该方法。大多数语言(C# 除外)都依赖它作为构造函数。
那么,在什么情况下应该使用这些通知或虚拟功能?
_process 与 _physics_process 与 *_input¶
_process
当需要帧间帧率相关的增量时间时使用。如果更新对象数据的代码需要尽可能频繁地更新,那么这是正确的地方。循环逻辑检查和数据缓存通常在这里执行,但这归结为需要评估更新的频率。如果他们不需要执行每一帧,那么实现定时器-让步-超时循环是另一种选择。
# Infinitely loop, but only execute whenever the Timer fires. # Allows for recurring operations that don't trigger script logic # every frame (or even every fixed frame). while true: my_method() $Timer.start() yield($Timer, "timeout")
_physics_process
当需要帧间帧率无关的增量时间时使用。如果代码需要随着时间的推移进行一致的更新,无论时间前进多快或多慢,这是正确的地方。循环运动学和对象变换操作应该在这里执行。
虽然有可能,但要获得最佳性能,应避免在这些回调期间进行输入检查。_process
并 _physics_process
会在每次机会时触发(默认情况下它们不会“休息”)。相反,*_input
回调只会在引擎实际检测到输入的帧上触发。
可以同样检查输入回调中的输入操作。如果要使用增量时间,可以根据需要从相关的增量时间方法中获取它。
# Called every frame, even when the engine detects no input. func _process(delta): if Input.is_action_just_pressed("ui_select"): print(delta) # Called during every input event. func _unhandled_input(event): match event.get_class(): "InputEventKey": if Input.is_action_just_pressed("ui_accept"): print(get_process_delta_time())
_init 与初始化与导出¶
如果脚本在没有场景的情况下初始化自己的节点子树,则该代码应在此处执行。其他属性或独立于 SceneTree 的初始化也应该在这里运行。_ready
这在or 之前触发_enter_tree
,但在脚本创建并初始化其属性之后触发。
脚本在实例化期间可以发生三种类型的属性分配:
# "one" is an "initialized value". These DO NOT trigger the setter. # If someone set the value as "two" from the Inspector, this would be an # "exported value". These DO trigger the setter. export(String) var test = "one" setget set_test func _init(): # "three" is an "init assignment value". # These DO NOT trigger the setter, but... test = "three" # These DO trigger the setter. Note the `self` prefix. self.test = "three" func set_test(value): test = value print("Setting: ", test)
实例化场景时,属性值将按照以下顺序设置:
-
初始值分配:实例化将分配初始化值或初始分配值。初始化赋值优先于初始化值。
-
导出值分配:如果从场景而不是脚本实例化,Godot 将分配导出值以替换脚本中定义的初始值。
因此,实例化脚本与场景将同时影响初始化和引擎调用 setter 的次数。
_ready 与 _enter_tree 与 NOTIFICATION_PARENTED¶
当实例化连接到第一个执行场景的场景时,Godot 将实例化树下的节点(进行调用_init
)并从根向下构建树。这会导致_enter_tree
调用向下级联树。一旦树完成,叶节点调用_ready
. 一旦所有子节点都完成调用它们的方法,节点将调用此方法。然后这会导致反向级联回到树的根部。
在实例化脚本或独立场景时,节点不会在创建时添加到 SceneTree,因此不会_enter_tree
触发回调。相反,只会_init
调用。当场景添加到 SceneTree 时,会发生_enter_tree
和_ready
调用。
如果需要触发作为另一个节点的父节点发生的行为,无论它是否作为主/活动场景的一部分发生,都可以使用 PARENTED通知。例如,这里是一个片段,它将节点的方法连接到父节点上的自定义信号而不会失败。对于可能在运行时创建的以数据为中心的节点很有用。
extends Node var parent_cache func connection_check(): return parent_cache.has_user_signal("interacted_with") func _notification(what): match what: NOTIFICATION_PARENTED: parent_cache = get_parent() if connection_check(): parent_cache.interacted_with.connect(_on_parent_interacted_with) NOTIFICATION_UNPARENTED: if connection_check(): parent_cache.interacted_with.disconnect(_on_parent_interacted_with) func _on_parent_interacted_with(): print("I'm reacting to my parent's interaction!")
数据偏好¶
有没有想过应该用数据结构 Y 还是 Z 来处理问题 X?本文涵盖了与这些困境相关的各种主题。
注:本文引用了“[something]-time”操作。这个术语来自算法分析的 Big O Notation。
长话短说,它描述了运行时长度的最坏情况。用外行的话来说:
“随着问题域规模的增加,算法的运行时间长度……”
-
恒定时间,
O(1)
:“......不增加。” -
对数时间,:“......以缓慢的速度增加。”
O(log n)
-
线性时间,
O(n)
:“......以相同的速度增加。” -
ETC。
想象一下,如果必须在一帧内处理 300 万个数据点。不可能使用线性时间算法来制作特征,因为数据的绝对大小会增加运行时间,远远超过分配的时间。相比之下,使用恒定时间算法可以毫无问题地处理该操作。
总的来说,开发人员希望尽可能避免参与线性时间操作。但是,如果将线性时间操作的规模保持在较小的范围内,并且不需要经常执行该操作,那么它可能是可以接受的。平衡这些要求并为工作选择正确的算法/数据结构是使程序员的技能有价值的部分原因。
数组与字典与对象¶
Godot 将脚本 API 中的所有变量存储在 Variant类中。Variants 可以存储与 Variant 兼容的数据结构,例如 Array和Dictionary以及Objects。
Godot 将 Array 实现为Vector<Variant>
. 引擎将数组内容存储在内存的连续部分中,即它们在一行中彼此相邻。
注:对于那些不熟悉 C++ 的人来说,Vector 是传统 C++ 库中数组对象的名称。它是一种“模板化”类型,这意味着它的记录只能包含特定类型(用尖括号表示)。因此,例如, PackedStringArray类似于Vector<String>
.
连续的内存存储意味着以下操作性能:
-
迭代:最快。非常适合循环。
-
Op:它所做的只是增加一个计数器以获取下一条记录。
-
-
插入、擦除、移动:位置相关。一般比较慢。
-
Op:添加/删除/移动内容涉及移动相邻的记录(以腾出空间/填充空间)。
-
从末尾快速添加/删除。
-
从任意位置缓慢添加/删除。
-
从前面添加/删除最慢。
-
如果从前面进行多次插入/移除,那么......
-
反转数组。
-
做一个循环,在最后执行数组更改。
-
重新反转数组。
这只会复制数组的 2 个副本(仍然是常数时间,但速度较慢),而复制大约 1/2 的数组,平均而言,N 次(线性时间)。
-
-
-
获取、设置:按位置最快。例如可以请求第 0 条、第 2 条、第 10 条记录等,但不能指定您想要的记录。
-
Op:从数组起始位置到所需索引的 1 次加法运算。
-
-
查找:最慢。标识值的索引/位置。
-
Op:必须遍历数组并比较值,直到找到匹配项。
-
性能还取决于是否需要穷举搜索。
-
-
如果保持有序,自定义搜索操作可以使其达到对数时间(相对较快)。不过,外行用户不会对此感到满意。通过在每次编辑后重新排序数组并编写一个有序感知搜索算法来完成。
-
Godot 将 Dictionary 实现为. 引擎存储键值对的小型数组(初始化为 2^3 或 8 条记录)。当一个人试图访问一个值时,他们会为它提供一个键。然后它对密钥进行哈希处理,即将其转换为数字。“哈希”用于计算数组中的索引。作为一个数组,OHM 然后在映射到值的键“表”中进行快速查找。当 HashMap 变得太满时,它会增加到 2 的下一个幂(因此,16 条记录,然后 32 条,等等)并重建结构。OrderedHashMap<Variant, Variant>
哈希是为了减少键冲突的机会。如果发生这种情况,该表必须重新计算另一个索引以获取考虑到先前位置的值。总而言之,这导致以内存和一些次要操作效率为代价对所有记录进行恒定时间访问。
-
对每个键进行任意次数的哈希处理。
-
哈希运算是恒定时间的,所以即使一个算法必须做不止一次,只要哈希计算的次数不太依赖于表的密度,事情就会保持快速。这导致...
-
-
保持表的大小不断增长。
-
HashMaps 有意保持散布在表中的未使用内存的间隙,以减少哈希冲突并保持访问速度。这就是为什么它的大小不断以 2 的幂的二次方增长。
-
正如人们可能会说的那样,字典专注于数组不擅长的任务。他们的操作细节概述如下:
-
迭代:快速。
-
Op:迭代地图的内部哈希向量。返回每个键。之后,用户然后使用键跳转到并返回所需的值。
-
-
插入、擦除、移动:最快。
-
Op:散列给定的密钥。执行 1 次加法运算以查找适当的值(数组开始 + 偏移量)。移动是其中的两个(一个插入,一个擦除)。地图必须进行一些维护以保持其功能:
-
更新有序的记录列表。
-
确定表密度是否要求需要扩展表容量。
-
-
字典会记住用户插入其键的顺序。这使它能够执行可靠的迭代。
-
-
获取、设置:最快。与按键查找相同。
-
Op:与插入/擦除/移动相同。
-
-
查找:最慢。标识值的键。
-
Op:必须遍历记录并比较值,直到找到匹配项。
-
请注意,Godot 不提供开箱即用的此功能(因为它们不适用于此任务)。
-
Godot 将对象实现为愚蠢但动态的数据内容容器。对象在提出问题时查询数据源。例如,要回答“你有一个名为‘position’的属性吗?”这个问题,它可能会询问它的脚本或ClassDB。人们可以在 Godot 中应用面向对象的原则一文中找到有关对象是什么以及它们如何工作的更多信息。
这里的重要细节是对象任务的复杂性。每次执行这些多源查询之一时,它都会运行几个 迭代循环和 HashMap 查找。此外,查询是线性时间操作,具体取决于对象的继承层次结构大小。如果 Object 查询的类(它的当前类)没有找到任何东西,请求将推迟到下一个基类,一直到原始 Object 类。虽然这些都是孤立的快速操作,但它必须进行如此多的检查这一事实使得它们比查找数据的两种替代方法都慢。
注:当开发人员提到脚本 API 有多慢时,他们指的就是这个查询链。与应用程序确切知道去哪里寻找任何东西的编译 C++ 代码相比,脚本 API 操作不可避免地会花费更长的时间。他们必须先找到任何相关数据的来源,然后才能尝试访问它。
GDScript 之所以慢是因为它执行的每个操作都经过这个系统。
C# 可以通过更优化的字节码以更高的速度处理一些内容。但是,如果 C# 脚本调用引擎类的内容,或者如果脚本试图访问它外部的内容,它将通过此管道。
NativeScript C++ 走得更远,默认情况下将所有内容保留在内部。对外部结构的调用将通过脚本 API。在 NativeScript C++ 中,注册方法以将它们公开给脚本 API 是一项手动任务。正是在这一点上,外部的非 C++ 类将使用 API 来定位它们。
因此,假设一个从引用扩展来创建一个数据结构,如数组或字典,为什么选择一个对象而不是其他两个选项?
-
控制:随着对象而来的是创建更复杂结构的能力。可以对数据进行分层抽象,以确保外部 API 不会随着内部数据结构的变化而变化。更重要的是,对象可以有信号,允许反应行为。
-
清晰:当涉及脚本和引擎类为它们定义的数据时,对象是可靠的数据源。属性可能不包含我们期望的值,但您无需担心该属性是否存在。
-
方便:如果一个人已经想到了类似的数据结构,那么从现有的类进行扩展会使构建数据结构的任务变得更加容易。相比之下,数组和字典并不能满足人们可能拥有的所有用例。
对象还使用户有机会创建更专业的数据结构。有了它,人们可以设计自己的列表、二叉搜索树、堆、展开树、图、不相交集和任何其他选项。
“为什么不将 Node 用于树结构?” 有人可能会问。好吧,Node 类包含与自定义数据结构无关的内容。因此,在构建树结构时构建自己的节点类型会很有帮助。
extends Object class_name TreeNode var _parent: TreeNode = null var _children: = [] setget func _notification(p_what): match p_what: NOTIFICATION_PREDELETE: # Destructor. for a_child in _children: a_child.free()
从这里,人们可以创建自己的具有特定功能的结构,仅受他们想象力的限制。
枚举:int 与 string¶
大多数语言都提供枚举类型选项。GDScript 没有什么不同,但与大多数其他语言不同,它允许使用整数或字符串作为枚举值(后者仅在使用export
GDScript 中的关键字时)。那么问题来了,“应该使用哪个?”
简短的回答是,“你觉得哪个更舒服”。这是 GDScript 特有的特性,而不是一般的 Godot 脚本;这些语言将可用性优先于性能。
在技术层面上,整数比较(恒定时间)将比字符串比较(线性时间)发生得更快。如果想保持其他语言的惯例,那么应该使用整数。
当想要打印枚举 值时,会出现使用整数的主要问题。作为整数,尝试打印 MY_ENUM 将打印 5
或你有什么,而不是像"MyEnum"
. 要打印整数枚举,必须编写一个字典来映射每个枚举的相应字符串值。
如果使用枚举的主要目的是打印值,并且希望将它们组合在一起作为相关概念,那么将它们用作字符串是有意义的。这样,就不需要单独的数据结构来执行打印。
AnimatedTexture vs. AnimatedSprite2D vs. AnimationPlayer vs. AnimationTree¶
在什么情况下应该使用 Godot 的每个动画类?新的 Godot 用户可能不会立即清楚答案。
AnimatedTexture是引擎绘制为动画循环而不是静态图像的纹理。用户可以操纵...
-
它在纹理的每个部分移动的速率 (fps)。
-
纹理(帧)中包含的区域数。
Godot 的RenderingServer然后按照规定的速率依次绘制区域。好消息是这不涉及引擎部分的额外逻辑。坏消息是用户几乎没有控制权。
另请注意,与此处讨论的其他节点对象不同, AnimatedTexture 是一种资源。可以创建一个使用 AnimatedTexture 作为其纹理的Sprite2D节点。或者(其他人无法做到的事情)可以将 AnimatedTextures 添加为 TileSet 中的图块,并将其与 TileMap集成以用于许多自动动画背景,这些背景都在单个批处理绘制调用中呈现。
AnimatedSprite2D 节点与 SpriteFrames资源相结合,允许人们通过 spritesheet 创建各种动画序列,在动画之间翻转,并控制它们的速度、区域偏移和方向。这使它们非常适合控制基于 2D 帧的动画。
如果需要触发与动画变化相关的其他效果(例如,创建粒子效果、调用函数或操作除基于帧的动画之外的其他外围元素),则需要将 AnimationPlayer 节点与 AnimatedSprite2D 结合使用。
如果希望设计更复杂的 2D 动画系统,例如,AnimationPlayer 也是需要使用的工具。
-
剪切动画:在运行时编辑精灵的变换。
-
2D 网格动画:为精灵的纹理定义区域并为其装配骨架。然后制作骨骼动画,根据骨骼之间的关系拉伸和弯曲纹理。
-
以上的混合。
虽然需要一个 AnimationPlayer 来为游戏设计每个单独的动画序列,但组合动画以进行混合也很有用,即在这些动画之间实现平滑过渡。为对象设计的动画之间也可能存在层次结构。这些是AnimationTree大放异彩的地方 。可以在此处找到有关使用 AnimationTree 的深入指南 。
逻辑偏好¶
有没有想过应该用策略 Y 还是策略 Z 来解决问题 X?本文涵盖了与这些困境相关的各种主题。
添加节点和更改属性:哪个先?¶
在运行时从脚本初始化节点时,您可能需要更改节点名称或位置等属性。一个常见的难题是,您应该何时更改这些值?
最佳做法是在将节点添加到场景树之前更改节点上的值。某些属性的 setter 具有更新其他相应值的代码,并且该代码可能很慢!在大多数情况下,此代码不会影响您的游戏性能,但在程序生成等大量使用情况下,它会使您的游戏变得缓慢。
由于这些原因,最佳做法始终是在将节点添加到场景树之前设置节点的初始值。
加载与预加载¶
在 GDScript 中,存在全局 预加载方法。它会尽早加载资源以提前加载“加载”操作并避免在性能敏感代码中间加载资源。
它的对应物load方法仅在到达 load 语句时才加载资源。也就是说,它将就地加载资源,当它发生在敏感进程中间时可能会导致速度减慢。该函数也是所有脚本语言都可以访问的ResourceLoader.load(path)load
的别名 。
那么,预加载和加载到底什么时候发生,什么时候应该使用其中之一?让我们看一个例子:
# my_buildings.gd extends Node # Note how constant scripts/scenes have a different naming scheme than # their property variants. # This value is a constant, so it spawns when the Script object loads. # The script is preloading the value. The advantage here is that the editor # can offer autocompletion since it must be a static path. const BuildingScn = preload("res://building.tscn") # 1. The script preloads the value, so it will load as a dependency # of the 'my_buildings.gd' script file. But, because this is a # property rather than a constant, the object won't copy the preloaded # PackedScene resource into the property until the script instantiates # with .new(). # # 2. The preloaded value is inaccessible from the Script object alone. As # such, preloading the value here actually does not benefit anyone. # # 3. Because the user exports the value, if this script stored on # a node in a scene file, the scene instantiation code will overwrite the # preloaded initial value anyway (wasting it). It's usually better to # provide null, empty, or otherwise invalid default values for exports. # # 4. It is when one instantiates this script on its own with .new() that # one will load "office.tscn" rather than the exported value. export(PackedScene) var a_building = preload("office.tscn") # Uh oh! This results in an error! # One must assign constant values to constants. Because `load` performs a # runtime lookup by its very nature, one cannot use it to initialize a # constant. const OfficeScn = load("res://office.tscn") # Successfully loads and only when one instantiates the script! Yay! var office_scn = load("res://office.tscn")
预加载允许脚本在加载脚本时处理所有加载。预加载很有用,但也有不希望的时候。要区分这些情况,可以考虑以下几点:
-
如果无法确定脚本何时加载,那么预加载资源(尤其是场景或脚本)可能会导致超出预期的进一步加载。这可能会在原始脚本的加载操作之上导致无意的、可变长度的加载时间。
-
如果其他东西可以替换该值(如场景的导出初始化),那么预加载该值就没有意义。如果打算始终自己创建脚本,这一点不是重要因素。
-
如果只希望“导入”另一个类资源(脚本或场景),那么使用预加载常量通常是最好的做法。然而,在特殊情况下,人们可能不希望这样做:
-
如果“导入的”类易于更改,那么它应该是一个属性,使用 an
export
或 a初始化load
(甚至可能直到稍后才初始化)。 -
如果脚本需要很多依赖,又不想消耗那么多内存,那么可能希望在运行时根据情况的变化加载和卸载各种依赖。如果将资源预加载到常量中,那么卸载这些资源的唯一方法就是卸载整个脚本。如果它们是加载的属性,则可以将它们设置为
null
并完全删除对资源的所有引用(作为 RefCounted扩展类型,这将导致资源从内存中删除它们自己)。
-
大级别:静态与动态¶
如果要创建一个大关卡,哪种情况最合适?他们应该将关卡创建为一个静态空间吗?或者他们应该分段加载关卡并根据需要移动世界内容?
好吧,简单的答案是,“当性能需要时”。与这两个选项相关的困境是一个古老的编程选择:一个是优化内存而不是速度,还是相反?
天真的答案是使用一次加载所有内容的静态关卡。但是,根据项目的不同,这可能会消耗大量内存。浪费用户的 RAM 会导致程序运行缓慢或因计算机同时尝试执行的所有其他操作而彻底崩溃。
无论如何,应该将较大的场景分解成较小的场景(以帮助资产的可重用性)。然后开发人员可以设计一个节点来实时管理资源和节点的创建/加载和删除/卸载。具有大型多变环境或程序生成元素的游戏通常会实施这些策略以避免浪费内存。
另一方面,动态系统的编码更复杂,即使用更多的编程逻辑,这会导致出现错误和错误的机会。如果不小心,他们可能会开发一个系统,使应用程序的技术债务膨胀。
因此,最好的选择是……
-
为小型游戏使用静态关卡。
-
如果有人有时间/资源玩中型/大型游戏,请创建一个库或插件来对节点和资源的管理进行编码。如果随着时间的推移进行改进,以提高可用性和稳定性,那么它可以演变成跨项目的可靠工具。
-
为中型/大型游戏编写动态逻辑代码,因为一个人具有编码技能,但没有时间或资源来完善代码(游戏必须完成)。以后可能会重构以将代码外包到插件中。
有关在运行时交换场景的各种方式的示例,请参阅“手动更改场景” 文档。
项目组织¶
介绍¶
由于 Godot 对项目结构或文件系统的使用没有限制,因此在学习引擎时组织文件似乎具有挑战性。本教程建议的工作流程应该是一个很好的起点。我们还将介绍如何使用 Godot 进行版本控制。
组织¶
Godot 本质上是基于场景的,并且按原样使用文件系统,没有元数据或资产数据库。
与其他引擎不同,许多资源都包含在场景本身中,因此文件系统中的文件数量要少得多。
考虑到这一点,最常见的方法是尽可能靠近场景对资产进行分组;当项目增长时,它会使其更易于维护。
例如,人们通常可以将他们的基本资产(例如精灵图像、3D 模型网格、材料和音乐等)放在一个文件夹中。然后他们可以使用一个单独的文件夹来存储使用它们的构建关卡。
/project.godot /docs/.gdignore # See "Ignoring specific folders" below /docs/learning.html /models/town/house/house.dae /models/town/house/window.png /models/town/house/door.png /characters/player/cubio.dae /characters/player/cubio.png /characters/enemies/goblin/goblin.dae /characters/enemies/goblin/goblin.png /characters/npcs/suzanne/suzanne.dae /characters/npcs/suzanne/suzanne.png /levels/riverdale/riverdale.scn
时尚指南¶
为了跨项目的一致性,我们建议遵循以下准则:
-
使用snake_case作为文件夹和文件名(C# 脚本除外)。这避免了在 Windows 上导出项目后可能出现的区分大小写问题。C# 脚本是此规则的一个例外,因为约定是在应该使用 PascalCase 的类名之后命名它们。
-
对节点名称使用PascalCase,因为这与内置的节点大小写相匹配。
-
通常,将第三方资源保存在顶级
addons/
文件夹中,即使它们不是编辑器插件。这样可以更轻松地跟踪哪些文件是第三方文件。这条规则有一些例外;例如,如果您为角色使用第三方游戏资产,将它们包含在与角色场景和脚本相同的文件夹中会更有意义。
输入¶
Godot 3.0 之前的版本从项目外部的文件执行导入过程。虽然这在大型项目中很有用,但它给大多数开发人员带来了组织上的麻烦。
因此,现在可以从项目文件夹中透明地导入资产。
忽略特定文件夹¶
要防止 Godot 导入包含在特定文件夹中的文件,请在该文件夹中创建一个名为的空文件(需要.gdignore
前导)。.
这对于加快初始项目导入速度很有用。
笔记
要在 Windows 上创建名称以点开头的文件,您可以使用文本编辑器(如 Notepad++)或在命令提示符下使用以下命令:type nul > .gdignore
一旦文件夹被忽略,该文件夹中的资源就不能再使用load()
和preload()
方法加载。忽略文件夹也会自动将其从文件系统停靠栏中隐藏起来,这对于减少混乱很有用。
请注意,.gdignore
文件的内容将被忽略,这就是文件应该为空的原因。它不像.gitignore
文件那样支持模式。
区分大小写¶
Windows 和最近的 macOS 版本默认使用不区分大小写的文件系统,而 Linux 发行版默认使用区分大小写的文件系统。这可能会在导出项目后导致问题,因为 Godot 的 PCK 虚拟文件系统区分大小写。为避免这种情况,建议坚持 snake_case
为项目中的所有文件命名(通常使用小写字符)。
注:当风格指南另有说明时(例如 C# 风格指南),您可以违反此规则。尽管如此,还是要保持一致以避免错误。
在 Windows 10 上,为进一步避免与区分大小写相关的错误,您还可以使项目文件夹区分大小写。启用适用于 Linux 的 Windows 子系统功能后,在 PowerShell 窗口中运行以下命令:
# To enable case-sensitivity: fsutil file setcasesensitiveinfo <path to project folder> enable # To disable case-sensitivity: fsutil file setcasesensitiveinfo <path to project folder> disable
如果您还没有为 Linux 启用 Windows 子系统,您可以在以管理员身份运行的PowerShell 窗口中输入以下行,然后在询问时重新启动:
Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux
版本控制系统¶
介绍¶
Godot 的目标是对 VCS 友好,并生成大部分可读和可合并的文件。Godot 还支持在编辑器中使用版本控制系统。但是,编辑器中的 VCS 需要针对您正在使用的特定 VCS 的插件。可以在Project > Version Control下的编辑器中设置或关闭 VCS 。
官方 Git 插件¶
官方插件支持从编辑器内部使用 Git。您可以在此处找到最新版本 。可以在此处找到有关如何使用 Git 插件的文档 。
要从 VCS 中排除的文件¶
Godot 会自动创建一些文件和文件夹。您应该将它们添加到您的 VCS 忽略:
-
.godot/
:该文件夹存放各种项目缓存数据。.godot/imported/
存储引擎根据您的源资产及其导入标志自动导入的所有文件。.godot/editor/
保存有关编辑器状态的数据,例如当前打开的脚本文件和最近使用的节点。 -
*.translation
:这些文件是从 CSV 文件生成的二进制导入翻译。 -
export_presets.cfg
:此文件包含项目的所有导出预设,包括敏感信息,例如 Android 密钥库凭据。 -
.mono/
:此文件夹存储自动生成的单声道文件。它只存在于使用 Mono 版本的 Godot 的项目中。
注:将此 .gitignore 文件保存 在项目的根文件夹中以自动设置文件排除项。
在 Windows 上使用 Git¶
大多数 Git for Windows 客户端都配置core.autocrlf
为true
. 这可能会导致文件被 Git 不必要地标记为已修改,因为它们的行尾会自动转换。最好将此选项设置为:
git config --global core.autocrlf input
已知的问题¶
运行git pull
前总是关闭编辑器!否则, 如果在编辑器打开时同步文件,您可能会丢失数据。