最后编写: 2023-1-20
迈向黎明
。
本文使用 Obsidian 加了一堆插件编写,Git 仓库的显示效果经过代码转换,与 PDF 略有差异(PDF 更好看一些)
想学 Godot 吗,是不是厌倦从 if 讲起的初学者教程?
既然你来看了这篇文章,那么你一定是个 Unity 大佬吧。
那么好,咱们来以 Unity 的角度快速入门 Godot,省去繁琐的基础教程吧。
Unity 可以不会,但不建议 0 编程基础的看,文中不会着重讲解代码语法和逻辑。
这篇文章看似是教程,其实是我的个人学习笔记,长得比较像教程而已 233。
我对 Godot 了解不深,文章是边学边写的,出现错误感谢指正。我的联系 QQ 2293840045
感谢来自 Godot中文社区 的 Life 指出的问题,现已修改 单例模式实现 章节,添加了 唯一名称 章节。
既然都用 Godot 了,当然要好好体验独特的 GDScript,本篇暂时不涉及 Godot 支持的其他语言。
Godot 支持的语言是真的多,仅我知道的就有 C#、Rust 甚至冷门的 nim
写到一半忽然感叹...上个新坑为啥要用 unity
本文写于 2023 年 1 月 20 日,当时我用的是 Unity 2021.3.16
和 Godot v4.0 beta 十几
。祝你阅读时 Godot 依旧兼容本文内容。
要说 UG 之间的最大区别,肯定是 Godot 开源、Unity 闭源(大部分),但讲这些对于咱们初学阶段影响不大,所以直接来看 UG 两者构成游戏的方式吧:
一般情况下,Unity 构成游戏的基本元素如下:
而 Godot 只有下面这些东西:
可以把节点理解成 Unity 的组件,只不过一个游戏物体只能绑定一个组件,通过多个这种单组件物体组合成一个复合物体。
现在假设,要创建一个带有物理效果的小球,并且让它发光,那么:
Unity 那边就不解释了。
Godot 中的物体父子关系和 Unity 相同:父物体移动会带着子物体移动。因此,将 CollisionShape、MeshInstance、OmniLight 放到 Rigidbody 的子级中即可构成一个移动的发光小球。
Unity 中,每个物体都必须记录位置、角度、缩放,因此 Transform 是 GameObject 的必备组件。
Godot 中,每个节点也需要记录位置、角度、缩放,因此 Godot 的节点都继承了 Node3D 或 Node2D 节点,也就是 Godot 中记录位置、角度、缩放的东西。
按照 Godot 节点
等于 Unity 仅有一个组件的物体
的逻辑,可以在 Unity 中用下面方式再做一次这个发光小球:
当然,把内置组件这样用太奇怪了,但假设是你在 Unity 中开发的某个超高级次世代对话系统:
由于系统太牛逼太复杂了,文本框、说话者头像、菜单按钮、特效层都需要单独的组件来控制,那么这样做就好像合理了一些。
Unity 的脚本也是组件,Godot 则不同。脚本可以绑定在一个节点上,同时一个节点也只能绑定一个脚本。选中任意一个节点,然后看它的属性列表最下面:
这个 Script 字段就是这个节点的脚本了。
如果你打开了一个脚本文件,会看到几个函数直接摆放到了文件里,没有 class 啥的,也就是说,Godot 中一个文件就是一个 class。
但是 class 里可以嵌套 class
终于到了熟悉的东西,Godot 的资源类似 Unity,就是放到项目目录里就行了,只不过 Godot 的项目目录更简单。
项目根目录里有一些 .
开头的文件夹,那些地方用来存放项目设置等引擎要用的东西,就类似 Unity 的 Packages、UserSettings 等文件夹,还包含类似 Unity 中的 .meta
文件的东西。
如果你会 Python,那么恭喜你可以开启八倍速模式了,毕竟 GDScript 语法风格极像 Python。
Godot 在使用变量前需要先声明,使用关键字 var
:
var playerName = "Rika"
var girlfriend = null
print(playerName)
# print 就是 godot 的输出了。
# 不是说好像 python 么,怎么成 c# 了 ?
playerName 变量明显是个字符串类型,在 Godot 中用 String
表示,而 girlfriend 是个空变量。
那么下面来列举一下 Godot 常用变量类型:
类型 | 解释 |
---|---|
String | |
bool、int、float | |
Vector2、Vector3、Color | |
Variant | 任意类型 |
Array | 等于 C# 的 List 和 Python 的 []
|
Dictionary | 等于 C# 的 Dictionary 和 Python 的 `{ |
Object | 大多数对象的基类(不是节点的基类) |
想要判断一个变量的类型呢:
if typeof("HAHA") == TYPE_STRING :
pass
基本类型的转换同,使用 目标类型(原始数据)
的方式进行转换:
int("666")
float("123.123")
String 不是基本类型,需要使用 str(原始数据)
进行转换:
str(111)
Godot 中的非 String 数据在字符串拼接的时候不能自动转换成 String。
错误:
"你有钱:" + 0
正确:
"你有钱:" + str(0)
不过好在一般不会这样拼接字符串,而是用下面会介绍的字符串格式化。
如果你是第一次接触弱类型语言,可能会感觉有点小爽(并不,语法补全屎一样)。
而我认为,编程界最伟大的两项发明:TypeScript
和 Python类型注解
。
如果你也是一个深陷弱类型泥潭的老将,那么太好了,Godot 支持类型注解:
var haha:int = 123
haha = "awd" # Error: String 不能转换成 int 类型。
给变量 haha 后面加了个冒号和 int,那么 haha 变量就成了一个 int 类型的了,他就只能存放整数。
下面多看几个例子:
var arg:int = 22
var items:Array = [1,2,"3",false]
var friends:Array[String] = ["You","Self"]
var KV:Dictionary = {
"A":1,"B":2
}
其中 friends 变量的类型 Array 后面加了一个 [String]
,者可以看作是 C# 中的泛型,就是说 friends 必须是字符串组成的数组。
我就奇了怪了,Dictionary 怎么就不支持泛型。
照搬 Python,在字符串后使用 %
运算符:
"你好 %s,你有 %d 块钱。" % ["Rika",0]
# 结果:你好 Rika,你有 0 块钱
# 如果参数只有一个,% 后面可以不用数组:
"呵呵$s" %s "..." # 呵呵...
继续深度照搬 Python,根据占位符名称进行格式化:
"你好 {name},你有 {money} 块钱。".format({
"name":"Rika",
"money":0
})
# 效果同上
GDScript(和 Python)采用缩进式语法表示代码块,而不是 C# 中的 {
和 }
花括号。
缩进相同且相邻的多行代码就是一个代码块。
假设现在有个 C# 格式的 if:
if(a >= 2){
print("a 大于等于 2");
a += 12;
}
print("if end");
转换成 GDScript:
if a >= 2:
print("a 大于等于 2")
a += 12
print("if end")
在 GDScript 中,if
语句首先省去了圆括号,然后在条件后面使用一个冒号 :
表示后面是一个语句块,然后下面的 print 和 += 运算都比 if
语句多缩进了一个 tab,因此他俩是 if
语句块的内容,而最后一个 print 不是。
注意缩进使用的符号,tab 和空格是不一样的,至于使用哪个可以凭喜好。
代码块中的空行和注释可以没有缩进或缩进不同,不影响代码行。
控制语句就还是那些,直接来看看各种控制语句的写法吧(python 同学可以直接跳过):
# if .. elif .. else
if a >= b:
pass
elif a == 0:
pass
else:
pass
# while
while 5 != 6:
pass
# for
var arr = ["A","B","C"]
for i in arr:
print(i) # 输出 A B C
# range 方法返回一个迭代器
for i in range(3):
print(i) # 输出 0 1 2
# match ,这就是个不用写 break 的 switch
var a = 2
match(a):
1:
print("a 是个 1")
2:
print("a 是个 2")
_:
print("a 不是 1 也不是 2")
嗯,挺好理解的吧,等等,为什么随便打的占位符 pass
被语法高亮了?
给你看一下屎一样的三元运算符:`print("活着" if hp>0 else "寄了")``
有时候不知为何要写一些奇怪的东西,例如下面例子:
if hp <= 0:
# 寄了,怎么办?TODO: 以后再说吧。
else:
我还活着()
看似没问题,但却迎来了一个错误,因为 if
后面找不到代码块,毕竟注释不算代码。
这个时候就可以先用一个关键字 pass
顶着,表示这是个空代码块:
if hp <= 0:
# 虽然寄了然后什么也没干,但是不报错了
pass
当然这个写法不仅用于 if
,在循环、方法定义、class
定义等地方都可以用。
之前说过一个脚本就是一个 class
,所以可以直接在脚本里定义方法:
# Godot4.0 beta15 版本开始支持中文标识符
func 我这个方法有两个参数(这个是参数, 这个也是参数):
pass
嗯,学会了吧,现在加上类型注解?:
func 我帮你计算字符串长度(字符串:String) -> int:
return len(字符串)
嗯,又学会了吧,现在加上可选参数?:
# 可以算个寂寞
func 我帮你计算字符串长度(字符串:String = "") -> int:
return len(字符串)
嗯,又又学会了吧,暂时想不到还有啥了。
定义方式不想解释,直接看代码:
# 使用extends关键字表示继承
class MyNode extends Node:
class InnerClass:
func testFunc():
print('我在里头')
# 比Python高级,在class里面用var定义成员变量
var value:int
func testFunc():
print("我是 MyNode")
func _init(): # 我是构造方法
print("诞生了一个 MyNode 示例")
func _to_string() -> String:
# 我是 ToString
return "MyNode[value = %d]" % value
采用 类.new()
来实例化:
var myNode:MyNode = MyNode.new() # 诞生了一个 MyNode 示例
myNode.value = 123
print(myNode) # MyNode[value = 123]
var innerClass:MyNode.InnerClass = MyNode.InnerClass.new()
之前说过脚本也是 class
,那么这个 class
叫啥,继承啥?
这些需要在脚本中声明:
extends Node # 继承自 Node
class_name Abc # 叫做 abc
这样操作之后,在其他的脚本里面就可以用 Abc 这个名字指代上面那个脚本了。
Godot 内置编辑器有时会缺少代码补全,遇到没补全的情况,不一定是代码错了,建议运行一下看看是否正常。
Godot 的脚本有两种存储方式,一种和 Unity 相同,作为代码文件存在资源目录里,另一种可以将文本代码直接存储到节点上,省去了在资源中管理脚本的工作,缺点是这样的脚本就不能复用了。
对着一个节点右键,点击添加脚本即可看见创建脚本的窗口:
上图是我对着一个 Node2D 节点创建脚本时的弹窗,注意继承选项,也是 Node2D,然后我们点击创建后,就会得到一个继承自 Node2D 的脚本。
为啥要重点说继承自 Node2D 呢,因为此时,这个 Node2D 节点其实就已经不是 Node2D 节点了(我在说什么?),这个节点其实已经变成了咱们这个脚本的实例,就好像在 Unity 中继承自一个组件去写了一个新组件一样。
Godot 的脚本正是采用这种继承方式去访问节点属性的,因此,这里的父类不能乱选,例如不能给一个 Node2D 节点加一个继承自 Rigidbody3D 的脚本。
一般这个父类就选节点本身的类型即可,但如果为了让脚本可以复用在更多节点上,也可以让脚本继承自这些节点的共同父类,当然,这会导致脚本中不能直接访问那些节点子类独有的成员。
继承组件的父类不会导致组件子类丧失效果,感觉他们的关系有点像这样:
title: 内置脚本转换成独立脚本 找到节点的 Script 属性,对着后面的脚本右键,选择保存即可。
当初刚会编程去玩 Unity 的时候,最震惊我的事情竟然是......public
的变量能在 Unity 界面上显示出来!
作为同行,咱 Godot 也能,只要在成员定义的 var
关键字前面加上 @export
:
@export var Name:String = ""
# 开头的 @ 符号是我这个版本的 Godot4 加上的,如果你是 Godot3 或未来的 Godot4,可能需要去掉 @ 符号。
然后在节点属性面板的最上面就能看到:
关于生命周期应该不用过多解释了,三个常用周期对照表:
Unity 生命周期 | Godot 生命周期 |
---|---|
Start | _ready() |
Update | _process(delta:float) |
FixedUpdate | _physics_process(delta:float) |
详细的周期可以看官方文档:Node节点 Methods
这些生命周期方法直接写在脚本里就行了,其中两个 process
方法的参数就是两帧间隔时间,也就是 Unity 中的 Time.DeltaTime
。
信号这个名字可能一听就蒙蒙的,其实就是 C# 中的 event
,如果做过 WinForm 开发一定非常熟悉。(Godot 做 UI 的时候真的感觉就像在用 WinForm)
Unity 的 UGUI 和 InputSystem 也用信号这个东西,只不过 Unity 里叫 Event,就是这个东西:
默认布局下,Godot 的信号面板和节点属性面板在同一个位置:
点进去就会看到各种密密麻麻的信号,双击一个信号就可以指定连接对象了,和 Unity 基本相同,这里就不继续讲解了。
信号面板旁边还有个分组面板,感觉有点类似 Unity 的 Tag
直接代码加注释解释吧:
extends Button
func _ready():
# 使用 connect 连接信号
# pressed 信号会在按钮点击时触发
pressed.connect(self.OnPressed)
# GDScript 的 self 就是 C# 的 this 关键字
func OnPressed():
print("Hello")
pressed.disconnect(self.OnPressed)
pressed 是 Button 的属性,如果有需求,还可以用字符串指定信号名,使用
connect(name, fun)
和disconnect(name, fun)
,同时去掉前面的信号属性。
如果脚本就是简单的监听按钮点击,其实可以直接用
_pressed
生命周期方法。
在脚本中使用 signal
关键字定义信号,可选添加小括号与参数类型注解:
extends Button
signal testSignal(a:float)
signal testSignalWithOutParams
func _ready():
testSignal.connect(OnPressed)
func _process(delta):
# 触发信号
testSignal.emit(delta)
func OnPressed(v):
print("Hello %f" % v)
此时再看这个按钮的信号面板:
emit 方法不做类型检查,但是若接收端参数不一致会报错。
再来认识个函数:
emit(name, args...)
,通过字符串触发信号,而且后续参数是变长的。
不知读者是否熟悉 html,如今前端框架发达,浏览器切换 url 往往不用刷新页面了,而是只刷新部分 html 标签,我感觉 Godot 就有这种前端的味道。
Godot 中的节点可以保存成资源,和 Unity 的预制体起到同样的作用,对着节点列表中的某个节点右键,即可把节点保存:
也可以和 Unity 保存预制体一样,从大纲视图直接拖到左下角的资源视图即可。
注意了,这个东西叫做 把分支保存为场景
,也就是说,这个"预制体"可以当作场景来用。(其实这里我觉得没必要这么翻译,直接说 保存节点树
就挺好的)
如果咱把之前的发光小球保存成场景,那么发光小球场景其实就是 Unity 的预制体,创建出来就是生成了一个发光小球。
如果把发光小球滚来滚去的场景保存下来,那么就成了真正的场景,创建出来并把之前的场景删了,那就是切换场景了。
Unity 中也可以在一个场景下,通过实例化和删除物体的方式做到切换地图的效果。
本节标题正是我刚接触 Godot 时的最大疑惑,当要创建一个游戏物体时,我的个人习惯如下:
父级其实也就影响子级移动旋转,因此产生运动的 Rigidbody 做根节点最合适。
不过有一点需要注意,Godot 中某些节点有明确的父子级关系要求,例如 Area3D 必须要求子级有 Collision 类节点,因此创建物体时要熟悉所用到的各种节点,根据他们的依赖关系选择父子级。
使用 get_node(节点路径:String)
获取节点,参数是节点路径,这个路径可以是于当前节点的相对路径,也可以是用 /root/
开头表示绝对路径,现假设有一个这样的场景:
在 Player 节点上执行 get_node
,下面列举几个节点路径的获取结果:
路径 | Node |
---|---|
. | Player 自己 |
Image | Player 下面的 Image 节点 |
./Image | 同上 |
./Area/CollisionShape2D | 最里面那个方块图标的节点 |
/root/Node2D | 第一个节点 Node2D |
.. | Player 的父级,也就是第一个节点 Node2D |
../Control | 下面那个图标是绿色圈圈的节点 |
路径不支持任何的模糊匹配。
/root
会得到一个 Window 对象,而不是最顶层的节点。
由于获取节点这个操作太常用了,Godot 就设置了个语法糖,使用美元符号 $
即可直接代替 get_node
方法调用:
$FirePosition
等于 get_node("FirePosition")
$/root/Node2D/Control
等于 get_node("/root/Node2D/Control")
如果路径包含特殊符号导致语法出错,也可以把 $
后面的东西用字符串表示:$"../Control"
等于 get_node("../Control")
可以调用其他节点的 get_node,实现从其他位置作为起点获取节点。
可以将场景中名称不重复的节点标记为唯一名称,方便在代码中获取它:
之后就能看到整个节点多了一个 % 小标记:
在代码中通过 %【节点名称】
语法糖来获取这种拥有唯一名称的节点:
var 我是那个Button = %Button
当然这样的节点不能重名,毕竟他叫唯一名称节点。
感觉类似 html 中的 id 属性。
get_node
方法也支持获取唯一名称节点:get_node("%Button")
。godot 3 不支持 % 语法糖,只能用
get_node("%Button")
或$"%Button"
如果只是操作一个简单的节点,可以直接:
# 创建节点
var n = Node2D.new()
# 添加到场景中(作为当前节点的子级)
add_child(n)
# 删除节点
# n.free()
还有个
remove_child
方法也能实现删除节点的效果,但它并不释放内存,还能把节点重新 add 到场景中。
当然我觉得大家更想要 Unity 中实例化预制体那种效果,于是咱么可以这样写:
@export var NewNodes:PackedScene
func Get():
var n:Sprite2D = NewNodes.instantiate();
add_child(n)
# n.free()
PackedScene
类型表示保存在资源里面的节点,它的 instantiate
方法可以把这些节点实例化出来,这个方法的返回值是那堆节点的根节点,上面例子中根节点是个 Sprite2D。
Unity 最近引入了 InputSystem,不过 Godot 的输入系统更类似 Unity 的传统输入。
在 Godot 界面菜单栏中点击【项目】, 点击【项目设置】,进入【键位映射】选项卡,就能看到 Godot 的键位管理界面了。
和 Unity 一样,Godot也内置了很多键位映射,不过需要点开右上角的 【Show Built-in Actions】 才能看到。
当你在这里创建好需要的键位后,在代码中使用 Input.get_action_strength("映射名称")
即可获取一个 0 到 1 的 float
,也就是按键的状态。
如果想要在 _process
中检测按键刚刚按下或刚刚抬起,也就是 Unity 中的 Input.GetKeyDown
和 GetKeyUp
,则使用 Input.is_action_just_pressed("XXX")
和 Input.is_action_just_released("XXX")
Input
里包含很多“人如其名”的方法,这里不一一介绍,比较有意思的是有俩 get_vector
和 get_axis
方法可以快速获取成对的输入,例如方向移动等。
如果你想完全掌控输入,可以尝试下
_input
生命周期,这可以绕过键位映射这些东西。
使用 FileAccess
类进行文件操作。
在Godot3中可能是
File
类
FileAccess
类的静态方法 open
可用于打开文件:
var f = FileAccess.open("user://test.txt",FileAccess.WRITE)
title: Godot 的文件路径
Godot 采用文件沙箱机制,FileAccess操作的文件路径需要使用
user://
或res://
开头来表示用户目录和资源目录,例如上文的user://test.txt
就是读取用户目录的数据。
res://
就是指Godot的项目目录了,这个目录的文件在游戏打包后是只读的。
user://
在 Windows 下默认是%APPDATA%\Godot\app_userdata\项目名称
这个目录,可以在项目设置中修改(需要开启项目设置的高级选项):这样的话文件就会存到
%APPDATA%\Hello
目录。
FileAccess 实例有 store_string
方法用来向文件追加字符串,flush
方法将内容写入到磁盘,get_as_text
方法返回整个文件内容,接下来就可以自行发挥存储数据的格式了。
顺便再看一下 Godot 的 JSON 操作吧:
var a = JSON.stringify({
"haha":false
})
print(a) # {"haha":false}
var t = JSON.parse_string('{"test":123}')
print(t.test) # 123
当然要是觉得纯文本存数据不好用,也可以尝试带有类型的存储方式,FileAccess 有很多
store_
开头的方法用来存储各种类型,以及get_
开头的方法读取各种类型数据。不过感觉用起来不够灵活,我就没详细研究,因此就先不介绍了。
在左上角菜单中点击【项目】【项目设置】即可看到项目设置面板,当然这个界面没什么好讲的,咱来看看怎么用代码访问这里面的选项:
# 这是窗口高度字段的路径
var path = "display/window/size/viewport_height";
# 获取窗口高度
var height:int = ProjectSettings.get_setting(path)
# 设置窗口高度
ProjectSettings.set_setting(path,height + 10)
# 保存设置
ProjectSettings.save()
那么哪个窗口高度字段怎么得到呢?看图:
不过,运行之后你会发现窗口并不能变大,这是因为
ProjectSettings
只是存放设置的地方,而不负责这些设置产生的效果。不过咱已经
save
了修改后的窗口高度,重启游戏后就能看到窗口变大了。
应该你也看到了,项目设置窗口上面有
添加
和删除
两个按钮,也就是说你可以在这里添加自己的项目设置。
来到 GDScript 后某些习惯的东西就不太会写了,其中对于游戏开发最常用的应该就是单例设计模式。
Godot 提供了一种实现单例的方法,点击菜单栏中的【项目】【项目设置】进入【Autoload】选项卡,这里可以添加一些脚本,他们会在游戏开始时自动实例化,并作为 /root
的子节点添加到场景中。
正因为这个脚本要作为子节点添加到场景中,所以他必须要继承自
Node
类
现在咱来写个单例脚本:
extends Node
class_name TestClass
var myname:String = "Rika"
func hello():
print("Hello " + myname)
然后添加到 Autoload:
现在就可以在代码中使用Autoload 中的名称,也就是TC直接引用这个实例:
print(TC) # TC:<Node#26289897900>
TC.hello() # Hello Rika
要开启Autoload中的全局变量选项才能在代码中直接获取这个实例。
因为这个单例是根节点的子节点,所以也可以通过
get_node("/root/TC")
找到它。
文章顶部说过,本文其实不是个真正的教程,只是我的个人学习笔记长得想教程。
因此,本文的后续更新取决于我的学习进度。
如果认为某些内容需要修改,或想给我提出任何建议,欢迎联系我或使用本仓库的 Issue 功能!
目前可能的后续内容:
- GDScript 反射
- 插件开发?
- 试试 Godot C#
此处可能存在不合适展示的内容,页面不予展示。您可通过相关编辑功能自查并修改。
如您确认内容无涉及 不当用语 / 纯广告导流 / 暴力 / 低俗色情 / 侵权 / 盗版 / 虚假 / 无价值内容或违法国家有关法律法规的内容,可点击提交进行申诉,我们将尽快为您处理。