查看: 486|回复: 18

[讨论] 【随缘更新】Godot 小知识

[复制链接]

40

主题

771

回帖

13

精华

版主

经验
7813
硬币
1258 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

发表于 2024-5-14 13:08:37 来自手机 | 显示全部楼层 |阅读模式
不如说是踩坑记录。
Moonstruck Blossom
个人网站:dasasdhba.github.io

40

主题

771

回帖

13

精华

版主

经验
7813
硬币
1258 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2024-5-14 13:14:40 | 显示全部楼层
1. export NodePath 和 export Node 的区别

乍一看从编辑器体验上两者没有区别,而且后者写代码更方便,似乎是前者的上位替代,但其实这俩有点区别。
NodePath 变量存的是相对路径,而 Node 变量存的则是绝对的 Node
当你想要 duplicate 一整个 Node 的时候,区别就体现出来了:NodePath 变量在 duplicate 之后存的仍然是相对路径,而 Node 变量在 duplicate 之后,指向的仍然是 duplicate 之前指向的那个老 Node

总之,在使用相关功能的时候,请衡量自己想要的到底是相对路径关系,还是绝对的引用。

点评

本质上export Node应该是特殊的export NodePath,只不过内部应该是自行封装了一个安全的get_node()  发表于 2024-5-15 10:33
duplicate以后还指向老node的那个应该是bug,前段时间在pr上看到过,在4.3被修了  发表于 2024-5-15 10:32
Moonstruck Blossom
个人网站:dasasdhba.github.io

40

主题

771

回帖

13

精华

版主

经验
7813
硬币
1258 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2024-5-14 19:00:43 | 显示全部楼层
2. 碰撞检测时,想好是 A 检测 B 还是 B 检测 A

让大量的砖块每个单独去检测有没有受到攻击,和让攻击判定去检测有没有碰到砖块,这两种方式的性能差异是巨大的
通常来说,一定要想好哪些对象更加适合作为发起碰撞检测的一方,不要怕麻烦,尤其是砖块特别多的时候

这一条不仅限于 Godot,任何游戏引擎都需要注意此问题。

点评

MFIT2-4.png  发表于 2024-6-26 17:53
mitf坦克.jpg  发表于 2024-5-14 22:06
Moonstruck Blossom
个人网站:dasasdhba.github.io

40

主题

771

回帖

13

精华

版主

经验
7813
硬币
1258 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2024-5-14 23:40:47 | 显示全部楼层
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 的引用
Moonstruck Blossom
个人网站:dasasdhba.github.io

60

主题

438

回帖

8

精华

版主

☯ 博 丽 不 是 灵 梦 ☯

经验
6783
硬币
1110 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章

发表于 2024-5-15 10:48:49 | 显示全部楼层
dasasdhba 发表于 2024-5-14 23:40
3. 通过 metadata 进行通信

试想下列情形:你为了不把逻辑和物理耦合在一块,选择通过子节点 C 来操控物理 ...

其实根据官方对SceneTree优化的表述,结合metadata存引用这个特性,可以在初始化组件节点C的时候顺带把组件节点C移出场景树(其实就是主循环要遍历的节点变少了,自然便流畅了)
需要注意的是:上述操作只有在组件节点C里没有_process()或_physics_process()时才能保证安全,不然后两者在节点树外是没法运作的。
另外,如果只是将其移出节点树外,那么在中心节点A调用queue_free()的时候,组件节点C并不会被删除,这个时候可以下代码:

  1. func _ready() -> void:
  2.     var par := get_parent()
  3.     assert(par != null, "The component %s does not have a operatee!" % name)
  4.     par.tree_exited.connect(func() -> void:
  5.         if par.is_queue_deletion():
  6.             queue_free()
  7. )
复制代码

不过上述代码也有个缺陷,就是如果用户是通过free()来删除节点的话那么is_queue_deletion()就会不起作用,从而导致组件无法删除,残留在内存当中,造成内存泄漏。这个时候最好的方法就是自己写一个delete()静态方法,在删除对象的同时也删除掉与其相关联的组件
>❀ To the Best You ❀<

40

主题

771

回帖

13

精华

版主

经验
7813
硬币
1258 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2024-5-31 10:15:57 来自手机 | 显示全部楼层
4. 物理引擎有时候更靠谱

反直觉的事情是,大量调用 Rect2.HasPoint 这种看似仅有四个不等式就能解决的位置判断,其带来的性能问题远远大于物理引擎的重叠测试。不过使用物理引擎处理这种需求时要注意调整最大查询次数。
Moonstruck Blossom
个人网站:dasasdhba.github.io

40

主题

771

回帖

13

精华

版主

经验
7813
硬币
1258 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2024-5-31 10:22:51 来自手机 | 显示全部楼层
5. 谨慎动态修改 Resource

正常情况下,Resource 都是共享的,这意味着以下几个常见的事情:

a. Shader Material 是 Resource,也就意味着 Shader Parameter 不能看作独立参数
b. Shape 是 Resource,也就意味着 CollisionShape2D 的 Shape Size 等参数不能看作独立参数
c. ...

总之,若需要动态调整 Resource,请勿忘在 Ready 时将其 Duplicate 一份(建议用 Duplicate(true))

点评

特别地,在编辑器中复制粘贴并修改 Resource 时,也不要忘记 Make Unique。  发表于 2024-5-31 10:24
Moonstruck Blossom
个人网站:dasasdhba.github.io

40

主题

771

回帖

13

精华

版主

经验
7813
硬币
1258 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2024-5-31 10:38:42 来自手机 | 显示全部楼层
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 实现,并无出现任何问题,不懂官方此举是作何考量。
Moonstruck Blossom
个人网站:dasasdhba.github.io

60

主题

438

回帖

8

精华

版主

☯ 博 丽 不 是 灵 梦 ☯

经验
6783
硬币
1110 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章

发表于 2024-6-2 23:13:13 | 显示全部楼层
本帖最后由 囿里有条小咸鱼 于 2024-6-3 00:17 编辑

我也补充一个吧:
7.切换CollisionShape2D的碰撞形状最好直接更换shape,而不是弄多个CollisionShape2Ds然后设置disabled属性。主要是节点多了看着也不舒服。同时,直接更改shape也减少了节点数量,一定程度上提升程序性能。
但是,不管是设置disabled还是更改shape,如果当前对象在Area2D里的话,都会导致导致该对象被Area2D判定一次退出和一次进入,对于只需要判定一次进出区域的代码来说处理起来会麻烦一点(更新:如果是在物理帧循环里更改shape的话是不会出现这个问题的,但设置disabled还是会出现该问题)

当然既然提到切换shape了那就再多嘴一句,建议配合AnimationPlayer使用,直观而且耦合度较低,非常适合做一些可视状态机类的效果。
>❀ To the Best You ❀<

60

主题

438

回帖

8

精华

版主

☯ 博 丽 不 是 灵 梦 ☯

经验
6783
硬币
1110 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章

发表于 2024-6-2 23:17:57 | 显示全部楼层
8. 粒子无法继承粒子发射器的旋转和缩放
这个godot官方有人已经提出未来会增加对该效果的支持了,具体什么时候实装就看godot社区有没有人愿意提pr了
>❀ To the Best You ❀<

40

主题

771

回帖

13

精华

版主

经验
7813
硬币
1258 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章第五届MW杯亚军对不起,小姐盲猜大王数字君X68数字君X68数字君X78

 楼主| 发表于 2024-6-12 01:54:57 来自手机 | 显示全部楼层
本帖最后由 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 中将其移除的操作。
Moonstruck Blossom
个人网站:dasasdhba.github.io

60

主题

438

回帖

8

精华

版主

☯ 博 丽 不 是 灵 梦 ☯

经验
6783
硬币
1110 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章

发表于 2024-6-13 19:41:30 | 显示全部楼层
本帖最后由 囿里有条小咸鱼 于 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是否已被销毁。将不安全代码放在这条语句下进行保护:
  1. var node: = Node.new()
  2. node.free()
  3. if is_instance_valid(node):
  4.     print("Valid")
  5. else:
  6.     print("Invalid")
  7. # 结果为Invalid
复制代码
2.从源头上防止这种情况,其实就是对引用该属性添加自定义取值函数(getter),配合利用is_instance_valid(xxx)与三目运算符来让返回结果可靠——要么为对该对象的引用,要么为空引用null
  1. var node:= Node.new():
  2.     get: return node if is_instance_valid(node) else null
复制代码
这样,只要节点无效,也会被视为null来处理而非previously freed object
>❀ To the Best You ❀<

60

主题

438

回帖

8

精华

版主

☯ 博 丽 不 是 灵 梦 ☯

经验
6783
硬币
1110 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章

发表于 2024-6-26 17:47:31 | 显示全部楼层
本帖最后由 囿里有条小咸鱼 于 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时被立即销毁,但弱引用是个例外,会在引用方脱离作用域时销毁,比如下面这个例子:

  1. var member = null
  2. ... # 在某个方法内
  3. member =  weakref(RefCounted.new())
复制代码
为什么要讲这个呢,其实涉及到两个比较重要的概念:循环引用和不可删性
首先讲不可删性吧,顾名思义,RefCounted对象及其实例(包括其子类及其子类的实例)无法通过free()销毁,否则会报错。这一点是出于安全性考虑才这样设计的,所以用的时候一定要注意通过解引用的方式让它自行销毁!
然后就是Godot里最大头的问题:循环引用
先解释下什么是循环引用,其实就是RefCounted A里有东西持续强引用RefCounted B里的东西,反之亦然,这就导致了二者的引用计数均为2。如果不处理好,到最后这两个资源会因为引用计数不为0而被永远滞留在内存里,直到退出应用程序,也就是会导致内存泄漏(Memory Leak),这是非常可怕的。所以建议:如果要对RefCounted及其子类实例进行引用,尤其是在节点里引用一个RefCounted,而RefCounted里又引用了该节点这种情况。如果只是删除了节点,则只会导致RefCounted的引用计数降为1,却并不能将其删除。如果想要通过引用计数机制将其删除,请在节点删除的同时解除该RefCounted对该节点的引用

>❀ To the Best You ❀<

60

主题

438

回帖

8

精华

版主

☯ 博 丽 不 是 灵 梦 ☯

经验
6783
硬币
1110 枚

赞助用户永吧十五周年建吧日纪念勋章永吧十五周年倒计时海报勋章

发表于 2024-7-2 14:24:02 | 显示全部楼层
本帖最后由 囿里有条小咸鱼 于 2024-7-3 10:15 编辑

12. 组合有时候比继承更好用
其实这一点应该算是涉及到面向对象设计的部分了吧。一般来说,我们如果想要扩展一个类的功能,最好的方式就是继承,对吧。但考虑下下面这个情况:
  1. class_name Animal
  2. extends Node

  3. var type: String
  4. var age: int


  5. func sleep() -> void:
  6.         print("I am sleeping")

  7. func eat() -> void:
  8.         print("I'm eating")

  9. func walk() -> void:
  10.         print("I'm walking")

  11. func swim() -> void:
  12.         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,然后分别对两个节点添加脚本,内容分别为:
  1. class_name Flying
  2. extends Node


  3. func soar() -> void:
  4.         print("Soaring!")

  5. func fly() -> void:
  6.         print("Flying!")

  7. func swoop() -> void:
  8.         print("Swooping!")
复制代码
  1. class_name Swimming
  2. extends Node


  3. func swim() -> void:
  4.         print("I am swimming")

  5. func breaststroke() -> void:
  6.         print("I am breaststroking")
复制代码
然后,针对不同的动物,将这些节点对应添加为对应动物的子节点即可,别忘了用变量在_ready()函数里(或者用@onready变量)存储对父节点的引用!
当然,得益于Godot强大的节点保存功能,你还可以直接把这些组件节点保存为一个个场景,在需要的时候直接拖出来放到实现者节点下方作为其直接子节点即可。

组合是针对继承(尤其是单继承)可能会产生的交叉问题而设计的一种最佳实践,善用组合可以让你的程序组织更有条理、更有逻辑。



>❀ To the Best You ❀<
您需要登录后才可以回帖 登录 | 创建账户

本版积分规则