【随缘更新】Godot 小知识
不如说是踩坑记录。 1. export NodePath 和 export Node 的区别乍一看从编辑器体验上两者没有区别,而且后者写代码更方便,似乎是前者的上位替代,但其实这俩有点区别。
NodePath 变量存的是相对路径,而 Node 变量存的则是绝对的 Node
当你想要 duplicate 一整个 Node 的时候,区别就体现出来了:NodePath 变量在 duplicate 之后存的仍然是相对路径,而 Node 变量在 duplicate 之后,指向的仍然是 duplicate 之前指向的那个老 Node
总之,在使用相关功能的时候,请衡量自己想要的到底是相对路径关系,还是绝对的引用。 2. 碰撞检测时,想好是 A 检测 B 还是 B 检测 A
让大量的砖块每个单独去检测有没有受到攻击,和让攻击判定去检测有没有碰到砖块,这两种方式的性能差异是巨大的
通常来说,一定要想好哪些对象更加适合作为发起碰撞检测的一方,不要怕麻烦,尤其是砖块特别多的时候
这一条不仅限于 Godot,任何游戏引擎都需要注意此问题。 3. 通过 metadata 进行通信
试想下列情形:你为了不把逻辑和物理耦合在一块,选择通过子节点 C 来操控物理根节点 A
现在,你希望另一个物理节点 B 与节点 A 发生碰撞时,调用其子节点 C 的相关函数
但是,物理节点 B 只能得到节点 A 的引用,并不能直接得到子节点 C 的引用
你可能首先想到了 get_node(path_to_c),但这样一来,你又把节点树的结构耦合到了一起
metadata 可以解决这个难题,其实这就是 godot node 都有的一个 Dictionary<string, Variant>
只需要在子节点 C 初始化时,将其自身的引用存储到 A 的 metadata 中
B 就可以在拿到节点 A 之后,通过 metadata 作进一步判断,以及拿到节点 C 的引用 dasasdhba 发表于 2024-5-14 23:40
3. 通过 metadata 进行通信
试想下列情形:你为了不把逻辑和物理耦合在一块,选择通过子节点 C 来操控物理 ...
其实根据官方对SceneTree优化的表述,结合metadata存引用这个特性,可以在初始化组件节点C的时候顺带把组件节点C移出场景树(其实就是主循环要遍历的节点变少了,自然便流畅了)
需要注意的是:上述操作只有在组件节点C里没有_process()或_physics_process()时才能保证安全,不然后两者在节点树外是没法运作的。
另外,如果只是将其移出节点树外,那么在中心节点A调用queue_free()的时候,组件节点C并不会被删除,这个时候可以下代码:
func _ready() -> void:
var par := get_parent()
assert(par != null, "The component %s does not have a operatee!" % name)
par.tree_exited.connect(func() -> void:
if par.is_queue_deletion():
queue_free()
)
不过上述代码也有个缺陷,就是如果用户是通过free()来删除节点的话那么is_queue_deletion()就会不起作用,从而导致组件无法删除,残留在内存当中,造成内存泄漏。这个时候最好的方法就是自己写一个delete()静态方法,在删除对象的同时也删除掉与其相关联的组件 4. 物理引擎有时候更靠谱
反直觉的事情是,大量调用 Rect2.HasPoint 这种看似仅有四个不等式就能解决的位置判断,其带来的性能问题远远大于物理引擎的重叠测试。不过使用物理引擎处理这种需求时要注意调整最大查询次数。 5. 谨慎动态修改 Resource
正常情况下,Resource 都是共享的,这意味着以下几个常见的事情:
a. Shader Material 是 Resource,也就意味着 Shader Parameter 不能看作独立参数
b. Shape 是 Resource,也就意味着 CollisionShape2D 的 Shape Size 等参数不能看作独立参数
c. ...
总之,若需要动态调整 Resource,请勿忘在 Ready 时将其 Duplicate 一份(建议用 Duplicate(true)) 6. C# 自定义 Signal 实际上不依赖 Godot Signal
所谓依赖 Godot Signal,即是使用了 Godot 的 Connect/Disconnect,好处在于参与连接的相关对象只要被 free 了之后便会自动断开连接。
Godot 自带 Signal 都是如此封装成为 C# event,而自定义的 C# Signal 生成的 event 却完全是采用了 C# 的 delegate,并不依赖 Godot Signal,这导致巨大的问题:
通过 += 订阅的自定义 Signal,不会随着相关对象 free 掉之后被自动取消订阅;反直觉的是,自带 Signal 有这份优待,如此不统一的行为太容易造成误解。可笑的是,我在 C# 自定义 Signal,参数还需要兼容 Godot Variant,却没有换来任何好处。尽管依旧可以使用 Godot Connect,但是这又依赖 Godot Callable,写起来极为不便。
本人最终的解决方案是修改 Godot C# 相关源码,将自定义 Signal 封装的 event 也采用 Connect 实现,并无出现任何问题,不懂官方此举是作何考量。 本帖最后由 囿里有条小咸鱼 于 2024-6-3 00:17 编辑
我也补充一个吧:
7.切换CollisionShape2D的碰撞形状最好直接更换shape,而不是弄多个CollisionShape2Ds然后设置disabled属性。主要是节点多了看着也不舒服。同时,直接更改shape也减少了节点数量,一定程度上提升程序性能。
但是,不管是设置disabled还是更改shape,如果当前对象在Area2D里的话,都会导致导致该对象被Area2D判定一次退出和一次进入,对于只需要判定一次进出区域的代码来说处理起来会麻烦一点(更新:如果是在物理帧循环里更改shape的话是不会出现这个问题的,但设置disabled还是会出现该问题)
当然既然提到切换shape了那就再多嘴一句,建议配合AnimationPlayer使用,直观而且耦合度较低,非常适合做一些可视状态机类的效果。
8. 粒子无法继承粒子发射器的旋转和缩放
这个godot官方有人已经提出未来会增加对该效果的支持了,具体什么时候实装就看godot社区有没有人愿意提pr了 本帖最后由 dasasdhba 于 2024-6-12 02:00 编辑
9. 小心使用 Array<Node>
当 Array<Node> 里边的 Node 被 free 掉之后,再试图从 Array 访问会出问题,建议在相关 Node 离开树的时候自动将其从 Array 中移除。用 try catch exception 可能也行,不过我不是很推荐。
具体来说,你不能指望通过 is_instance_valid 来将 free 掉的 Node 从 Array 中移除,只能通过给这些 node 的 tree_exited 信号添加一个从 Array 中将其移除的操作。
本帖最后由 囿里有条小咸鱼 于 2024-6-26 17:29 编辑
10:记得用is_instance_valid()保证物件有效性,提升代码安全度
其实是接着das发的第9条补充的。
先解释一下:在Godot中,如果你对一个节点实例调用了用free(),或者该节点的queue_free()已经生效,且如果该节点有被其他脚本有所引用,那么其他脚本对该节点的引用并不会变成null,而是会变成“previously freed object”,在4.3版本及之前的版本,该值与null进行比较时会返回true,但从4.3版本开始,该值与null比较则会返回false。如果此时下方代码中正好有会调用到这个已被销毁的节点的代码片段,那么在程序运行时会导致报错。
针对不同的防治方向,在此给出以下两种解决方法:
1.在过程中防止出现这种现象:即使用if is_instance_valid(xxx),该函数会在对象xxx为null或者其他任何无效值(包括previously freed object)时为false,检测节点xxx是否已被销毁。将不安全代码放在这条语句下进行保护:
var node: = Node.new()
node.free()
if is_instance_valid(node):
print("Valid")
else:
print("Invalid")
# 结果为Invalid2.从源头上防止这种情况,其实就是对引用该属性添加自定义取值函数(getter),配合利用is_instance_valid(xxx)与三目运算符来让返回结果可靠——要么为对该对象的引用,要么为空引用null
var node:= Node.new():
get: return node if is_instance_valid(node) else null这样,只要节点无效,也会被视为null来处理而非previously freed object 本帖最后由 囿里有条小咸鱼 于 2024-6-27 16:56 编辑
11. RefCounted
RefCounted是Godot自行开发的一套可回收对象,其实是依靠其内部的引用计数来完成的。当对象被其他对象的属性引用,或者引用其他对象时,该对象的引用计数+1,当其他属性解除对该对象的引用时,或者该对象解除对其他对象的引用时,该对象的引用计数-1,引用计数达到0时该对象会被立即销毁(没有延迟,这点切记!)。Resource是RefCounted的子类之一。
RefCounted的引用计数机制具体如下:
[*]被强引用时,比如被成员/局部变量引用,或被函数参数引用时,该对象的引用计数+1
[*]强引用其他对象时,该对象的引用计数+1
[*]被弱引用时,即通过weakref()函数而被引用时,该对象的引用计数不变
[*]被解除引用时,比如原本引用该对象的成员/局部变量/参数改引用了其他对象或赋值为null时,该对象的引用计数-1
[*]解除对其他对象的强引用时,该对象的引用计数-1
[*]一函数内的局部变量/参数强引用了RefCounted,但达到了作用域尽头,结束了其作用生命,则该RefCounted的引用计数-1
[*]引用该对象的其他对象被删除时,该对象的引用计数-1
比较特殊的时通过弱引用,虽然提到RefCounted会在引用计数为0时被立即销毁,但弱引用是个例外,会在引用方脱离作用域时销毁,比如下面这个例子:
var member = null
... # 在某个方法内
member =weakref(RefCounted.new())为什么要讲这个呢,其实涉及到两个比较重要的概念:循环引用和不可删性
首先讲不可删性吧,顾名思义,RefCounted对象及其实例(包括其子类及其子类的实例)无法通过free()销毁,否则会报错。这一点是出于安全性考虑才这样设计的,所以用的时候一定要注意通过解引用的方式让它自行销毁!
然后就是Godot里最大头的问题:循环引用
先解释下什么是循环引用,其实就是RefCounted A里有东西持续强引用RefCounted B里的东西,反之亦然,这就导致了二者的引用计数均为2。如果不处理好,到最后这两个资源会因为引用计数不为0而被永远滞留在内存里,直到退出应用程序,也就是会导致内存泄漏(Memory Leak),这是非常可怕的。所以建议:如果要对RefCounted及其子类实例进行引用,尤其是在节点里引用一个RefCounted,而RefCounted里又引用了该节点这种情况。如果只是删除了节点,则只会导致RefCounted的引用计数降为1,却并不能将其删除。如果想要通过引用计数机制将其删除,请在节点删除的同时解除该RefCounted对该节点的引用。
本帖最后由 囿里有条小咸鱼 于 2024-7-3 10:15 编辑
12. 组合有时候比继承更好用
其实这一点应该算是涉及到面向对象设计的部分了吧。一般来说,我们如果想要扩展一个类的功能,最好的方式就是继承,对吧。但考虑下下面这个情况:
class_name Animal
extends Node
var type: String
var age: int
func sleep() -> void:
print("I am sleeping")
func eat() -> void:
print("I'm eating")
func walk() -> void:
print("I'm walking")
func swim() -> void:
print("I'm swimming")这里我们构造了一个叫Animal的类,作为所有动物类的抽象,包含type和age这两个属性,以及sleep()、eat()、walk()和swim()这四个方法。那么,如果我们让狗去继承这个类,还是比较合理的,你甚至可以在狗这个类的基础上继续特化狗这个实例的抽象。然而,假如你让一个不会游泳的动物去继承这个类,然后你就会发现:这个动物不是不会游泳吗?!为什么它能游泳呢?!
事实上,与其这样问,不如看看Animals这个类的构造再说吧:你看,你把swim()都定义在Animals这个大基类上了,这下岂不是所有动物都会游泳?!假如以后再加个会飞的动物,你难不成还要把fly()函数也写进Animals这个类里?!想想都不合理吧……于是,聪明的你想到了:我把swim()这个方法只分给那些会游泳的不就行了嘛。确实,这是个好方法,但每当你要新引入一个会游泳的动物时,都要复制粘贴一样的代码,这不费劲嘛?于是,聪明的你又想到了:干脆我再构造一个专门会游泳的动物类得了。确实,只要会游泳的动物,都可以继承这个类,而且swim()本身就在你新构造的类里面,省去了复制粘贴代码的功夫。但与此同时,你又想到了:现实世界里还会有会飞的动物,那么你如法炮制,让会飞的动物类继承Animal这个大基类。但你有没有想过:这世界上还有既会飞又会游泳的动物呢?于是你又如法炮制,做了个既会飞又会游泳的动物类,但这次你却又把游泳的方法和飞行的方法复制粘贴了一遍……假如这两个方法代码都特别长,难道说每改一次都要复制粘贴一次吗?如果项目越做越大,那岂不是到最后你连要改哪个地方都忘了……最后的结果嘛不言而喻:推倒重做!
那么问题究竟出在哪里?我们还需要回过来分析问题本身:动物,有会飞的、会游泳的。而飞和游泳是什么?是行为。既然如此,那么为什么我们就不能把行为作为一种可复用公用模块提取出来呢?这种可复用、可供其他类/对象使用的模块就是组件(Component)。而这种手段我们就叫作组合(Composition)。组合的好处就在于它能够针对上述情况进行完美解决,通过提取函数,将函数作为一种模块/插件,哪个类需要它,哪个类就去实现(Implement)它就行了,不会导致非通用函数冗余的情况。
目前的主流编程语言都有一个叫做接口(Interface)的概念,它其实就是构成组合的重要手段之一。接口实际上就是定义一组抽象方法和抽象属性,然后让类去进行实现,这样其他类想要调用共同的一个函数的时候,就可以通过这个接口去调用实现该接口的类的方法。话虽如此,接口大多数情况下还是起着一个规范的作用。假如我们用接口这种逻辑去重构上述的代码,那么思路就是:将飞和游泳分别抽象成两种接口,一种操作飞相关的行为,一种操作游泳相关的行为。而接口呢,还能定义多个函数,这样,你还能继续细化飞/游泳的方式,比如俯冲飞行/蛙泳/狗刨之类的。但缺点就是,如果接口方法是抽象方法,那么每次修改该接口抽象的时候,你都要对每个实现该方法的类都改一遍。
然而Godot目前并没有添加类似的语法。虽然如此,但凭借Godot强大的节点树结构,我们便得到了第二种组合的方式:对象组件(Object component)。顾名思义,就是在Godot中让一个直接子节点去充当组件的功能。因为节点可以通过get_parent()获取直接父节点,所以可以通过变量来获取对直接父节点的引用,这样我们就可以直接将直接父节点视为实现者(作用节点/代理节点),将组件节点视为被实现者(源节点/委托节点)。按照这个逻辑,我们可以新建两个节点,分别为Flying和Swimming,然后分别对两个节点添加脚本,内容分别为:
class_name Flying
extends Node
func soar() -> void:
print("Soaring!")
func fly() -> void:
print("Flying!")
func swoop() -> void:
print("Swooping!")class_name Swimming
extends Node
func swim() -> void:
print("I am swimming")
func breaststroke() -> void:
print("I am breaststroking")然后,针对不同的动物,将这些节点对应添加为对应动物的子节点即可,别忘了用变量在_ready()函数里(或者用@onready变量)存储对父节点的引用!
当然,得益于Godot强大的节点保存功能,你还可以直接把这些组件节点保存为一个个场景,在需要的时候直接拖出来放到实现者节点下方作为其直接子节点即可。
组合是针对继承(尤其是单继承)可能会产生的交叉问题而设计的一种最佳实践,善用组合可以让你的程序组织更有条理、更有逻辑。
13. 莫名奇妙的换行问题
如果你发现 Label/RichTextLabel 等组件的自动换行在测试的时候正常,导出之后却不正常的话,尝试做以下设置:
https://s21.ax1x.com/2024/09/14/pAumkJx.png
我不懂也不想懂原理,但这样确实解决了我的问题。 14. Parallax2D 不靠谱
4.3 新出的 Parallax2D 用起来确实方便,但在打开了 Snap 2D Transforms to Pixel 的情况下,Camera 不做特殊处理会有明显抖动(做了特殊处理的话 Camera 也不流畅)。ParallaxBackground + ParallaxLayer 的方式没有此问题,故建议像素风的项目暂时不要考虑 4.3 的这个新功能。
15. RigidBody 的碰撞法线测试不靠谱
测试环境:矩形(Rectangle)碰撞箱,添加重力正交方向的常数力
结果:在平地上运动测出的碰撞法线偶尔会出现 (1, 0),换句话说非常离谱地误判成了撞墙。
只能说这个 Body 没有像 CharacterBody 那样提供 is_on_floor() 之类的 API 是有原因的,因为压根就不靠谱。
注:将重力接触面(即底部)替换为圆角底面(Capsule)似乎可以有效缓解这个问题。 16. 给PhysicsBody2D加重力的话,就用get_gravity()吧
相较于传统的velocity.y += g * delta,get_gravity()可以获取物体实际所受到的重力,返回的不是一个浮点数值,而是一个向量,向量大小及方向为所受合重力的大小和方向,比传统的velocity.y += g * delta更具有一定的优势,对360°重力方向的平台游戏开发者而言无非是一个十分强力的存在。
但请注意:这个方法只能在_physics_process()中可以稳定返回准确值,在_process()中可能会不稳定。此外,只有在当前PhysicsBody2D在场景树内且物理就绪才能调用该方法。 本帖最后由 囿里有条小咸鱼 于 2024-10-28 00:04 编辑
17. GDScript中lambda表达式的一些坑很多刚上手gdscript的开发者一不小心便容易掉进lambda表达式(一般是匿名函数)的坑里。试看下面这段lambda表达式:
var a = 1
var u = func():
print("First a: %s" % a)
a = 2
print("Second a: %s" % a)估计会有小伙伴认为这个代码中最后一行会输出Real a: 3,对吧?那么就来看看实际上对不对吧:
First a: 1
Second a: 2
Real a: 1欸!发现了吗?在u所指向的匿名函数外的print居然输出的是没有经过修改的a值,这是怎么一回事呢?
实际上,在GDScript中,lambda表达式也像普通函数一样有一个范围,在这个范围内去捕获对应的数据。这个范围就叫做捕获作用域(Capture scope),个人习惯简称为捕获域,只有在这个作用范围内的所有信息才会被lambda表达式所捕获。对于lambda表达式而言,其捕获域是从其函数声明开始往上追溯,直到追溯到lambda表达式自身所在作用域的开头(如果在一个函数内,则为该函数的作用域开头)。lambda表达式不会对其声明及定义后面的内容进行捕获。也就是说,下面这行代码会直接报错:
var q = func():
print(s) # 报错,在作用域内找不到标识符s
var s = 1 # 在lambda表达式后面的声明不会被该lambda表达式所捕获lambda函数的捕获域还有一个特点:如果对捕获域内所声明的数据进行修改,那么该修改只会在其lambda函数体本身及其更子级的lambda表达式(也就是嵌套的子lambda表达式)内生效,一旦超出该lambda函数体(的母体),则对被捕获的数据的修改均会回退至其被lambda表达式捕获前的状态,也因此就出现了前文当中的情况。
此外还有一点,lambda表达式只会对其捕获域内的数据进行一次捕获操作。一旦该数据被捕获,那么该数据在lambda表达式中的初始形式也就确定了。看不懂什么意思?那就上代码:
var q = 2
var s = func(): print(q)
s.call() # 结果为2
q = 4
s.call() # 结果仍然为2不知道各位小伙伴看明白了没有:我在lambda表达式前声明了一个q变量,存入整数数据2。然后又声明了s,指向一个lambda表达式,在该lambda表达式里打印q。根据刚才提到的内容,此时q在该lambda函数的捕获域内,其在lambda函数内的初始状态也就随之固定了。这个时候,无论你在lambda函数声明与定义后进行何种修改,只要不在lambda函数体内对q进行修改,print()函数打印出来的q就依旧是其在被lambda表达式捕获前的q,而非在lambda表达式定义后面发生变化后的q。换句话说,lambda表达式的作用环境在其声明时进行初始化,此后,其内部的作用环境与外部的作用环境就完全隔离了。
不过刚才这些都是对lambda表达式声明前的非引用类型进行操作,但如果是对引用类型进行操作呢?比如数组、字典、对象。。。
我们先需要区分两个概念,赋值和赋引用:
[*]赋值,主要是对值类型的数据而言的,比如int, float, bool这些,如果“=”右侧接着一个值类型的数据,则表示将该数值传递给一个变量。
[*]赋引用,对于引用类型而言,比如数组、字典、对象等复杂数据结构,我们往往会将其引用进行传递。如果“=”右侧接着一个引用类型的数据,则表示将对该引用类型数据的引用传递给左侧的变量。
也就是说,对于引用类型的数据而言,“=”实际上更偏向于把一串数据的地址(数据所在的地方)传递给左侧的变量来管理了,我们通常就会说“xxx变量指向/引用xxx数组/字典/对象”,因为比起值类型,引用类型的“=”具有非常强烈的指向性。
我们再回到lambda表达式里,实际上,lambda表达式依然会捕获其捕获域内的引用类型,但捕获的是这些引用数据类型本身,而不是变量本身。然而,基于引用类型传递引用的这一特性,你依旧可以在lambda表达式内对捕获域内的引用类型的数据进行操作,同时,在该lambda函数的定义后面,该引用类型的数据在lambda内所产生的修改效果依旧有效:
var u =
var r = func(): u.append(2)
u.append(3)
print(u) # 结果为
但需要特别注意以下的例子:
var u =
var r = func(): u =
u.append(3)
print(u) # 结果为欸,为啥变成了,为啥不是呢?
实际上,的操作相当于构造了一个新的数组(字典类同),也就是相当于Array(x, y, ...),此时,lambda函数体内的u = 实际上是让u指向了一个新的Array(2)的数组。而lambda表达式实际上捕获的是u所指向的对象,而根据前面所提到的lambda表达式捕获域及其捕获数据的特性,u = 很明显是在lambda函数的作用域内把u = 这个操作进行了重定义的。一离开lambda函数的函数体,u = 这个操作也就会回退回其被捕获前的状态,也就是u = 。
那为啥append()就行呢?要知道,append()是直接通过u找到u所指向的数组,对数组本身进行操作的,根据前文所提到的引用类型数据的特点,这样的操作所带来的影响依旧可以带出lambda函数的函数体,因此append()是可以产生上述影响的。
以上就是有关gdscript lambda表达式的一些坑,希望能帮助更多小伙伴防止踩雷。
页:
[1]