隔了数月再捡起 Godot 居然觉得无所适从,找不到从何下手。
顿时一股悲哀从心底升起:曾几何时我也是过目不忘、学过一遍必如数家珍的选手。
现在变成这个 B 样,是谁的错呢?
虽然人老了,活还是要干的。以后还是老老实实勤做笔记吧。
想当年中学的时候每一个老师都不厌其烦地叨叨「好记性不如烂笔头」。
从「嗤之以鼻」到「有点道理」再到「不以为然」,到现在再反转,果然姜还是老的辣。
技术选型
前天(2022.4.6)刚好出了 开发者快照 alpha 6
,不过跟我没关系。
一堆坑没填呢,真没精力开新坑了。(至少等年中 beta 再说)
话说之前 Godot 4.0 开发进度竟然从 82% 倒退回 81%,挺乐的。
(关闭的 issues 没有新开的多就会这样)
4.0 肯定是和 3.x 不兼容的——改了很多语法,重命名部分系统函数和变量。
好消息是官方确认会出迁移工具帮助 3.x 项目过渡到 4.0。
不过我估计有也不会用,毕竟我这种,啊,强迫症晚期,啊,人称重构狂魔。Code Review 对我来说是一种享受。
这次用的是最新的稳定版 v3.4.4.stable.official [419e713a2]
。
参考文档
开发范式
代码格式
「代码格式」参考 Python 的《PEP 8 编程风格指南》。
命名约定
「文件命名」考虑到项目一致性,文件命名统一使用全小写蛇形 snake_case
。
除非你写的是 C# 要用
类名做文件名 ,那你用大驼峰。
当然 场景 / 类 / 节点 的命名仍使用大驼峰,和默认的节点名格式保持一致。
「变量命名」就那一套,没什么可说的。
代码顺序
「代码顺序」如下:
|
|
「方法」就是「函数」。一般面向对象叫
方法 、面向过程叫函数 。
这个顺序一定要背下来吗?
不要,谁有功夫背这个。自定义脚本模板直接套就完了。
Windows 打开 %APPDATA%\Godot\script_templates
,新建文本文件 all_comments.gd
:
|
|
常见 Q & A
以下答案均来自官方文档《最佳实践》一章。
SceneTree(场景树)和 NodeTree(节点树)
任何游戏引擎都是围绕着构建程序所用的事物抽象的。
在 Godot 中,游戏就是一棵由节点构成的树,树又可以结合起来构成场景。
然后你还可以将这些节点连起来,让它们通过信号进行通信。
游戏的所有场景都汇集在场景树(Scene Tree)中,字面意思是一棵由场景形成的树。
又由于场景是由一个个子节点聚合而成的,以树的形式保存为节点树(Node Tree),因此场景树也是节点树,一棵更大的节点树。
(决战关卡)"] === scene_a["场景 A
(BOSS)"] scene_root === scene_b["场景 B
(宝箱)"]
这是一棵(最简单的)场景树。
(决战关卡)"] === parent(["子节点
(BOSS 对象实例)"]) scene_root === scene_b["场景 B
(宝箱)"] subgraph scene_a["场景 A(BOSS)"] parent === child_1(["孙节点 1
(BOSS 的动画)"]) parent === child_2(["孙节点 2
(BOSS 的音效)"]) end
这是同一棵场景树(不过是把子节点的细节显示出来了)。如上图所示:
- 场景 A 是一棵节点树;
- 包含场景 A 的「某个场景」是场景树,当然更是一棵节点树。
但是,从场景的角度来考虑设计游戏更容易,因为它们可以代表角色、武器、门或用户界面。
节点是面向 内部的业务逻辑 的,场景却是面向 实际的应用场景 的。
你可以创建任意数量的场景,用 .tscn
扩展名保存到硬盘上,表示「
(没错,Godot 最常见的 .tscn
文件就是「以文本形式保存或者说记录的场景」。)
我们把这些文件叫作「
|
|
如何选择分辨率
类型 | 尺寸 | 拉伸模式 | 拉伸比例 | |
---|---|---|---|---|
推荐设置 | Width × Height | Stretch Mode | Stretch Aspect | |
桌面游戏 | 像素风 | 256 × 224 ~ 640 × 480 取决于你用的大部分素材,比如 320 × 180 (1080p 的像素就真的只是一种 风格 了) (我只见过有插画,游戏大规模用不现实) | viewport | keep |
桌面游戏 | 非像素风 | 1920 × 1080 3840 × 2160 (2K)(4K?独立游戏? (有能力做 4K 的不会看本文,不会选 Godot) | 2d | expand |
手机游戏 | 横屏 | 1280 × 720 1920 × 1080 (针对高端机型)(再高没有意义,你的玩家没有写轮眼) | 2d | expand |
手机游戏 | 竖屏 | 720 × 1080 1080 × 1920 (针对高端机型) | 2d | expand |
应用程序 | 你想要支持的最小窗口尺寸 | disabled | ignore |
- 采用
expand
拉伸应该善用 布局 菜单将 Control 节点的锚点吸附到正确的位置。 - 不想支持异形屏(比如 16:10 的显示器,18:9 甚至 19:9 的手机)可以不用
expand
而是继续采用keep
,多余的部分会自动用黑边填充。 - 竖屏手游应该将 Display > Window > Handheld > Orientation 设置为
portrait 。 - 应用程序可以在脚本的
_ready()
函数中通过设置OS.min_window_size
的值来定义窗口的最小尺寸。这样可以防止用户将应用窗口缩得过小,导致 UI 布局出现问题。
手机显示的分辨率和拍摄是两码事,哪怕 10 吋平板,超过 1080p 肉眼凡胎又能看出个什么花呢?
恕我直言,1440p 都 TM 是纯噱头、无效堆料。
你就是看得出来?(大圣收了神通吧)那你跟我说干嘛,你去跟中科院说啊。
拍摄还有点说法,万一机主就是喜欢用手机拍(他买得起 RED,但他买了就是不用),拍完别说 60 吋 8K 屏,他用 IMAX 巨幕放,有钱任性爱造作,行不行?当然可以。至少物质上可以实现。
如何组织项目结构
文件夹名和文件名,
举例(自用项目组织):
|
|
如何切换场景
手动更换场景
多种实现方式 | 方式 | 是否继续处理 | 缺点 |
---|---|---|---|
删除现有场景 | 1. SceneTree.change_scene() 2. SceneTree.change_scene_to() 3. get_node("/root/Main").free() 删除整个主场景 | 卸载内存 | 回到此场景变得更昂贵 (重新加载需要时间和内存) |
隐藏现有场景 | 更改节点的可见性和碰撞检测 从游戏角色的角度隐藏整个节点子树 | 处理继续 | 增加不必要的负载 |
分离现有场景 | Node.remove_child(Node) 将此场景从场景树上分离 类似其他引擎里的禁用(disable) | 仍在内存中 但处理停止 | 如果场景树中存在数据更新 则此场景里的数据会变得过时 |
小地图功能或本地多人分屏游戏可以使用
ViewportContainer
来同时显示多个不同的场景。
直接切换场景树
对于小型游戏,一个更简易的选择是使用一个游戏单例;
然后用 SceneTree.change_scene()
直接切换游戏的主场景(舞台)。
不过这种方式需要注意 GUI 的额外处理,可以选用以下任一方式:
- GUI 也是单例
- 将 GUI 包含在世界中
- 手动添加到根节点的子节点
否则 GUI 会在场景转换时自行删除。
何时使用场景与脚本
正如本章一开始就讲过了:Godot 说的「场景」一般泛指的 .tscn
文件,其本质就是打包好的场景(Packed Scene)而已。理论上只用纯脚本就可以实现场景的所有功能。
那么在既可以用脚本包也可以用场景的情况下,什么时候应该选用脚本呢?
从性能方面考虑
- 脚本每条指令都要调用脚本 API,导致后端要进行多次 「查找」,以找到要执行的逻辑。
- 场景定义了使用序列化数据创建对象的资源。
引擎可以在后端批量处理场景,并提供比脚本好得多的性能。
也就是说:场景包的速度比脚本快;游戏越大,越应该选用场景包。
从易用性方面考虑
使用脚本添加一个节点
|
|
使用场景添加一个节点
点击加号,选中要添加的节点,点击确定。
从稳定性方面考虑
实际使用过程中勾选一个选项 / 改变一个数值很容易,但你很难「不小心」删掉一整行代码。
这是好处也是坏处,是优势也是劣势:
- 「创造」过程中,采用 脚本 写代码一字一句交代得清清楚楚显然更保险;
- 「使用」过程中,采用 场景 来修改提供现成的可配置项对于 节点本身 的破坏性显然更小。
因此得出下列结论。
结论
- 如果想创建一个基本工具,会在不同的项目中重用,可能会提供给不同技能水平的人(包括那些不认为自己是程序员的人),那么它应该是一个脚本,最好有一个自定义名称 / 图标。
- 如果想创建一个基于本游戏特有的概念,那么它应该是一个场景,场景比脚本更容易跟踪 / 编辑(毕竟可视化),而且更安全(出错的概率更小*),最后性能也更高。
* 众所周知,开发过程中最大的安全隐患是「你」,你就是一个人形 bug 制造机。
你会受到体力(生理)/ 精力(心理)以及来自外界环境的各种负面影响,堪称极其不稳定。血肉苦弱,有机体。如果有得选,尽量不要给自己任何犯错的机会。
至于那些仅用场景无法实现的功能,当然是正常写脚本了(你想用场景也不能够啊)。
(说起来一个游戏真正的精华,灵魂所在,往往是这些引擎本身的接口提供不了的功能。)
_process(delta)
和 _physics_process(delta)
该用哪个
游戏是使用循环来运行的。每一帧,你都需要先更新游戏世界的状态,再把它画到屏幕上。
刷新画面的每一帧怎么来的?当然是运算出来的。
依赖于这个「刷新画面」的帧——我称之为前端渲染帧,
就是 _process()
处理数据的时机。
无论渲染时间长短(进而导致_physics_process()
处理数据的时机。
而 delta
这个参数表示
通过 deltatime 动态调整游戏的表现,就能做到(避免 掉帧 发生):
无论游戏后端运算速率的快慢,最后展现出来玩家感受到的观感是平滑且一致的。
- 前端渲染帧越高越好;越高表示画面刷新速率越快、频率越高,画面响应越及时。
前端渲染帧可能随着时间、设备的不同而不同。 - 后端物理帧变化越小越好;变化越小表示游戏运行得越稳定。
后端物理帧会努力维持在一个固定的频率*。
* 后端物理帧在默认设置下恒定为一秒 60 帧。(以格斗游戏为典型的大部分游戏都是 60 帧)
你可以在 Project Settings > Physics > Common > Physics Fps 设置这个值。
任何和物理运算(比如运动和碰撞)相关的行为都应该在 _physics_process()
中处理,比如角色的移动速度 / 子弹的弹道速度等等。
- 大部分时候都应该将速度乘以 deltatime;
否则游戏一卡顿就会导致判定出现异常(它理应已抵达那里,但实际上却尚未到达那里)。 - 有时加速度也应该乘以 deltatime;
否则可能出现类似坠落前 90 米掉落用了 0.9 秒,最后 10 米却用了 10 秒的怪异情形。
很多玩家司空见惯的 bug 背后就是这么简单的原理。
以上只是范式,很多游戏并没有这样做;有些是没有顾及到,有些是不在乎,有些甚至是故意为之。
比如跑毒图的都知道 GTA5 就是物理和渲染帧挂钩的:同一辆车,FPS 越高极限速度就越快。
也许 R 星认为当画面掉帧时应该让操作一起卡顿对玩家来说才更公平。
(没有嘲讽的意思,你细想是有它的道理在的。涉及到输入反馈等多方面的游戏体验优化问题)
如果只是需要一个一直运行,但不需要像 _process()
或 _physics_process()
频率那么高的逻辑检查和数据缓存循环,可以利用计时器实现:
|
|
_input(event)
和 _unhandled_input(event)
该用哪个
两者都可以用来接收并处理单独的输入事件。二者的区别在于:如果按键、鼠标点击等事件没有被 _input()
回调或用户界面组件处理,_unhandled_input()
方法才会收到这个事件。
游戏中的输入通常是使用 _unhandled_input()
处理的。
而 _input()
回调可以用来在 _unhandled_input()
获取前拦截并处理输入事件*。
* 比如鼠标输入就应该尽量使用
_input()
而不是_unhandled_input()
,以免被奇奇怪怪的 UI 组件拦截进而导致操作失灵。
|
|
为了保证最佳性能,尽可能避免在上面说的 _process()
或 _physics_process()
里高频率轮询检查玩家输入——不管实际有没有输入,它们都会触发检查(浪费巨量资源)。
而 _input()
或 _unhandled_input()
只会在引擎实际检测到存在输入的帧上触发。
当然了,如果的确需要 deltatime 的话,你可以根据实际需求来获取对应的 deltatime。
|
|
单例(自动加载 autoload
)什么时候用
Godot 的场景系统虽然强大而灵活,但有一个缺点:无法保存多个场景都需要的信息。
具体来说:玩家得分、背包 / 仓库、永久属性、持有货币、阵营声望……等等。
你当然可以通过一些变通方法来解决此问题,但是它们有其自身的局限性:
- 可以把需要的数据当成子节点来加载和卸载;
然而,这就意味着产生了不健康的强耦合关系——如果缺失了数据,这些场景就无法再独立正常运行。 - 可以把需要的数据存储到硬盘里的
user://
目录(类似存档文件),谁需要就自行读取;
可是频繁地读写文件是一件很麻烦也很耗资源的事,甚至可能拖累游戏运行速度。
什么是 Godot 的单例
单例就是解决「在多个场景之间持久存储数据」的常见用例的实用工具。
利用单例,你可以实现:
- 无论当前运行哪个场景,始终加载。因此可以用来切换场景和处理场景之间的过渡。
- 存储全局变量(比如玩家信息),因为 GDScript 在设计上就不支持全局变量。
需要注意的是 Godot 中的单例比较特殊,只是「行为」类似我们一般所说的「单例设计模式」中的那种「真」单例。也就是说,如果你确实需要,你
可以 把 Godot 的单例多次实例化。
Godot 里单例的实际用法
你可以设置 extends Node
)的脚本」。
比方说「Path」为 res://player_variables.gd
,「Node Name」为 PlayerVariables
。
这意味着,任何节点都可以访问并使用一个名为 PlayerVariables
的单例:
|
|
如果你还勾选了get_node()
:
|
|
应该在什么时机使用单例
举个实际的例子:切割音频问题。
假设我们正在制作一个平台跳跃类游戏(类似超级马里奥),希望收集金币时能够播放音效。
如果使用 AudioStreamPlayer
节点,当正在播放音效时调用它,新的声音就会打断第一个声音。
有一个堪称一劳永逸的解决方案——写一个全局的、能够自动加载的类(音效管理器)。
它会生成一个 AudioStreamPlayers
的节点池,每次新的音效播放请求出现就添加到节点池里开始播放。假如取名为 Sound
,你可以在项目的任何地方使用 Sound.play("coin_pickup.ogg")
之类的方式轻松调用,听起来很美好是吧。
短期来看,这确实解决了问题;但与此同时,它带来了更多更大的麻烦。
- 全局对象:整个项目每一个需要声音的地方现在都强依赖于这个音效类。假如一个音效有错误,或者找不到一个可用
AudioStreamPlayer
,那么一切都会崩溃,一切。 - 全局访问:任何对象都可以从任何地方调用
Sound.play(sound_path)
。如果你发现有一个音效有错,你对于它具体在什么位置完全是毫无头绪 ,你必须漫山遍野地去排查每一个可能出错的地方。 - 全局资源占用:由于从游戏启动之始就维护了一个
AudioStreamPlayer
节点池,你不知道该如何控制数量——太少会出现 bug(被吞掉而没有播放成功的音效),太多则会占用更多其实根本没必要占用的内存。
将使用音效的场景的 AudioStreamPlayer
尽可能保留在其内部,以上问题都会消失。
- 每个场景管理自己的状态信息,如果数据有问题,只会在该场景
内部出现问题 。 - 每个场景只会访问自己的节点,如果出现一个和它相关的 bug,不用找了,必然是它。
- 每个场景占用的内存按需分配(
少份多次);而且在不再需要时可以直接从内存中卸载掉。
然后会碰到一个问题:音效可能放不完——当金币被吃掉的时候播放音效,而音效还没放完可能整个金币节点就被
queue_free()
释放掉了。这里提两个解决方案:
- 金币被吃的时候
现 创建一个新的AudioStreamPlayer
节点并挂载到根节点再播放,放完清理。
(音效不像图形会「错位」,随便在哪放都是同样效果。总不可能从邻居的蓝牙音箱放出来吧)- 金币被吃的时候隐藏金币的图像和碰撞体,启动音效播放器,播放完毕后再释放整个金币节点。
讲清楚了为什么应该谨慎使用单例(自动加载),现在说说到底怎么用才不算乱用:
- 静态数据:如果你需要某个类专属的数据,比如数据库,那么自动加载很合适。
因为 Godot 中并没有脚本 API 来创建和管理静态数据。 - 静态函数:创建一个只返回值的函数库(工具类)。
- 范围广泛的系统:如果一个类只管理自己的信息,而不会掺杂其他对象的数据。
而且这个信息可能到处都会访问,那么使用单例是一个好主意,比如任务 / 对话系统。
一句话:有一个游戏系统 1. 自己管自己(的数据);2. 全局可访问;3. 独立存在。
这个系统就是单例(自动加载)没跑了。
如何快速调试(使用 tool
工具脚本预览代码效果)
将 tool
关键字放在任何 GDScript 文件的顶部,它将在编辑器中运行。
用官方文档的话说:「这使您可以导入和导出插件,创建自定义级别编辑器之类的插件,或使用与项目中使用的相同的节点和 API 来创建脚本。」
我没当成脚本;我是当成「即时可见的调试器」(所见即所得)用的。不过需要注意两点:
- 如果你写了 bug,由于立刻运行,bug 会传染给编辑器,可能导致编辑器崩溃。
(比如涉及queue_free()
,一定要极其谨慎,小心再小心。)
解决方案:主要逻辑写完测过没问题再在文件开头加上 tool 慢慢调教调试。 - 如果你状态不太好(加班太久神智不清),可能会忘记编辑器和实际运行之间是有区别的。
(然后你 debug 半天死活没有效果差点疯掉,最后发现是编辑器和游戏的代码弄混了。)
解决方案:把在编辑器中运行的代码和在游戏中运行的代码分开,如下所示。
|
|
如何自定义数据结构
并不是任何事都必须用 Node 节点来完成;比如自定义数据结构,使用 Object 扩展即可。
给大家简单表演一个链表助助兴吧:
|
|
如果是继承节点(
extends Node
)的脚本定义了class_name
还会自动加入节点列表。
单元测试:
|
|
预期结果:
1 2 3 4
Node 2 found.
1 2 5 3 4
6 1 2 5 3 4
6 1 2 5 4
1 2 5 4
1 2 5
Node 2 found.
Node 3 not found!
这里只是简单演示一下用法,如何做内存管理、甚至扩展成 双向循环链表
此处不再赘述。
Object 不管理内存,如果类继承自 Object,必须手动删除(
free
)它的实例来释放内存。
|
|
同理,队列
、栈
、堆
、二叉搜索树
/ 红黑树
/ 散列树
/ 各种 树
、各种 图
、不相交集……
无论何种数据结构,只要你想,你做就完了。
GDScript 是一门具有自举能力的独立编程「语言」。
Godot 就是用 GDScript 写的,编辑器本身其实就是一个 Godot 游戏。
除了 Object 之外,你还可以使用 Reference,如果你不想手动进行内存管理的话。
Reference 会跟踪对自己的引用,只有当对自己没有另外的引用存在时,才会删除加载的内存。
(除此之外跟 Object 没啥区别。Tree
节点就是 Object,File
对象就是 Reference。)
如果你想将数据结构保存为文件,可以再进一步使用 Resource。
它们天然具有将其对象属性,序列化和反序列化 Godot 资源文件的能力。
(用人话说:以文件形式保存和加载。脚本 .gd
和场景包 .tscn
其实就是 Resource。)
具体用法可以参考「创建自己的资源」。
如何应用面向对象原则
如果可能的话,应该尽量设计没有依赖性的场景。
你应该创建场景,而场景将所需的一切保留在其内部。
另外设计项目架构时可以思考以下几个问题:
聚合 or 组合
- 聚合:简单地聚在一起,彼此之间并不直接依赖。
- 组合:除了聚在一起之外,还进一步互相连接彼此,进而形成一个整体。
继承 or 构造
- 继承:全盘接受父类的一切,并在此基础上扩展自己的特有功能。
- 构造:想象一堆拼到一半的乐高积木,你可以选择自己想要的不同部件组成特定的整体。
场景如何与外部环境交互
一句话:向下调用(善用 $
),向上信号。
具体应用可以参考官方文档对 依赖注入 设计模式的实现。