Godot 学习 03——GDScript 深入

接下来准备做个小游戏比如 flappy bird 练手,但在此之前需要先熟悉 GDScript,以及 2D 游戏开发中涉及到的概念,那个实践教程还是太浅了,先把 GDScript 过一遍,主要关注 GDScript 自身,直接看官方文档,因为写的还不错。参考 https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_basics.html#

这里主要是学了 Godot 语言自身的特性,还有一个更深入的文档 https://docs.godotengine.org/en/stable/tutorials/scripting/index.html#core-features 没有学习,但我没啥热情,用到再看吧。下一步是去学 2D 相关文档。


GDScript 其实是可以做类抽象的。gd 文件定义的是一个匿名类,只能直接使用文件名去引用(除非使用 class_name,如const Parser = preload('res://parser.gd'),这里得到的是一个 class,它可以使用 new 去创建相应的实例。

注意到这里的区别——gd 是 class,tscn 是 Scene,但加载 class 得到的是 Class,而加载 Scene 得到的是 PackedScene,它使用 instantiate 得到实例(而这个实例也是 Node,只是子 Node 也会被实例化)。

一个 gd 文件,既可以自己独立使用,也可以 attach 到 Scene 和 Node 上,这更坚定了我的那个想法——attach 是 mixin 操作

此外,gd 文件中也可以定义 class,这时候该 gd 文件就能够充当命名空间的作用。class 的格式和顶层格式类似:

1
2
3
4
5
class Idol extends Resource:
@export var name: String
@export var age: int
func _init() -> void:
pass

下面的学习中能够意识到,Godot 的类型系统基本半残废,不要对它有太高期望(不然用 C#去)。至于泛型……哈哈哈,别想了。只有数组有泛型签名,而且只有在取元素的时候能看到类型,其他时候都看不到类型。

学习的时候主要是以 python 为基础,没提到就是和 python 一致。

字面量,运算符

字典有一种新的字面量(似乎是 lua 风格):

1
2
3
var a = {key = "value", another_key = 2}
# 同 Python 中的形式
var b = {'key': 'value', 'another_key': 2}

godot 的基本类型都是小写字母开头,int,float,bool(但 String 是大写)。

true,false 是小写,null 也是小写

Godot 支持&&||!,但推荐用 and,or,not。

==,Godot 中的==稍微松一些,1 == 1.0是真。但 match 比==更严,会保证类型一致。

关于isGodot 中的 is 是 isinstanceof 的意思,右操作符是类型

Godot 中有as,这个语法会对 Object 在运行时进行类型 cast,转换失败返回 null。可以用在 var timer := $SomeTimer as Timer这样。

但是as对于内置类型则会尝试进行类型 convert,如'123' as int,这时候转换失败则抛出异常

fb这两个字符串修饰符都不能用,但r仍旧可用。要格式化字符串,使用%操作符,就像 old python 一样。

/操作两个 int 时是整数除法,同其他 C 系语言。如果要进行浮点数除法,可以把其中一个操作符转成浮点数,如float(x)x * 1.0。余数只能对 int 使用。

&'name'是 StringName,之前学过。

^'NodePath'是 NodePath,即查询特定 Node(以及 Node 下的节点)的路径,它和文件路径语法相似:

1
2
3
4
5
6
7
8
9
^'A' # 子节点 A
^'A/B' # A 的子节点 B
^'.' # 自己
^'..' # 父级
^'../../C' # 父级的父级的子节点 C

^'/root' # 根节点
^'/root/Title' # 根节点的子节点 Title
^'A:rotation:y' # 子节点 A 的 rotation 属性的 y 属性

还有两种语法,$NodePath%UniqueName,这两种语法严格来说不是字面量,它们是get_node方法的两种简写。$NodePath根据 NodePath 获取子节点,%UniqueName通过场景唯一名称获取节点(无论它是否是直接 child),需要在 Godot 编辑器中配置哪个节点能够使用 UniqueName

格式化字符串

有三种方式处理字符串——%操作符,String.format方法,以及直接使用+连接字符串。比较凄惨,但反正并不是常用。

Godot 不支持不定参,所以 format 方法只能接受数组。

1
2
print('hello, %s' % 'John Smith') # %s 对应 str 方法,注意布尔值的字符串表示是首字母大写的
print('hello, {}'.format(['John Smiths']))

占位符和 c 系的一致,%s 字符串,%d 整数,%X 十六进制表示,%f 浮点数,%.2f 两位小数浮点数,但额外提供一些,如 %v 为向量的字符串表示。更细节的有需要再学。

format 倒更有意思,format 默认是{}作为占位符,这个占位符中间可以包含下标——传的是数组则下标是数字,传的是字典则是文字;但更有趣的地方是,这个占位符可以更换——下面揭晓:

1
2
3
print('Hello, {0}'.format(['World']))
print('Hello, {name}'.format({name='World'}))
print('Hello, %name'.format({name='World'}, '%_')) # 使用 %_ 的形式去插值

format 不支持传入格式化的模式,可以在 format 的参数里使用%来做格式化。

语句

首先关于变量定义,有const SOME_CONSTANT = 123这种语法,给定编译期常量,const 的右值可以是 preload,这个会比较常用。

match

GDScript 支持 match,支持嵌套解构,支持模式 guard,牛的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 字面量和多模式
match 1:
1,2,3:
print('1-3')
'a','b','c':
print('a-c')
var other: # 不绑定则 _
print('兜底,%s' % other)
# 数组,字典解构
var some_key = '12'
match [1, 2, 3]:
[]:
print('空数组')
[some_key, ..]: # 残念,.. 不能绑定
print('第一个元素是 %s' % some_key)
[1, var second, 3]:
print('第一个元素是 1,第三个是 4,长度为 3')
print('第二个元素是 %s' % second)
match {a=1, b=2}:
{}: print('空 dict')
# 键不能传变量
{'name' : 1}: print('完全匹配')
{'a':1, ..}: print('部分匹配,同时匹配键和值')
{'a', 'b'}: print('存在 key a, b')
{'b': var wtf, ..}: print('匹配键,然后绑定值 %s' % wtf)
# 嵌套解构
match [[[1]]]:
[[[var wtf]]]:
print('嵌套也可以 %s' % wtf)
match [
{name='Haruka', age=16, clazz='765', friends={Chihaya='',Miki=''}},
{name='Chihaya',age=17,clazz='765', friends={Chihaya='',Miki=''}}]:
[{'name': var fst_idol_name, 'friend': {'Chihaya', ..}}, ..]:
print('第一个 idol 名字是 %s ,朋友中有 Chihaya' % fst_idol_name)
[var fst, _] when fst['name'] == 'Haruka':
print('有两个元素,其中第一个元素的"name"=="Haruka"')

for

for 支持 in 是 int,float,处理 dict 时迭代的是 key。

1
2
3
4
5
6
7
8
for key in {a=1, b=2}:
print(key) # a, b
for c in 'hello':
print(c) # h e l l o
for i in 3: # same with range(3)
print(i)
for i in 2.2: # same with range(ceil(2.2))
print(i)

Godot 中也有迭代器协议,但这个迭代器协议不是新产生一个迭代器,而是操作自己内部的状态……这就非常奇异,显然 Godot 对 Array,Dictionary 的迭代是内置支持的,我们没法直接对我们自己的数据类型写 for-in……其实可以,但是没法嵌套迭代自己。

Godot 这个迭代协议看看就好,非常奇怪——还有个 init,难道想多次迭代吗?不是不行就是了。注意三个方法上必须有一个参数,虽然这个参数没有用到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ArrayIterator:
var arr: Array
var next_idx: int
func _init(arr_: Array) -> void:
arr = arr_
next_idx = 0
func _iter_init(_arg):
next_idx = 0
return next_idx != len(arr)
func _iter_get(_arg):
return arr[next_idx]
func _iter_next(_arg):
next_idx += 1
return next_idx != len(arr)
for i in ArrayIterator.new(arr):
print(i)

调用顺序应该是 init,get,next,get,next,get……最后 next 返回 false。

assert

断言和 python 的一致,但需要加括号;断言在生产环境会被忽视,所以可以大胆用,但同时这也要求断言表达式不能有副作用,不然会导致生产环境和开发环境行为不一致

1
2
assert(i == 0)
assert(i == 0, "i Must equals 0!")

Annotation

这里指的是@语法,注意这里叫注解不叫装饰器,因为确实没有代码执行,只是添加了元信息让 Godot 能够进行特殊处理

首当其冲的是@report_X,导出变量使之可在 Godot 编辑器中编辑。注解中可以使用 const 定义的变量

常用的 Annotation 如下:

注解名 参数 描述
export 注解在 var 语句上,暴露某个变量到编辑器,变量的类型需要明确
export_range 开始值,结束值,步长,…hint 可以是 int 也可以是 range,hint 表示指定一些额外信息,如是否显示滑动条,角度转弧度,值是否可以超出滑动条
onready 初始化会推迟到_ready 执行时,使得允许获取使用子 Node 进行初始化;注意@onready@export同时使用时,会导致 onready 的初始值覆盖掉编辑器中配置的
tool 标注整个脚本,让脚本在 Godot 编辑器中执行

export 相关的注解见https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html#doc-gdscript-exports

@tool

https://docs.godotengine.org/en/stable/tutorials/plugins/running_code_in_the_editor.html

Godot 编辑器本身也是一个 Godot 编写的应用,这时候看 tool 注解的描述可能会让人误以为脚本这时候是在 Godot 编辑器本身上执行,能够操作 Godot 编辑器中的内容,比如 FileSystem,Inspector 等面板,这是不正确的(但也不是错误,因为 EditorScript 确实能够操作这些内容)。我们知道,Godot 的 Scene Tree 实际上有两个——Remote,即运行时的,以及 Local,即落盘时的。普通的脚本显然是在 Remote 上执行,而 tool 标注的脚本会在 Local 上执行

这也就是说,tool 标注的脚本,能够操作当前正在编辑的 scene,能做用户能做的任何事情,包括移动、旋转、缩放任意物体,添加新节点,以及能够在用户配置资源的时候得到信号,显示特定信息等,这个很有意义。

tool 的使用有很多局限(和方便):

  1. tool 脚本引用的脚本必须也是 tool 脚本,否则会认为引入一个空脚本
  2. 继承 tool 脚本的脚本不是 tool
  3. 非 tool 脚本引用 tool 脚本时,tool 将无效(但在编辑器中直接引用有tool脚本的scene时,有效)

一个用例是,为 Resource 标注 tool,然后在它的字段的 setter 中进行通知,然后在使用它的 Node 中监听该通知,然后展示警告(遗憾的是,这个 Node 绑定的脚本也必须是 tool,这可能会让人觉得有点不舒服)。这里其实更想在 Resource 中就增加配置要求……但 Godot 不支持这个,只能让调用者来了。

下面是一个示例,为配置项增加更丰富的限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# idol.gd
@tool
extends Resource

class_name Idol

## 小偶像的名字
@export var name: String = '':
get(): return name
set(value):
if value != name:
name = value
changed.emit()

## 小偶像的年龄
@export var age: int:
get(): return age
set(value):
if value != age:
age = value
changed.emit()


# test_scene.gd
@tool
extends Sprite2D

@export var idol: Idol:
set(value):
if value == null:
idol = null; return
if idol != null:
idol.changed.disconnect(update_configuration_warnings)
idol = value
idol.changed.connect(update_configuration_warnings)

# 这个方法会被Godot调用以获取配置错误
func _get_configuration_warnings():
var warnings = []

if idol.name == "":
warnings.append("不能没有名字")

if idol.age <= 0:
warnings.append("年龄不能小于0")

# Returning an empty array means "no warning".
return warnings

但这玩意儿也是很蛋疼——我这个Node还想在运行时发光发热呢,难道要我在每个业务的地方都检查当前是不是编辑器环境,只是为了在这里能有个提示?

实践见分晓吧,至少弹警告这个部分我觉着godot需要再想想。

Godot 类型

Variant,没有显式指定类型的变量都是 Variant……然而显式指定了不也只是编译期的吗……

Variant 几乎可以存储任何类型的数据,它和 Object 类似但设计目标不同——Variant 是为了性能优化和高效存储,尽量避免类型检查和转换的开销,同时也支持序列化,但这可以认为是某种……实现相关的东西,作为使用者,直接把 Variant 当成 Object 或 any 就行了,只要知道底层 Godot 会做比更换引用更多事情就好了

注意,Object 可以使用[]运算符访问属性,和 js 一样。如果需要访问不存在的属性,使用 Object 的setget方法。

但其实仍有差异——内置类型是 Variant 的子类,Object 是 Variant 的子类,RefCounted 是 Object 的子类,但各种 Node 都是 Object 的子类。这倒有点像 scala 之类的,这里不研究。

null,类型是 Nil,但这个类型好像拿不到。

int 是 64 位,float 是 64 位,但相应的数据结构,如 Vector2,Vector3,存的是 32 位

Godot 提供了诸多向量、矩阵类型。

  • Vector2/3:二维、三维向量,32 位浮点数
  • Vector2/3i:二维、三维向量,整数
  • Rect2:2D 矩形,包含 position 和 size 两个 Vector2
  • Transform2D:3x2 的齐次矩阵,描述 2D 旋转、缩放、平移
  • Plane:3D 平面,包含一个法线向量和一个标量表示距离(和原点的?)
  • Quaternion:四元数
  • AABB:3D 盒子,和坐标轴对齐,Axis-aligned bounding box
  • Basis:3x3 矩阵,描述 3D 旋转和缩放
  • Transform3D:包含 Basis 和 origin,等价于 Basis 的齐次矩阵

Array,它的索引方式和 python 一致。4.0 让 Array 可以有类型,让设置获取元素时有静态类型检查,但是方法仍旧没有检查。这个类型的实现不是实现了泛型,所以是残废,不能递归,比如Array[Array[int]]是非法的,另外,Array等价于Array[Variant]

另外,Typed Array 无法协变。

有所谓的 PackedArray,它更优化,但缺少一些方便的方法,在需要优化时考虑使用 PackedArray。

最后,Callable,获取方法时会得到 Callable,需要使用 call 调用。

初始化顺序

初始化时,变量的初始化遵循下面的顺序:

  1. 赋值变量的零值,如果变量已经指定类型是 int,false,bool 等,赋值该类型的初始值(也就是说,类型签名在运行时也有作用!),如果是 object 或没有标注类型,是 null
  2. 将初始值赋给变量,从上到下(推迟 onready 的)
  3. 执行_init
  4. 赋值 @export 注解的变量
  5. 初始化 onready 标注的变量
  6. 执行 _ready

初始化 Scene 时:

  1. 父节点变量初始化,_init
  2. 子节点变量初始化,_init
  3. 子节点 _ready
  4. 父节点 _ready(所以,父节点在 ready 后能够获取子节点信息)

注意只有类继承 Node 时才能有 onready 和 _ready。

枚举

枚举的语法同 C 那一套,但可以没有命名空间——等价于一串 const。

1
2
3
4
5
6
7
8
enum {TILE_BRICK, TILE_FLOOR}
# 等价于
const TILE_BRICK = 0
const TILE_FLOOR = 1

enum State {STATE_IDLE, STATE_JUMP = 5, STATE_SHOOT}
# 等价于
enum State {STATE_IDLE = 0, STATE_JUMP = 5, STATE_SHOOT = 6}

函数和 Lambda

函数可以有类型标注,可以有默认值,但没有关键字参数,剩余参数,且无法通过关键字调用函数

lambda,支持多行,但必须 return 返回值,调用需要使用 call 方法,而且该方法没有静态类型检查

1
2
3
4
5
6
# lambda,支持多行,但必须 return 返回值,调用需要使用 call 方法,而且该方法没有静态类型检查
# 这里的类型标注只在函数体内有意义
var ff := func(a: int, b: int) -> int:
return a + b

print(ff.call(2, 3))

函数是一等公民,下面玩一下高阶函数:

1
2
3
4
5
6
7
8
9
10
11
func map(fn: Callable, arr: Array) -> Array:
var res = []
for item in arr:
res.push_back(fn.call(item))
return res

func inc(x: int) -> int:
return x + 1

func _ready() -> void:
print(map(inc, [1, 2, 3]))

注意,注意,注意——Lambda 绑定变量是按值绑定!这点和 Java 一样,但是不限制变量是“实际上 final”的,这导致 Lambda 看不到其后对捕获的变量的值的改变。对于这点编辑器会有一个警告。

1
2
3
4
5
6
func wtf_man():
var a = 42
var f = func(): a += 1
a = 100
f.call()
print(a) # 100

解决方法和 java 倒也一样——用容器包裹:

1
2
3
4
5
6
func wtf_man():
var a = [42]
var f = func(): a[0] += 1
a[0] = 100
f.call()
print(a[0]) # 100

但是闭包能正常让变量逃逸……这个需要变量是 RefCounted 的?等遇到再看吧。

1
2
3
4
5
6
7
8
9
10
func mk_counter() -> Callable:
var counter = [0]
return func():
counter[0] += 1
return counter[0]

func _ready() -> void:
var counter = mk_counter()
print(counter.call())

OOP

一切字段、方法都属于特定的类,和 java 一样严厉。一切脚本默认都是匿名类,除非使用class_name语句标识。顶层的 extends 语句是配置这个类继承的类,没有给定默认继承 RefCounted。

类可以继承自一个全局的类(class_name 标注的就是全局的类),另一个 gd 文件,以及该 gd 文件下的内部类。

1
2
3
4
5
6
7
8
# Inherit/extend a globally available class.
extends SomeClass

# Inherit/extend a named class file.
extends "some_file.gd"

# Inherit/extend an inner class in another file.
extends "some_file.gd".SomeInnerClass

使用super.method(args)调用父类的特定方法,在特定方法中,使用super(args)调用父类的同名方法。

只有虚方法能够重写,尝试重写引擎提供的方法如 get_class,queue_free 会抛出错误

使用_init方法定义构造器。父类有构造器,子类则必须有且调用父类。

如果一个 Node 要当作 Scene 去使用,它不应当有有参构造器,会构造失败

此外,还有静态构造函数_static_init——在类加载时调用。

然后是内部类……一样的,就这样了。

属性

属性可以有 getter,setter,可以省略任一(好像只是省略,不是只读只写),语法很奇葩,属性是“物理的”——Godot 真的会创建一个字段对应属性,而不是像 python 那样需要手动写字段和属性对应,但仍旧可以像 python 那样操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 属性即字段:
var physic_prop: int = 0:
get:
print('get physic_prop')
return physic_prop
set(value):
print('set physic_prop')
physic_prop = value

var milliseconds: int = 0
# 像 python 那样操作:
var seconds: int:
get:
return milliseconds / 1000
set(value):
milliseconds = value * 1000

# 还有替代语法
var my_prop: int: # 可以不换行
get = get_my_prop, set = set_my_prop
func get_my_prop():
print('get my_prop')
# 这里是 ok 的,不会递归调用自己
return my_prop
func set_my_prop(value: int):
print('set my_prop')
my_prop = value

使用函数作为 getter、setter 的时候,Godot 知道这时候不走属性,不会重复调用。但 Godot 只会对 getter、setter 函数本身进行特殊处理,下面的代码则会造成永远的递归:

1
2
3
4
5
6
var my_prop:
set(value):
set_my_prop(value)

func set_my_prop(value):
my_prop = value # Infinite recursion, since `set_my_prop()` is not the setter.

“命名空间”

Godot 的一大缺憾是没有命名空间——要么始终使用文件名去互相引用,要么使用 class_name 注册到全局,但这会有命名重复的问题。

这里有一个 pattern,是 GPT 说的,不知道是否是最佳实践(官方文档就没提这个),它的思路是尽量少地使用 class_name,而是只在“模块”的顶层使用 class_name,然后在其中使用 const 和 preload 去暴露自己的子模块,这时候认为这些子模块的命名空间就是这个 class_name 了。

比如我闲蛋疼在 Godot 中做了个乐理库,然后抽象了 Note,Chord,Score 几个类代表音符,和弦,谱子;这时候如果要给它们每个都暴露到全局的话就必须得取更精确的名字,比如 YkiMusicNote 这样,很蠢。这时候我们就这么干——把它们丢到 yki_music 文件夹下,创建一个 main.gd 文件,然后写:

1
2
3
4
5
class_name YkiMusic # 尽量精确

const Chord = preload('./chord.gd')
const Note = preload('./note.gd')
const Score = preload('./score.gd')

然后用户使用的时候就直接YkiMusic.Chord.new,就像其他编程语言 import 模块一样。

这有个可能的缺陷——Chord 对应的类实际上仍是匿名的,debug 和打印的时候……可能会露馅?管它呢,不是大问题。总之使用这种方式,就能够实现命名空间了,这为模块化带来方便。

内存管理

GDScript 使用的是引用计数而非垃圾回收——继承 RefCounted 的,没被引用的对象自动地马上就会被回收。Node 没有继承 RefCounted——它们只能被手动回收通过 free 和 queue_free

引用计数同样带来一个问题——循环引用会导致内存泄漏,编程时要注意这一点。weakref 可以创建弱引用。

信号,协程

这个好像本质上没啥东西,我目前学的东西好像足够,先放放。

文档注释

GDScript 的文档注释的格式没有随 Python,很遗憾。

GDScript 的文档注释以##开头,它后继要注释的对象(或者和要注释的对象在同一行,即 inline 注释),就像 python 之外的一切语言做的那样。对整个脚本的注释则必须要放在脚本开头(extends 之后)。文档注释支持 BBCode 以添加样式(我倒是第一次见用这个的,活见鬼,这玩意儿不是论坛用的吗)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
extends Node2D
## 第一行是简略介绍
##
## 然后是对脚本的详细介绍,一大段一大段,支持的 tag 非常有限,
## 只有 tutorial,deprecated,experimental,tutorial 是什么鬼??
## deprecated 和 experimental 在 vscode 插件中好像都不支持
##
## @tutorial: http://someTutorial.com
## @tutorial(title): http://someTutorial.com
## @deprecated: Use [AnotherClass]
## @experimental: unstable

## 某个 signal
signal my_signal

## 某个枚举
enum Direction {
## 攻
UP,
DOWN, ## 受
}

## 某个变量,export 的会直接在编辑器中作为 tooltip 显示 [br]
## 使用 BBCode 语法可以让注释为多行
@export
var my_var: int

## 某个函数,好像没法给参数做注释
func map(arr: Array, fn: Callable) -> Array:
return []

## 内部类和脚本的语法一样
##
## 详细介绍……
## @tutorial: https://someTutorial.com
class SomeInnerClass:
pass

关于 BBCode 的用法,见https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_documentation_comments.html,可以显示的东西是很多的。

异常处理

Godot 中没有异常处理,也不支持泛型。Godot 中提供了一个 Error 枚举可以用来表示特定错误,函数可以返回 Error 来表示它求值是否成功。

但是,我如果想要这个函数返回一个值呢?哈哈,Godot 不支持泛型,也不支持元组,这导致无论是想走 Result Monad 的路子,还是想走 go 的路子,都不行。

按需求来吧,这点也太坑了……