GDScript 零基础图文入门

各位热爱游戏的小伙伴们大家好。

随着 Godot 的热度越来越高,有很多无程序开发基础的小伙伴认识了 Godot,个人认为 Godot 从 4.0 版本开始,已经成为了最适合游戏开发新人上手的引擎(易上手 不等于 功能简单),她相较于 Unity 更易学习,且功能足够各位开发者实现自己的梦中游戏。

开发游戏通常离不开编写代码,Godot 支持的编程语言中 GDScriptC# 是使用最广泛的:

  • C# 是微软公司开发的编程语言,虽然普及率不如 JavaPython 等“超一线”语言,但其功能十分强大,且拥有“宇宙第一优雅”的编程语法,但由于它真的很强大且语法实在“优雅”,学习它会花费更多的时间与精力。

  • GDScript 听名字就知道这是 Godot 自己的编程语言,因为是为游戏开发而设计,语言中的每一项功能都是为了方便我们做游戏而诞生,因此在实现同样功能的情况下,GDScript 会比 C# 少很多代码,学习起来也更加轻松。

综上所述,我更推荐新人者从 GDScript 上手,这将是一条轻松愉悦的学习路线。

[!tip] 新人学编程第一大门槛:选择编程语言。

我这里要说:随便选一个语言就行(当然现在我推荐 GDScript 了就不要乱选了233),重要的是坚持学到最后,不要中途放弃或者换语言。其实绝大多数编程语言都是共通的,就像你学会合成铁镐子后自然就会合成钻石镐一样,当你通过本文学会 GDScript 后,自然也会看的懂很多其它语言的代码。

[!tip] Godot 版本

编写本文时 Godot 刚刚推出了 4.0 rc2,所以文中按照 4.0 版本讲解。

关于本文

以前我在 Gamemaker 那边搞一对一教学来着,最近喜欢上了 Godot,发现这边完善的中文资料少之又少,尤其缺乏针对零基础新人的教程,因此我计划结合之前在 gamemaker 那边的教育经验编写本文,希望可以帮助各位。

我不是专业写东西的,所以文中肯定会出现问题,如有错误或疑问还请及时反馈,如果真的是本文出了问题,尽早改正可以防止误导更多人。

如果你有不懂的地方或者文中没提到但是想学的东西也欢迎联系我,各位的反馈和建议可以让文章更加完善。

反馈途径

可通过作者(Rika)QQ:2293840045 联系我,或者直接使用 git 仓库的 issue 等功能。

[!tip] 广告时间

本文作者靠在线一对一教学赚零花钱,如果你想零障碍快速学习游戏开发或者其他什么编程知识,欢迎联系我。

帮助编写

详见 Git 仓库的 README。


[!tip] 想要下载本文?

在这个页面的右上角有一个打印按钮,点击即可保存 PDF 版本。

或者,直接点击这里:下载 PDF

不过,由于本文一直在持续更新,还是建议在线阅读以保证能看到新内容。

Q: 为什么不录视频教程?

A: 首先视频教程制作太麻烦,其次,我认为通过视频学习东西其实更加费时费力,文档可以随时回头看,视频容易看一遍就自我感觉都会了。


想给这篇文章起个名字,叫做《灵动 Godot:小白的 GDScript 编程入门》怎么样?


本文使用 Mdbook 编写。

感谢 lightyears 同志提供的 GDScript 语法高亮支持。

感谢 saierXP 同志指出问题并提供建议。

开始

Godot 官网:https://godotengine.org/

Godot 最新正式版下载:https://godotengine.org/download/windows/

所有版本下载:https://downloads.tuxfamily.org/godotengine/

本文讲解 GDScript,所以下载普通版,就是没有 .NETMono 字样的版本即可。

什么是编程?

编程说白了就是写代码,而代码是控制计算机运行的指令。

对于游戏来讲,游戏中的每一处逻辑都是由代码来控制,游戏的运行离不开代码。

假设要实现玩家点击空格开火,不点空格回血这个逻辑,那么就需要写一段这种代码:

游戏中的每一帧都执行:
    如果玩家点了空格:
        开火
    否则(没点空格):
        给自己加血

[!note] 伪代码

上文中这段奇怪的文字被称作伪代码,也就是说这并不是真正的程序代码,但是它可以更清晰的表达真实代码的逻辑,通常在设计某些复杂代码之前写出来当草稿用。

看起来这好像和编程没什么关系,但我没有糊弄你,真正的代码基本就是这个样子:

func _process(delta):
    if Input.is_action_just_pressed("space"):
        fire()
    else:
        hp += 1

我们学习编程,实际上就是把脑海中的伪代码转换成实际代码,为了完成这个转换工作,首先需要记住一些代码语法,但请注意,代码语法十分简单,这不应该是编程学习中的重点,真正需要你费头脑的是想出那些伪代码,至于语法格式,忘了就翻翻这篇教程,忘多了就会了。

在 IT 行业工作的程序员中有一种职位专门负责提供思路,也就是“创造伪代码”,那些底层的码农们就负责把“伪代码”翻译成程序语言。(很明显创造伪代码的人工资更高)

第一句代码

如果你就算只碰过一点点编程,应该也会见过这么一句话:

"Hello, World!"

这是计算机行业的元老级 meme 之一,当人们在学习一门新编程语言时,就会想办法让那个语言把上面这几个字母显示在屏幕上。

这时你可以打开 Godot,创建一个 Label,然后在 text 属性中写上 Hello, World!,运行后(或许不用运行)就会看到屏幕上出现了这几个字母,恭喜,你学会 GDScript了!

创建HelloWorld标签

等等,那说好的代码呢?

很明显上面是一些玩笑话,但我建议你还是尝试一编上面的步骤,毕竟你是一位要成为边城带师的人,这种点点鼠标的操作还是要熟练掌握才行的 。

本文重点在 GDScript,不会涉及太多的引擎操作知识,如果你还不会引擎的基本操作,建议打开 Godot 到处点一点,很快你就会熟悉她的界面逻辑了。

给纯新人小伙伴的一个建议:放纵自己的好奇心,对感兴趣的东西先动手尝试再寻求帮助更好。

print

下面我来带大家用代码的方式显示一句话。

首先我们要明白 Godot 中构成游戏的基本单位是节点,也就是默认界面左上角的那些东西。我们写的代码被称作脚本(不是玩游戏开挂的那个脚本哈),每个节点可以绑定一个脚本来扩展节点的功能,因此想执行咱自己的代码,第一步就是要有一个节点。

目前随便创建一个节点即可,然后选中节点,点击添加脚本按钮:

创建节点并添加脚本

在最后出现的窗口中,是对这个新脚本的设置,建议勾上内置脚本选项,其余目前不用改,接着点击创建即可。

接着咱就被带到了一个代码编辑器中,看起来应该是这样:

extends Control

# Called when the node enters the scene tree for the first time.
func _ready():
    pass # Replace with function body.

# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta):
    pass

第一行可能不太一样,这取决于刚刚创建的节点是什么类型。

现在目标聚焦 func _ready(): 这一行,在这一行的最后咱们加一个回车,然后输入 print("Hello, World!"),完事后这一段应该是下面这个样子:

func _ready():
    print("Hello, World!")
    pass

[!tip]

注意不要改动其他地方的代码,上面我放了三行代码只是完整代码的一部分,因为每次都贴完整代码太长了,所以我就省略了其他部分,但你不要省略。

注意 print 的前面应该是一个制表符,Godot 应该会自动帮你加上,如果你发现 print 和下面的 pass 没有垂直对齐,可能需要手动在前面输入一个制表符(按Tab键输入)。

接下来,保存场景、运行游戏,发动你的火眼金晶在屏幕上找到 Hello, World! 吧!

helloWorld

恭喜你已经成功运行了第一句 GDScript 代码!

[!warning] 常见问题

  1. 在编程时我们要保证自己的输入法处于英文状态,一般的编程语言不认识中文符号,看看你 print 后面的括号和双引号是英文的吗?

  2. Hello, World! 两边的双引号在这里表示中间夹着一段文字,因此不要省略这俩双引号!

  3. 运行的场景要选择当前编写代码的场景,不要运行错了场景。(可以点击F6直接运行当前场景)

... 更多问题等待你的反馈,我会在这里解答

[!note]

print 这句话的功能其实就是在 Godot 引擎界面中显示一些东西,虽然玩家看不到,但对于我们开发者来讲通常会利用 print 进行一些排错工作,起到类似于日志的作用。

脚本结构

上一节咱们运行了一句代码,但我估计你应该还是比较蒙,这节就来看一下之前那篇代码的每一句话是什么意思。

代码语法之:注释

首先来认识一个最简单的语法,它叫做注释

因为代码本质上还是给计算机看的东西,在逻辑复杂或代码太长时,人类阅读起来非常困难,人们就在代码中穿插加入一些小笔记来解释代码的逻辑和用法,这种小笔记就是注释了。

在 GDScript 中输入一个井号 # 就表示注释,从这个符号开始到这一行结束都属于注释范围,所以再来之前那篇代码,就会发现其中很多东西都是注释(方便理解,我把注释翻译成了中文):

extends Control

# 在节点第一次进入场景树时执行
func _ready():
    pass # 用方法体替换这里

# 每一帧执行一次,'delta' 指上一帧和这一帧的间隔时间
func _process(delta):
    pass

可以看到,Godot 还贴心的留给了我们三句话。。。

[!tip]

注释不会被当作代码执行,如果你给刚才的 print 前面加上 # 号,那么 print 就不会执行了,所以有时遇到了某段代码不确定要不要删除,也可以先用注释的方式临时屏蔽。(虽然规范上不建议这样做,但是一个人开发的情况下老子就是规范)

[!tip]

GDScript 本身不支持多行注释,但可以用多行字符串语法冒充多行注释,语法为三对单引号 '''多行注释1''' 或者三对双引号 """多行注释2""",但不推荐使用,因为这个多行字符串是实际的脚本数据,在导出项目后仍会保留。(不建议各位使用这种语法,但学习他人代码时如果遇到了,希望各位能看懂)

代码语法之:方法

方法这个名称实在是抽象,它还有另一个名称叫做函数,行吧,也比较抽象。。。

你可以把方法理解成一段保存好的代码,在需要执行的时候调用一下就会执行。

GDScript 中使用 func 表示一个方法,后面紧跟一个方法的名字,所以再看上面的代码,我们会发现其中包含两个方法,分别是_ready_process

方法的最后有一个冒号,然后另起一行的内容就是方法体,也就是这个方法所包含的代码, pass 关键字表示这个方法没有代码,也可以把 pass 替换成咱自己的代码。

之前写的 print 下面还跟着一个 pass 呢,那个 pass 其实没用,删掉也可以。

方法和函数这两个名字我也经常混着用,如果看到后面发现我提到了函数这个词,要知道指的就是方法。

结合官方给的注释,现在我们大致明白之前的 print 代码是怎么回事了,因为咱把 print 放在了 _ready 方法当中,按照注释“在节点第一次进入场景树时执行”所说,咱们的 print 就跟着 _ready 在节点第一次出现时执行了一次。

[!tip] 还是有点蒙吗?

这一节的内容留个印象即可,后面会再详细讲解,现在只需要知道两件事:

  1. _ready 方法中的代码会游戏刚运行时执行

  2. _process 方法中的代码会在游戏运行时的每一帧执行

[!tip] 啥叫场景树?

Godot 中把一堆节点构成的东西称作,那么场景树就是这个场景中节点所组成的树了。

至于进入场景树就是指加入到场景树中,也就是游戏刚开始时了。

后续预告

到这里,你就没有回头路了
-- 沃夏·硕德

为了保证学习不枯燥,后续教程中会穿插一些实践内容,这些小节标题以整活开头,各位小伙伴也可以整自己的活,本文整活仅供参考,大家要发挥自己的想象力。

后续的学习路线

对于零基础新人来讲编程往往是最大的门槛,引擎界面的操作和节点的用法这类表面事物,自己琢磨或简单看看视频就能学会。

从这篇文章的标题可以看出,本文重点讲解 GDScript 编程知识,单独阅读本文并不能保证你学会 Godot,但优先学习 GDScript 能使你更轻松的学习其他教程资料。

最后,提醒各位新人,学习 Godot 要主动自学,这并不是因为 “Godot 资料少”一类原因造成的,因为每个人的心中都有属于自己的游戏,他人的教程终究不能完全覆盖你需要的知识。

资源汇总

考虑到 godot 学习资源稀缺,所以在此汇总了一些资源,在学习完本教程后,可继续学习。

视频教程

完整教程

勇者传说 (B 站视频)

Godot 4 吸血鬼生存复刻教程(B 站视频)

RTS即时战略游戏 (B 站视频)

横向卷轴像素艺术游戏(B 站视频)

资源收集游戏(B 站视频)

从零开始制作土豆兄弟(B 站视频)

Godot 俯角射击游戏教程(B 站视频)

Godot4.0 2D游戏全要素全技能速成教程(B 站视频)

Roguelike 游戏系列(B 站视频)

经典的俄罗斯方块游戏(B 站视频)

godot教程100集(B 站视频)

知识点

Control节点实例—背包系统(B 站视频)

2D可破坏地形(B 站视频)

将 VS Code 连接到 Godot(B 站视频)

文档资源

godot官方简体文档

GODOT RECIPES:英文网站,本网站收集了各种解决方案和示例,可帮助您制作所需的任何游戏系统。

Liuqingwen 的个人博客:包含众多 Godot 中文资料。

资源网站

开源素材的整合站:国内的开源素材的整合站,制作游戏时不再因为找可商用素材花费大量时间

godot工坊:国内的 Godot 论坛,包含文档、教程、资源、答疑等板块。

itch:国外的独立游戏交流平台,有很多资源和工具,还能玩其他作者的游戏。

kenney:国外的资源网站,上千免费资源任你使用,大多是 low poly 或像素风。

Game UI:国外的游戏 UI 网站,主要用来学习和参考。

Godot.A.L:一个国外的 Godot 资源网站,看起来比 Godot 自带的资源网站高级一些。

Godot Market Place:国外的 Godot 资源市场,类似上一个,包含付费内容。

[!tip] 英语的重要性

在此尽量罗列的是中文教程、国内的网站搬运的带字幕的教程。

godot的教程大部分为英文编写,掌握好英文非常重要(善用翻译工具同样重要)。

基础语法

GDScript 是一门十分像 Python 的语言,但针对游戏进行了很多优化。

本章讲解基础语法,略微涉及一些实践内容。

变量

变量是代码中用来存放数据的基本单位(抛开计算机底层不谈),游戏中会变的数据都是变量。

玩家的生命值、游戏分数这种直观看到的数字是变量,玩家的位置、任务进度这种抽象的数据也是变量。

在 GDScript 中使用 var 关键字声明变量,它的基本语法格式如下:

var <变量名> [: 类型] [= <初始值>]

[!tip] 语法格式解读

本文中使用上面这种格式展示语法,其中尖括号引用的内容表示必须填写,并把尖括号内的东西换成该填入的字符,例如<变量名>就表示这里必须要写一个变量名,至于名字是什么你可以自己来定。

方括号表示可选填写,例如 var 语句中的类型初始值就可以省略。

变量声明示例

var 玩家名称 # 单纯声明一个变量,存储值为空,后续可以存储任意类型的数据。
var 玩家生命值 = 100 # 声明变量初值为 100,后续可以存储任意数据类型的数据。

var 敌人名称: String # 声明变量初值为空,并限定类型为字符串,后续只能存储该类型的数据。
var 敌人生命值: int = 100 # 声明变量初值并同时限定类型为整数,后续只能存储改类型的数据。

# 若指定了初始值,使用 `:=` 语法可以自动推导变量类型,等价于上一种变量声明格式,后续只能存储该类型的数据。
var 战斗回合数 := 300 # 等价于 var 战斗回合数: int = 300

关于变量类型我们会在下一节讲解。

变量声明的位置

方法体的内部和外部都可以声明变量,但实际作用不同(具体参见[作用域]章节):

var 生命值 = 100 # 放在了 _ready 的外面
func _ready():
    var 生命值 = 100 # 放在了 _ready 的里面

当放在方法外面,也就是文件最外层时,表示这个变量属于当前节点,也就是说这个节点现在拥有了生命值这个属性,这个变量随着节点一起出现和消失。

当放在方法里面时,这个变量就成了一个临时变量,当方法被执行,程序运行到 var 语句这一行时就会创建这个变量,当方法执行完毕时,这个变量就会自动消失。

因此可以感觉到,如果要处理一个持续的数据,应该把变量的声明放在方法的外面。

变量赋值

现在假设咱们给节点声明了生命值这个变量,现在希望它每帧扣除一滴血,这时就可以使用变量赋值语句来修改变量的值,赋值语句格式如下:

<变量名> = <新的值>

可见其实就是把 var 关键字去掉了而已,既然这样,实现扣除一滴血的代码就可以这样写:

func _process(delta):
    生命值 = 生命值 - 1

由于 _process 是每帧执行一次,所以上面代码就实现了每帧扣除一点血。

注意不要在前面加上 var,否则就成了每帧声明一个新变量。

[!tip] 什么是关键字

关键字就是指 GDScript 中具有特殊含义的一些单词,例如 var,他就表示创建变量。

[!tip] 中文变量名

Godot 从 v4.0-beta15 的开始支持 Unicode 编码(万国码标识符,包含中文编码)。但大多数人更喜欢英文变量名,本文为了阅读方便考虑(我怕我英语渣闹笑话)采用汉字变量名。

[!tip] 声明?创建?

在 生命值 这个例子中声明就等于创建,但不要和后面要学到的创建(实例化)对象搞混。

[!note] 变量名命名规则

上面提到了,变量名是自己起的,但很明显不能乱起,例如我让一个变量叫 var,这肯定很怪,所以变量名有以下规则:

  • 不能使用数字开头

  • 不能包含特殊字符,例如空格、加号等(唯一支持的符号是下划线 _)

  • 不能和关键字重复

本文为了阅读方便,将尽量使用中文命名,这样读者可以根据变量名快速区分 Godot 内置变量和我们定义的变量,不过在一般的开发中,变量名还是以英文为主。

变量名的一个特例,match 是个关键字,但是变量名可以为 match。当然,我们都不推荐这类同名,因为容易混淆。

数据类型

对于人类来讲,看到 5 就知道这是数字,看到 Hello 就知道这是单词,那么数字单词这种概念在计算机中就被称为数据类型。

GDScript 中的基本数据类型有这些:

  • 整数int),例如 100100 都是整数
  • 小数float),例如 3.140.10.04.0 都是小数
  • 字符串String),用双引号或单引号包裹的内容是字符串,例如 "Hello, World!""我是字符串" 都是字符串
  • 布尔值bool),这种类型表示真假或对错,只有 truefalse 这两个值,对应

下面是使用时的例子:

var hp      = 100
var pos     = 4.2
var name    = "Rika"
var walking = false

[!tip] 代码格式

实际上变量名后面可以不写空格直接跟上等于号,我为了美观让这些等于号垂直对齐了,实际开发中根据自己喜好加空格即可。

不过,通常的编码规范都要求能加空格的地方至少加一个空格,毕竟密麻麻的字母看起来容易眼花。

其实大部分代码编辑器都有自动格式化代码的功能,可以一键美化代码,可惜目前的 Godot 还不支持(截止 4.0 rc2)

数据类型转换(自动)

我们看一段代码:

var a = 10
var b = 3.14
var c = a + b

首先我们定义了一个变量 a,它存放整数 10,又定义变量 b,存放小数 3.14,最后定义变量 c 存放 a 与 b 的和,那么问题来了,变量 c 中存放了一个什么类型的数字呢?

a + b 这个式子是 整数 + 小数 的形式,为了能够正确的到 13.14 这个数,他的结果显然是小数类型。

虽然整数参与了运算,但结果是小数,也就是说这其中自动进行了数据类型的转换,不过因为是自动进行的,所以我们通常不用在意。

这种自动类型转换在不同语言中有不同的名字,例如有的叫 隐式类型转换,有的叫 自动类型提升,这东西和计算机存储数据的原理有关,在这里就不过多讨论了。

为什么需要数据类型转换?

我们再看一段代码:

print(10 / 3)

思考,这会显示什么呢?

按道理讲这应该会显示 3.一堆3,但实际我们只能看到一个数字 3,这是因为这个 10 / 3 是整数运算,这种情况下得到的结果也必定是整数,因此就看不到小数点后面的内容了。

这时我们可以手动修改一下:

print(10 / 3.0)

对于 GDScript 来说,只要数字包含小数点,即便小数位全是 0,这个数也是小数类型,所以修改后的 print 就能正常显示 3.一堆3 了。并且这其中还对整数 10 进行了自动类型转换。

计算机存储空间有限,小数位数肯定也有限,实际显示的是 3.33333333333333,不会特别长。

如果想要运行看看效果,记得把这些代码都放到 _ready(): 这个方法体中再运行游戏。

数据类型转换(手动)

有自动类型转换,肯定也就有手动类型转换了,现在把刚刚的 10 / 3 代码稍作修改:

var a = 10
var b = 3
print(a / b)

这个的结果大家已经知道,就是 3,现在假设,变量 a 和 b 是从其他地方获取的值,这个值就是整数,不能通过添加小数点的方式让它变成小数。

于是需要我们手动进行类型转换了,它的语法格式如下:

<目标类型>(<被转换的值>)

其中目标类型可选如下:

  • int - 整数
  • float - 小数
  • bool - 布尔值
  • str - 字符串

我们需要把整数变成小数,所以目标类型就是 float,接着来修改 print 这一行:

print(float(a) / float(b)) # 只给一个变量加上 float 也行,另一个会自动转换类型

接着就能再次看到 3.一堆3 了。

[!tip] 四舍五入

int(1.9) 的结果是 1,也就是直接抛弃小数,如果想要四舍五入取整,可以改成 round(1.9),这样结果就是 2 了。

[!tip] 函数调用

你应该已经发现,float(XXX) 这种东西和 print(XXX) 的格式一摸一样,这种格式被称为方法调用,在后面方法章节会单独讲解。

表达式与运算符

表达式是由一些值和运算符组成的式子,例如 10 + 2 这种。

GDScript 中表达式分为这么几类:

  • 算数表达式
  • 关系表达式
  • 逻辑表达式
  • 赋值表达式

算术表达式

顾名思义,算术表达式就是指数学运算的式子,例如 5 + 2 100 * 0.5 等,运算符有这些:

  • +
  • -
  • *
  • /
  • % 求余(取模)

最后一个取余数可能有点忘了是什么,其实就是小学二年级还没学小数的时候,“两个数除不尽”剩下的那个数,例如:

print(10 % 3) # 显示 1,因为 10 / 3 商 3 余 1
print(15 % 5) # 显示 0,因为 15 / 5 商 3 余 0,正好除尽

同时不要忘记先算乘除后算加减,必要时使用括号改变运算顺序:

((10 + 4) * 2 - 8) * 2

不要轻视这个小学知识点,在修改代码时可能会忘记前后的符号导致运算优先级出错,这种 bug 找起来十分麻烦。

[!tip] % 的优先级

是先求余还是先加减乘除?哈哈,我不告诉你。

当你的式子很长时,建议用一个临时变量保存一下中间结果,保证编程思路的清晰比什么都重要。

如果真的要在式子里混合求余运算符呢?那就加括号呗。

我写了七八年代码也没记住 % 的优先级,毕竟没有必要记住

[!note] 负数

直接在数字或变量前加一个减号即可表示负数,例如 print(-10),或是先 var a = 1print(-a) 即可显示 -1

[!note] 字符串拼接

加号 + 的两侧若为字符串,还表示字符串拼接,例如 var a = "a" + "b",那么 a 里面就会存放 ab 这两个字符。

关系表达式

关系表达式的运算符如下:

  • > 大于
  • < 小于
  • >= 大于等于
  • <= 小于等于
  • == 等于
  • != 不等于

这些应该也没什么好讲的,那么看一句代码:

print(10 > 9)

嗯,很简单,可是......它会显示什么呢?

这时要了解关系表达式的一个特点:它的结果是布尔值,也就是 true 或 false。

因为 10 > 9 这是个正确的不等式,所以结果是 true,假如有一句 print(10 == 9),那么这就会显示 false 了。

逻辑表达式

逻辑表达式的运算符有这些:

  • not 非(否)
  • and 与(同时为 true)
  • or 或(任意为 true)

乍一看比较蒙,根据括号里的词语理解一下,这三个运算符是对布尔值进行计算的。

第一个 not 是一个一元运算符,它只对一个数据进行操作,写在被操作数的前面,例如 not false 就表示 true

and 运算符只有在两侧都为 true 时结果才是 true,否则结果一律为 false

orand 相反,两侧都为 false 结果才是 false,否则结果为 true。或者理解成只要有一个 true,结果就是 true

赋值运算符

我们一直看到的等于号 = 其实就是一个赋值运算符,它的作用就是把右边的值放到左边的变量中。

其他的常用赋值运算符还有 += -= *= /=,他们分别表示根据变量中原有的值进行相对运算并保存到变量中,例如:

var a = 10
a += 10  # a 在原来的基础上又加了10,变成了20
a *= 2   # 由于上一句代码,a已经是20了,再乘上2,就变成了40
a /= 10  # 40 / 10 得到 4,a变成了4
print(a) # 显示 4

多种运算符混合计算

我们貌似已经发现了规律:

  • 数学运算符的参数是数字,结果是数字
  • 关系运算符的参数是数字,结果是布尔值
  • 逻辑运算符的参数是布尔值,结果是布尔值

所以,当上述多种运算符组合在一起时,会先运算数学运算符,接着是关系运算符,然后运行逻辑运算符,当然最后是赋值运算符。

[!tip] 等于和不等于

==!= 两侧的值不一定是数字,例如 "Abc" == "Abc" 的结果是 true"123" == 123 的结果是 false

综合例子

现在来测一测自己,下面这段代码的每个 print 会显示什么?

print(5 - 3 < 10)
var a = 100
print(a >= 100 and false)
a -= 99
a *= 10
print(a + 10)

答案在下面

.

.

.

.

.

.

.

.

.

.

.

.

true

false

20

强类型变量

我们之前定义的变量是没有限定类型的,这种变量中可以存放任意类型的值,例如下面代码:

var a = 10
a = "Hello"
a = false

变量 a 依次存放了数字 10、字符串 Hello、布尔值 false,也就是说 a 变量是不限定类型的。

这样的变量简单易懂,但说实话,并不易用。

绝大多数变量都应该只存储一种类型的数据,例如存放玩家名称的变量,肯定永远存放字符串,玩家生命值变量,肯定永远存放数字。

在定义变量时,在变量名后加上冒号和类型来明确变量类型,例如:

var 生命值: int = 100
var 玩家名: String = "Rika"

这种变量是不能存放其他类型的数据的,例如 生命值 = "满血" 这样的代码就会出错,因为 生命值 变量是 int 类型的,不能存放字符串。

[!tip] 什么叫“强类型”

可以把强类型理解成强制类型,就是强制要求变量只能存放某种类型的值,与之对应的就是弱类型,也就说不加 : 类型名 这样的变量。

推导类型

每次都加上一个冒号和类型会比较麻烦,所以 GDScript 提供了一种语法::=,用法如下:

var 玩家生命值 := 100     # 等于 玩家生命值: int
var 玩家名称 := "Rika"    # 等于 玩家名称: String

使用 := 符号后,GDScript 会根据右侧的变量初始值推断类型,例如例子中的 玩家生命值 变量,由于初始值是个 int 类型的 100,所以这个变量就是 int 类型的。

这个语法要求变量必须有初始值,毕竟 GDScript 需要根据初始值才能去推导变量的类型。

[!tip] 强类型有什么用?

看似使用强类型会比较麻烦,但强类型能显著减少我们写代码时的犯错概率,原理就不解释了,大家继续学习就能体会到。

[!note]

强类型语法只在声明变量的时候才用,给变量赋值的时候不能用!

整活:你好XXX

经历了枯燥的变量、数据类型、运算符的学习,欢迎来到第一个整活章节。

哈哈,也不要以为整活章节就不用学东西了,毕竟想一想,就目前学的 GDScript 能整什么活呢?

所以在整活章节中,我们需要学习 GDScript 操作 Godot 游戏的方法,这一节咱们实现这样一个功能:

Hello

这里有一个按钮和一个输入框,点击按钮后,按钮上就会显示 你好:<输入的内容> 这句问候。

场景创建

场景很简单,共有三个节点:

Tree

添加好后调整它们的位置即可,怎么摆放大家随心即可。

接下来给最外层的 Control 节点添加一个内置脚本,暂时不需要修改里面的内容。

然后关键步骤来了,选中按钮,接着点击引擎右边的 节点 选项卡,进入 信号 列表,找到其中的 pressed() 并双击:

双击信号

然后就会看到一个弹窗,在窗口中选中添加了脚本的 Control 节点并点击连接

连接信号

然后我们的脚本中就会多出一个 _on_button_pressed 方法,应该长这样:

func _on_button_pressed():
    pass # Replace with function body.

解读

当按钮被点击时,就会发出 pressed 信号,而现在按钮的 pressed 信号连接了脚本中的 _on_button_pressed 方法,就意味着点击按钮就会执行这个方法,你可以在方法中先写一句 print 试试。

获取输入

我们接下来需要获取玩家的输入,因此就要先找到输入框节点,获取节点的语法是这样的:

$<节点路径>

目前输入框的节点路径就是他的名字,所以咱可以在 _on_button_pressed 方法中写下:

var 输入框: LineEdit = $LineEdit
var 按钮: Button = $Button # 顺手把按钮也拿到

[!tip]

LineEditButton 是节点的类型,这些节点类型也是一种数据类型,可以用在强类型语法中。

[!tip]

$ 语法本质上是一种简写,它的完整写法是一个方法调用,写做 get_node("节点路径"),不过还是 $ 写法更简单实用。

输入框节点的 text 属性表示输入的值,通过一个小数点的点 .,可以从节点中获取属性,所以:

var 输入值: String = 输入框.text

按钮的显示文字也是 text 属性控制的,所以把这个输入值加上前缀,再交给按钮的 text 就完成这节整活了。

按钮.text = "你好:" + 输入值

最终的完整方法应该长这样:

func _on_button_pressed():
    var 输入框: LineEdit = $LineEdit
    var 按钮: Button = $Button # 顺手把按钮也拿到
    var 输入值: String = 输入框.text
    按钮.text = "你好:" + 输入值

[!tip]

文中代码使用了强类型语法,也可以不使用,但推荐加上

代码块

之前我们简单介绍了一下代码块概念,这里再复习一下。

代码块就是指一片代码的集合,通常使用一个冒号 : 开头,然后使用相同的缩进表示,某些其他语法需要由代码块来组成。

例如方法:

func _ready():
    print("我是代码块里第一行代码")
    print("我是代码块里第二行代码")

现在假设有一个 print 向右缩进了一点:

func _ready():
    print("我是代码块里第一行代码")
        print("这一行会出错")

由于第二个 print 缩进和上面不一致,那么这个 print 就不属于方法的代码块了。

同理,向左缩进也一样:

func _ready():
    var a = 2
var b = 200

上例中的变量 b 就处于 _ready 方法的外面。

分支结构

我们之前写的代码其实并没有什么逻辑可言,它们只会按照顺序一行行执行而已。

在真正的游戏中有很多分支,例如玩家与 NPC 对话时,如果完成了任务,NPC 就会给玩家奖励,否则 NPC 就会告诉玩家一些任务提示。这里的任务是否完成就是一个分支条件。

如果

在程序中使用关键字 if 来表示分支,它的语法格式如下:

if <逻辑表达式> :
    <代码块>

if 可以直接理解成汉语中的如果,当逻辑表达式的值为 true 时就会执行下面的代码块,否则就不执行。

其中的逻辑表达式就是指一条运算结果为逻辑值的式子,现在假设咱有个变量 var 任务完成数 = 10 来表示当前完成了几个任务,当完成 20 个任务时显示恭喜通关:

func _ready():
    var 任务完成数 = 10
    if 任务完成数 >= 20:
        print("恭喜通关了")

目前运行游戏的话你会发现什么也没有显示,毕竟任务完成数现在是10,if 后面的条件不满足,也就不会执行里面的代码。当然你可以手动把变量值改成 20 以上的数字再试试。

否则

现在想完善一下刚才的程序,在任务完成数量不达标时提示玩家需要完成更多任务,那么以目前学到的知识,我们可以写成这样:

func _ready():
    var 任务完成数 = 10
    if 任务完成数 >= 20:
        print("恭喜通关了")
    if 任务完成数 < 20:
        print("完成的任务还不够多")

这样虽然可以实现效果,但是我们可以发现两个 if 的条件语句正好相反,这时可以把第二个 if 语句替换成 else 语句:

if 任务完成数 >= 20:
    print("恭喜通关了")
else:
    print("完成的任务还不够多")

很明显这个 else 就是否则的意思,当上一个 if 条件不满足时则执行。

否则-如果

现在我们想让这个例子更复杂一些,具体规则如下:

  • 当完成 20 个任务时显示 恭喜通关
  • 完成 15-19 个任务时显示 马上就完成了
  • 完成 5-14 个任务时显示 加油
  • 完成 5 个以下时显示 这才刚刚开始

于是我们可以写出:

if 任务完成数 >= 20:
    print("恭喜通关")
else:
    if 任务完成数 >= 15:
        print("马上就完成了")
    else:
        if 任务完成数 >= 5:
            print("加油")
        else:
            print("这才刚刚开始")

这一层一层的 else 语句看起来实在是不好看,那么再认识一个新的关键字:elif

elif 就是 else 和 if 的组合形式,它会在上一个 if 的条件不满足时判断自身条件,如果自身条件满足则执行自己的代码,同时 elif 可以多个串联使用。

修改之前的代码,使用 elif 语句的结果如下:

if 任务完成数 >= 20:
    print("恭喜通关")
elif 任务完成数 >= 15:
    print("马上就完成了")
elif 任务完成数 >= 5:
    print("加油")
else:
    print("这才刚刚开始")

[!tip] 条件判断可以避免重复

想想我们的条件中提到的是15-19个任务显示马上就完成了,但为什么我们的 if 和 elif 后面只判断了 任务完成数 >= 15 呢?

这里就要想一下 else 和 elif 的一个特点:只有在上一个 if 或 elif 的条件不满足时才判断。

由于第一个 if 已经判断了 任务完成数 >= 20 的情况,那么下面的所有 elif 和 else 中的任务完成数就不可能包含 20,所以我们就只判断 >= 15 即可。

[!tip] bool 值变量可以直接放到表达式里

一个变量也可以组成表达式,假设有个变量 var a = true,想判断 a 是否是 true 时千万不要写 if a == true,直接写 if a 即可。(虽然写成第一种也不会出错,但就是会显得自己编程水平不好......

[!tip] match 条件分支语句

match 语句在判断变量与大量固定值是否相等时,比 if 分支更简洁,不过由于 if 可以实现同样的功能,所以用的较少。

比如下面的示例判断三月份有几天。

var 月份 = 3
match 月份:
    2:
        print("非闰年该月份有28天")
    4,6,9,11:
        print("该月份有30天")
    _:
        print("该月份有31天")

更多用法可以参阅官方文档相应介绍,大家可以先认识一下这个语句,但不一定能用到。

[!tip] 三元运算符(又称三目运算符)

比如下面的例子,判断玩家在 x 轴的正半轴还是负半轴。

var 玩家的x轴位置 = 100
var 玩家方向 = 1 if 生命值 >=0 else -1

夹在 ifelse 中间的为条件语句 生命值 >= 0,如果条件语句为真,返回 if 前的值,否则返回 else 后的值。

作用域

我们来看一段代码:

var a = 10
if a > 0:
    var b = 6
print(b)

看起来没有什么问题,应该会显示一个数字 6 吧?

运行一下试试,不出意外的话就出现意外了,Godot 说 print(b) 是错误的,因为变量 b 不存在。

这也很好理解,比如我们把变量 a 的初始值改成 -100,那个 if 就不会执行,自然就没有执行到 var b = 6 这一行,此时确实没有变量 b。

现在来了解下变量作用域,也就是变量的生效范围。

规则只有一条:内层代码块可以访问外层代码块的变量,但反之不行。实际上,当内层代码执行完毕后,内层的变量会被删除。

上例中,var b = 6 这一句话就是在 if 的内层作用域当中的,而在 if 之外的 print(b) 在位外层作用域,就不能访问内层的变量。

现在修改代码:

var a = 10
if a > 0:
    var b = 6
    print(b)

整活:狐狸彩票

这节整活来实现一个买彩票的小游戏,简单概括如下:

  • 刚开始你有 1000 块钱
  • 彩票售价 100 块
  • 彩票下注需要两个数字,这两个数字都是个位数,不能是 0
  • 猜中一个数奖金 300,猜中两个奖金 3000

玩起来大概这样:

狐狸彩票

场景创建

这个界面需要这么几个东西:

  • 标题
  • 两个数字输入框
  • 一个购买按钮
  • 一个显示结果的 label
  • 一个显示钱包的 label

我创建的节点长这样:

node

逻辑分析

我们现在已经知道了游戏的逻辑,但是该如何将其转换成代码呢?

首先我们需要一个变量来保存钱包里的钱,并在按下购买按钮时对其进行判断,如果金钱足够则扣钱并进行买彩票的逻辑,如果金钱不足则显示钱不够。

所以我们可以得到这样一段伪代码:

当按下按钮时:
    if 钱包的钱足够买一张彩票:
        扣钱
        获取输入的两个数字
        随机抽取两个数
        判断随机数和输入的数字是否相等并以此产生奖金
        显示获奖结果
    else:
        显示“你的钱不够了!”

我的脚本编写参考

这里是我编写的代码,可以作为参考,你也可以尝试自己翻译一下上面的伪代码。

脚本放置在最外层的 Panel 节点上,然后绑定按钮的 pressed 信号。

界面布局和组件设置一类的操作我就不再讲解了,大家随意点击自己琢磨即可,没有什么特别难的地方。

代码:

extends Panel

var 钱包 := 1000

func _on_button_pressed():
    if 钱包 >= 100: # 看看有钱吗?
        # 先交钱
        钱包 -= 100
        $Label3.text = "钱包:" + str(钱包)

        # 获取下注的两个数字
        var 下注数a: int = $SpinBox1.value
        var 下注数b: int = $SpinBox2.value

        # 随机产生两个数字
        var 随机数a: int = randi_range(1, 9)
        var 随机数b: int = randi_range(1, 9)

        # 在消息 label 上显示随机产生的两个数,最后的 "\n" 表示换行。
        $Label2.text = str(随机数a) + ", " + str(随机数b) + "\n"

        # 判断两个数字是否赌对了
        var a赌对了: bool = 下注数a == 随机数a
        var b赌对了: bool = 下注数b == 随机数b

        # 根据两个数字的猜测结果加钱
        if a赌对了 and b赌对了:
            $Label2.text += "全猜对了!奖金 3000"
            钱包 += 3000
            $Label3.text = "钱包:" + str(钱包)
        elif a赌对了 or b赌对了:
            $Label2.text += "猜对一个,奖金 300"
            钱包 += 300
            $Label3.text = "钱包:" + str(钱包)
        else:
            $Label2.text += "你一分钱也没赚到"
    else: # 穷了
        $Label2.text = "你已经没钱下注了"

这里唯一一个陌生的东西就是 randi_range 了,它会根据括号里填入的数字生成一个随机整数,包含这两个数以及两数之间的数。

注意不要把钱包变量的定义放在方法里,否则每次点按钮都会创建一个新的钱包变量,逻辑就错了。

循环

某些代码可能会反复执行多次,比如咱们要显示 100 个 hello,利用循环语句即可只写一个 print。

循环语句语法格式:

while <条件表达式>:
    <代码块>

这和基本的 if 语句是一样的,只不过换成了 while 关键字,执行逻辑为:如果条件为真,则执行一次代码块,然后再判断条件,如果条件还为真,则再执行代码块,然后反复判断 + 执行,直到条件为假。

那么咱的 100 和 hello 就可以这样写:

var 计数 = 0
while 计数 < 100:
    print("hello")
    计数 += 1

可以尝试思考一下,执行完这段代码后,计数变量的值是多少?

死循环

写循环时最容易出现的错误就是死循环,死循环并不是说循环死了,而是循环一直活着,例如:

var 计数 = 0
while 计数 < 100:
    print("hello")

我删掉了 计数 += 1 这一句代码,此时的 计数 变量将一直为 0,while 的条件也就永远为 true,所以这个循环会反复执行停不下来,这种代码会导致游戏卡死,甚至需要任务管理器才能退出,不过好在开发阶段可以点击 Godot 右上角的停止按钮来结束游戏。

[!note]

游戏画面的更新和执行代码并不是同时进行的,他们的执行顺序可以简单理解为交叉进行,例如:

代码 -> 更新画面 -> 代码 -> 更新画面 -> 代码 -> 更新画面 -> ...

由于代码执行的很快,所以玩家眼中游戏画面是连续的,但如果现在有一个死循环或很长很长的循环,画面自然就会卡住。

数组

现在假设我们要给玩家设计个背包,可以装三个物品:

# 只是举个例子,暂时就存字符串了。
var 背包格子1 := "水瓶"
var 背包格子2 := "钥匙"
var 背包格子3 := "金币"
print("背包中有:")
print(背包格子1)
print(背包格子2)
print(背包格子3)

这样我们就定义了三个变量来表示三个背包格子,但如果背包升级了呢?现在变成了 10 个格子,总不能定义 10 个变量吧。

数组定义

于是我们来认识一个新的数据类型:Array,中文名数组(也有人叫集合)。

数组就是一堆数据构成的组,在 GDScript 中使用一对方括号表示数组,在方括号中填入要保存的数据,数据之间用逗号分隔,例如使用数组制作背包:

var 背包: Array = ["水瓶", "钥匙", "金币"]

数组元素引用

这样咱就把很多物品放到了一个变量里,在想要访问背包中的物品时,使用 数组变量名[下标] 来访问:

print("背包中有:")
print(背包[0]) # 显示:水瓶
print(背包[1]) # 显示:钥匙
print(背包[2]) # 显示:金币
print(背包) # 显示:["水瓶", "钥匙", "金币"]

方括号里的数字其实就是序号,这里的 [0] 表示背包中的第一个东西,也就是 水瓶

这种语法也可以用来给数组中的元素赋值,例如:

背包[0] = "空" # 把水喝了

[!tip] 下标从 0 开始数

程序员笑话:你的右手有几根手指头?0.1.2.3.4,四根!

添加数据

使用 <数组变量>.append(<值>) 的形式可以向数组中添加新的值,例如:

背包.append("苹果")
背包.append("一本书")

获取长度

使用 len(<数组变量>) 获取一个数组的长度,例如:

var 背包 := ["水瓶", "钥匙", "金币"]
print(len(背包)) # 显示一个数字 3

注意了,长度可不要从 0 开始数。

删除数据

使用 <数组变量>.remove_at(<下标>) 删除指定位置的元素,例如:

var 背包 := ["水瓶", "钥匙", "金币"]
背包.remove_at(1)
print(背包) # 显示:["水瓶", "金币"]

元素的类型

一个数组中可以存在不同类型的数据,例如:

var 一个数组 := [1, "你好", false]

甚至数组内在再含一个数组:

var 又数组 := [[1, 2, 3], [1, 2, 3]]

遍历

我们现在有了背包,如果要每行显示一个背包物品,我们可以:

var 背包 = ["水瓶", "钥匙", "金币"]
print("背包中有:")
print(背包[0]) # 显示:水瓶
print(背包[1]) # 显示:钥匙
print(背包[2]) # 显示:金币

很明显这样做只能显示背包的前三个物品,假设背包很大就不能这样写了。

此时结合之前的 while 语句和 len() 获取数组长度,可以改成下面这样:

var 背包 = ["水瓶", "钥匙", "金币"]
print("背包中有:")
var 下标 = 0
while 下标 < len(背包):
    print(背包[下标])
    下标 += 1

我们声明了一个变量下标,并在循环中通过这个下标从背包中取物品,同时给下标加一,这样就可以访问背包中的每个东西,直到 下标 < len(背包)false,也就是下标达到背包大小时停止。

[!tip]

注意不要写成 下标 <= len(背包),这里不能等于,因为背包的长度是 3,而 背包[3] 是在获取第四个值,程序会出错。

for

上面这种把数组中每个元素都访问一次的行为被称为遍历,这种操作非常常见,于是有了一种专门为遍历而生的语法:

for <元素变量名> in <遍历目标>:
    <代码块>

这个东西本质上还是个循环,循环次数就是遍历目标的长度,每一轮循环中,都将从遍历目标里取出一个元素放到元素变量中。

把之前的 while 换成 for,运行结果不变,代码如下:

var 背包 = ["水瓶", "钥匙", "金币"]
print("背包中有:")
for 物品 in 背包:
    print(物品)

for 后面的 物品 变量是自动创建的,不需要我们使用 var 声明。

range

有时候我们想直接指定循环次数,例如显示 50 个 hello,这样直接用一个变量和 while 也可以搞定,但我们可以结合 range 方法和 for 来实现同样的效果:

for 当前次数 in range(50):
    print("Hello")

range 这个方法会根据括号里的数字产生一个数组,里面分别是 0、1、2、3、4...直到括号里的数字,但不包括那个数,也就是最后一个数字是 49。

不过 GDScript 还给咱们提供了一种简写方式,直接把 range(123) 写成 123 即可,例如和上面的 range(50) 效果相同的 for 可以写成 for 当前次数 in 50:

循环控制

某些时候我们可能想要提前结束循环,比如实现功能:显示玩家背包中金币前面的内容。

var 背包 = ["水瓶", "钥匙", "金币", "帽子"]

var 发现金币 = false
for 物品 in 背包:
    if 物品 == "金币":
        发现金币 = true
    if not 发现金币:
        print(物品)

这段代码可能略微复杂,大家可以先试着理解一下,就当复习一下前面章节了。

当然这么麻烦的代码实际开发中是不会写的,因为 GDScript 中有两个循环控制关键字。

break

首先认识第一个:break

它的功能就是当执行到的时候跳出循环,不再进行后续操作。

这时就可以修改上面的代码:

for 物品 in 背包:
    if 物品 == "金币":
        break
    print(物品)

当发现遍历到的物品是金币时,就跳出这个 for,也就不再显示金币和后面的内容了。

continue

第二个:continue

它的功能是执行到的时候提前进入下一次循环。

比如现在咱们想要显示所有的个位奇数:

for i in range(10):
    if i % 2 == 0: # 是否时偶数
        continue
    print(i)

这个逻辑其实不用 continue 就可以,大家可以自己研究。

认识面向对象

面向对象(简称OOP),最流行的编程方式。

对于游戏开发而言,面向对象编程方式是最符合人类逻辑的,甚至给你一种编程也是玩游戏的感觉。

这一章内容不多,但都是理论概念上的东西,没有啥整活章节,可能略枯燥,不过下一章整活丰富,希望各位坚持看完本章。

方法

我们之前简单了解过方法,知道使用 func 关键字表示一个方法。

方法本质上就是一堆代码的集合,这些代码共同构成某些功能,例如 Godot 内置的 print 方法就是输出文字,str 方法就是把某些东西转换成字符串。

如果我们想要创造一个自己的方法,则可以使用这种语法:

func <方法名称>([参数列表]):
    <方法体>

参数列表我们下节再讲,现在,如果我们想要从两个输入框里获取文字,将它们拼接起来并显示:

func 输出拼接结果():
    var 文字1 = $LineEdit1.text
    var 文字2 = $LineEdit2.text
    print(文字1 + 文字2)

然后在需要使用这段代码的地方使用调用语句:

输出拼接结果()

把某段常用的代码保存成方法是一个良好的习惯,这样做可以减少代码数量,提高代码复用率,这算是方法最根本的用法。

[!tip]

虽然目前没在圆括号里写任何东西,但这个括号还是不能省略的。

完整的例子如下:

extends Node3D

func _ready():
    输出拼接结果()
    
func 输出拼接结果():
    var 文字1 = $LineEdit1.text
    var 文字2 = $LineEdit2.text
    print(文字1 + 文字2)

注意不要把方法写错了位置,它不应该嵌套在另一个方法中。(方法内定义方法会在以后讲解)

方法-参数

参数可以用来控制方法,例如我们之前接触的 print("Hello"),这其中的 "Hello" 就是一个参数,他控制了 print 方法要显示什么内容,再例如 int(n) 其中的 n 就控制把谁转换成整数。

现在回看我们上一节写下的方法,他只能固定的显示输入框1和输入框2的内容,如果想让他显示任意两个输入框的内容,就可以加入参数。

参数可以写很多个,每个参数之间使用逗号分隔,修改后的方法定义如下:

func 输出拼接结果(左边的输入框, 右边的输入框):
    var 文字1 = 左边的输入框.text
    var 文字2 = 右边的输入框.text
    print(文字1 + 文字2)

参数本质上就是变量,这些参数变量会在被调用时赋值,在调用的地方这样写:

输出拼接结果($LineEdit1, $LineEdit2)

就表示将 $LineEdit1 的值传递给了 左边的输入框 这个参数变量,另一个参数同理。

[!tip]

$xxx 这种语法会得到一个 Node 类型的值,表示场景中的一个节点。

[!note]

参数变量也支持强类型语法,例如:

func 输出拼接结果(左边的输入框: LineEdit, 右边的输入框: LineEdit):

方法-返回值

某些方法调用语句可以作为数值使用,例如 var a = int(1.2),我们知道右侧的 int(1.2) 会变成数字 1,那么这个 1 就被称为 int(1.2) 的返回值。

返回值其实就是一个方法的计算结果,在方法中使用 return 关键字表示返回值。

例如我们不再希望输出拼接结果方法直接显示输入框拼接的结果,而是将结果保存到某个变量中,则可以修改成这样:

func 输出拼接结果(左边的输入框, 右边的输入框):
    var 文字1 = 左边的输入框.text
    var 文字2 = 右边的输入框.text
    return 文字1 + 文字2

调用的地方写成:

var 拼接结果 = 输出拼接结果($LineEdit1, $LineEdit2)

[!note]

返回值也支持强类型语法,在参数列表的括号后面使用 -> 来表示返回值的类型:

func 输出拼接结果(左边的输入框, 右边的输入框) -> String:

return 结束方法的执行

因为 return 关键字表示方法的计算结果,当结果产生时方法就没必要继续执行了,所以 return 关键字还会停止方法的执行,就类似循环中的 break 语句:

func 输出拼接结果(左边的输入框, 右边的输入框):
    var 文字1 = 左边的输入框.text
    var 文字2 = 右边的输入框.text
    return 文字1 + 文字2
    print(文字1 + 文字2) # 这句 print 是永远不会执行的。

其实这个方法应该改名了,叫做“获取拼接结果”更合适。

面向对象

对象,其实就是平时我们口中的“东西”,每一个“东西”,在程序中都可以抽象成对象,例如游戏中的一个箱子、敌人、门,甚至是 UI 上的一个按钮、一张图片都可以理解成对象。

对象拥有两种最基础的东西:

  1. 属性
  2. 方法

属性是用来描述对象的,例如箱子的大小、敌人的血量,说白了就是变量,只不过这个变量是属于一个对象的。

方法则是对象可以进行的动作,例如打开箱子、敌人受伤、开门操作,说白了就是一段代码,也就是用 func 定义的方法。

Godot 中的对象还可以有信号,这个以后再讲。

面向对象中有两个词会经常提到:实例

类,可以理解成对象的模板,同时也是一种 GDScript 中的语法,定义一个类就等于定义了一种对象,但注意,是定义了一对象而不是一个对象。

实例,指的是根据一个类创建出来的对象,是一个切实存在的东西。

例如,某个游戏有一种敌人,它拥有生命值、攻击力、等级这三个属性,刚刚这段描述这个敌人的话就等于是创建了这个敌人的类。现在玩家开始了游戏,面前生成了三只这种敌人,那么这三只敌人就被称为敌人实例。

再例如我们三次元生活中,比如手机,我只说手机这两个字,那么这就是类,因为手机是一东西而不是切实存在的特定物体,但如果我说你的手机,这就是一个切实存在的特定物体,那么你的手机就是一个实例,并且是手机类的实例。

回到 Godot,如果你理解了上面的内容,那么你就知道 $LineEdit 所获取的是一个输入框实例,因为所获取到的这个输入框是场景中切实存在的。

[!tip] 不要弄混节点名和类型

$ 符号后面填写的是节点的名字,这个名字可以在引擎界面左上角的节点列表中修改,如果咱把节点的名字修改成了汉字: 一个输入框,那么代码就需要改成 $一个输入框 才行。

容易弄混的是,Godot 中输入框类的名字也是 LineEdit,记住 $ 符号后面是节点名而不是类名即可。

讲个笑话

面试官问眼前的小伙:

你来介绍一下什么是类。

小伙思考了一下,答道:

本人工作吃苦耐劳,不懂什么是累。

类成员

阅读过程中请斟酌实例这两个词,理解它们的区别。

属性

属性定义于类,独立存在于每个实例中,是用来描述实例的,例如 LineEdit 的 text 属性就是描述这个输入框所输入的内容的。

$LineEdit.text

其中的 $LineEdit 就能获取到输入框实例,后面的点符号 . 可以理解成汉字 ,就是从实例中取某个成员。

再读一遍这句话:属性定义于类,独立存在于每个实例中。

属性定义于类,这是说属性是由类指定的,一个类的所有实例都有同样的属性,例如每个 LineEdit 实例都有 text 属性,而 TextureRect 类的实例则没有 text 属性。

属性独立存在于每个实例中,这是说每个实例的同名属性的值是不同的,比如现在有两个输入框,也就是两个 LineEdit 实例,我们可以在这两个输入框中输入不同的内容,也是说他们俩的 text 属性的值是不同的。

[!note] 三次元例子

咱已经知道手机是一个类,那么手机的颜色、电量、所有者这些就是手机的属性。

方法

方法表示实例能进行的动作。例如 LineEdit 有一个没参数的 clear 方法可以清空输入框的内容:

$LineEdit.clear()

或者想要删除某个节点,可以调用它的 free 方法:

$LineEdit.free()

[!note] 三次元例子

还是说手机,手机能够开机、打电话、玩游戏,这些手机能干的事情,就是手机的方法。

对象的属性还是对象

如果我们想让输入框的位置产生变化,那我们可以访问它的 position 属性,这个属性表示的是坐标,但坐标肯定不是一个数字,所以 position 的类型是 vector2,这个 vector2 也是一个类,而 position 属性存放的则是一个 vector2 实例,他有两个属性:xy,如果我们想让输入框向右下方移动一点,则可以:

$LineEdit.position.x += 100
$LineEdit.position.y += 100

[!tip]

向下移动是增加 y 值,因为 Godot 中的 2D 坐标系正方向是向下的,且原点在屏幕左上角。

[!note] 三次元例子

手机的所有者,这是个什么类型的数据?

在完整的面向对象程序中,应该还有一个人员类,那么很明显,手机所有者这个属性的类型就是人员类,而你的手机的所有者属性所存放的值,就是你这个人员类实例。

引用类型与 null

引用类型对应的是值类型,这是两种数据的存储方式。

值类型

我们来看这段代码:

var a = 10
var b = a
a += 10
print(a)
print(b)

这段代码会输出两个数字,依次是 2010,下面是解释:

  • 首先,我们声明了个变量 a,里面存放了 10,然后定义变量 b,里面存放 a 的值,所以 b 中也存放了一个 10。

  • 接下来我们对 a 自增 10,使得 a 变量的值变成了 20,此时我们的游戏中就有了两个数字,分别是 20 和 10。

  • 也就是说 var b = a 这一行代码,会将 a 的值复制一份存到变量 b 中,实际上两个变量的值互不影响。

这种类型的数据,就被称为值类型数据。

我们学过的数字字符串布尔值都是这种值类型数据,而数组是引用类型数据。

引用类型数据

再来看一段类似的代码:

var a = [1, 2]
var b = a
a.append(10)
print(a)
print(b)

这段程序会输出两个 [1, 2, 10]

这是因为数组是引用类型的数据,当执行 var a = [1, 2] 这一行时,变量 a 实际存放的并不是这个数组,而是这个数组的引用

接下来 var b = a 这一行,我们将 a 赋值给 b,实际上就是将这个数组的引用赋值给了变量 b,所以,变量 a 和 b 存放的是同一个数组的引用

所以在后续代码中,不论是调用 a.append 还是 b.append,实际上都是对同一个数组进行操作,因此在最后 print 时两个变量输出的其实是同一个数组。

这种引用类型的数据,除了数组外还有很多,例如绝大多数类的实例都是引用类型,自然也就包括各种节点。

所以我们使用 var 输入框1 = $LineEdit,然后 var 输入框2 = 输入框1,实际上这两个输入框变量指向的都是同一个输入框。

null

null 表示没有数据,我们可以写一句 var a = null 来表示变量 a 中什么都不存放。

某些情况下我们会意外的得到一些 null,例如使用 $ 获取一个不存在的节点时,它就会返回 null。

记住,null 并不是什么好东西,比如我们执行下面的代码:

var 输入框 = $输入框节点名
print(输入框.text)

如果场景中并没有叫做 输入框节点名 的输入框,输入框 变量就会存放一个 null,此时下一行就出问题了,因为 null 并没有 text 属性,于是我们就得到了一个错误。

所以,当我们不能断定某些操作是否会得到 null 时,一定要使用 if 语句判断一下:

var 输入框 = $输入框节点名
if 输入框 == null:
    print("没获取到输入框")
else:
    print(输入框.text)

这里的 if 输入框 == null 还可以写成 if 输入框 is null

日后再深入

理解面向对象需要一定过程,这一章只是带领各位初步认识一下面向对象的概念,各位需要在学习下一章的过程中逐步理解本章内容。

Godot 的很多东西如果不会面向对象还不好学,但学面向对象又需要很多编程经验,所以本书尝试将这两部分穿插来讲。

引擎交互

学 GDScript 的最终目的还是 Godot。

学习建议

某些章节可能会让你感觉云里雾里,这是因为某些内容是互相关联的,可能需要结合后几节的知识才能理解。

所以建议不要因为一小点看不懂就放弃。

生命周期 - 单次执行周期

节点的生命周期是指节点从出生(创建)到死亡(删除)的过程,这个过程中有几个关键的时间点,我们可以在这些时间点编写我们的逻辑代码,承载这些代码的方法就被称为生命周期方法。

我们之前见过 _ready_process 方法,它俩就是两个最常见的生命周期方法。

[!tip]

生命周期方法不需要我们手动调用,Godot 会在内部自动调用它们。

_enter_tree

这个生命周期方法会在节点进入到场景树时执行,也就是节点出现时执行。

注意不要和下面的 _ready 搞混,在执行 _enter_tree 生命周期方法时可能还没有子节点,因为子节点还没有加入到场景树中。

_ready

当节点完全准备好时执行。

完全准备好是指子节点都执行完毕 _ready_enter_tree 方法,并且当前节点执行完 _enter_tree 方法。

区分 _enter_tree 和 _ready

假设现在有一个这样的场景:

Control
    LineEdit
    TextureRect

其中 Control 节点有两个子节点,分别是 LineEdit 和 TextureRect,并给他们三个都加上相同的脚本:

extends Node

func _enter_tree():
    # name 是指节点的名字
    print (name + " enter tree")

func _ready():
    print (name + " ready")

运行场景后,我们会看到这样的输出结果:

Control enter tree
LineEdit enter tree
TextureRect enter tree
LineEdit ready
TextureRect ready
Control ready

可见 _enter_tree 会最先执行,且父节点优先执行,而 _ready 最后执行,且父节点排在最后。

_exit_tree 节点离开场景树

顾名思义,当节点离开场景树时执行,且子节点优先执行。

生命周期 - 循环执行周期

上一节认识的生命周期每个节点只会触发一次,下面这两个周期方法会在节点存在时反复执行。

_process

我们都知道游戏画面是一帧一帧显示的,那这个 _process 就是每个画面帧时执行了。

这个方法还需要有个参数,Godot 给的默认参数名是 delta,它表示当前帧和上一帧之间间隔的时长,单位是秒。

_physics_process

类似画面帧,游戏中进行物理效果模拟时也是一帧一帧进行的,不过这个帧不等于画面帧,Godot 默认是每秒 60 物理帧,同样他也有个 delta 参数,表示上一个物理帧与当前物理帧之间的间隔时长。

画面帧和物理帧

画面帧和物理帧就是指 _process_physics_process

当我们处理一些画面显示相关的逻辑,例如按钮动画、视角移动等,建议使用 _process,这能保证每次画面刷新时都能看到流畅的画面变化。

如果要处理一些物理相关的逻辑,例如玩家移动、开门关门等,一定要使用 _physics_process,因为物理碰撞、摩擦等运算都是在物理帧进行的,如果某个物体在画面帧中移动,可能会导致物理帧中处理不到这次移动信息,从而影响物理模拟的真实性。

delta

两个 process 生命周期都有个 delta 参数,使用这个方法可以平衡不同帧率对游戏的影响。

例如现在我有一个配置极高的电脑,可以保证 _physics_process 方法每秒执行 60 次,而你的电脑比较垃圾,只能保证 _physics_process 方法每秒执行 30 次。

然后这个游戏里有一段这样的代码:

func _physics_process(delta):
    position.x += 1

很明显,我会每秒移动 60 单位,因为我的电脑在一秒钟内执行了 60 次 _physics_process,而你一秒钟只能移动 30 次。

这时结合 delta 参数,让上面的代码变成:

func _physics_process(delta):
    position.x += 60 * delta

这时,由于 delta 有“电脑越差数值越高”的特点,修改后的代码可以保证咱俩每秒都能移动 60 的单位。

[!note] 常用生命周期

最常用的生命周期就这三个: _ready _process _physics_process

熟练掌握这三个生命周期即可实现绝大多数效果。

获取输入

Godot 给我们预先写好了很多类,这其中有一个 Input 类专门用于获取玩家的输入信息。

先来介绍一个最简单的方法,is_key_pressed,它可以判断一个按键是否被按住,例如实现一个按 w 键向上移动:

func _physics_process(delta):
    if Input.is_key_pressed(KEY_W):
        position.y -= 1000 * delta

因为涉及到移动,所以我们把这段代码放到了 _physics_process 生命周期方法中。

根据 is_key_pressed 这个名字可以看出来,他是判断按键是否被按下的,也就是说这个方法的返回值是布尔类型,当按键被按下时返回 true,没按下时返回 false。它的参数是一个 KeyCode 类型的枚举,什么是枚举可以先不用考虑,总之这个参数应该是你想检测的按键,而 Godot 定义了一堆 KEY_??? 这样的变量来表示每一个按键,上面代码中使用的 KEY_W 就表示了键盘上的 W 键。

is_key_pressed 结合 if 语句,就实现了当玩家按下某个键时执行一段代码这样的逻辑,这里当按下 W 时就会执行向上移动,也就是 position.y -= 1000 * delta

关于 position 和 delta 分别在 生命周期2类成员 小节讲过。

输入映射

大部分游戏都支持多种输入方式,例如一般的主机游戏都支持键鼠和手柄,虽然我们可以使用 or 运算同时判断多个按键输入,但这必定会很麻烦。

现在的绝大多数游戏都会有一个这样的界面:

cs的键位设置界面

在这种地方,游戏定义了例如移动、跳跃等动作。在代码中直接判断玩家是否进行了某种动作即可,Godot 也为我们提供了一个这样的东西。

打开引擎主界面菜单中的项目 -> 项目设置 -> 输入映射 选项卡,即可看到类似上图的界面,我们可以在这里添加咱们的按键映射,例如:

输入映射

在代码中,我们可以使用 Input.is_action_pressed("动作名称") 来获取某个动作对应的按键是否被按下,例如我们要检测上图中的 Fire 动作:

if Input.is_action_pressed("Fire"):
    print("按下了 Fire 键!")

线性输入

游戏手柄上有一些可以“输入一半”的键,比如摇杆和扳机,这时候就可以使用 Input.get_action_strength("动作名称") 来获取一个小数数值,范围是 0 ~ 1,表示按键移动的强度。

例如根据玩家向左移动摇杆的幅度控制移动速度:

var left = Input.get_action_strength("动作名称")
if left > 0:
    position.x -= left * delta * 1000

成对输入

有时候我们会需要成对的输入,例如操控船只的加速和减速,我们可以使用 Input.get_axis("反方向动作","正方向动作") 来获取一个 -1 ~ 1 的值。

或者有些两个轴的输入,例如玩家的上下左右移动,可以使用 Input.get_vector("-x动作","+x动作","-y动作","+y动作") 来获取到一个 Vector2 类型的值,其中的 x 和 y 的范围是 -1 ~ 1。

这两个方法没什么难点,这里就不放示例了,各位亲自体验尝试以下吧。

[!tip]

这些返回小数的输入获取方法,对于键盘或非线性按键的操作会返回 -1、0、1 这种整数值。

鼠标输入

鼠标的按键输入可以直接使用输入映射功能。

如果要获取鼠标的位置,则可以使用 get_global_mouse_position() 获取鼠标在 2D 世界中的坐标。

如果要获取鼠标的移动速度,则需要使用 _input 生命周期方法:

func _input(event):
    if is_instance_of(event,InputEventMouseMotion):
        print(event.velocity)

这样就会输出鼠标的移动速度。

这段代码涉及到一些没学过的东西,暂时先不解释了,暂时只要知道里面的 if 中的代码会在鼠标移动时执行即可。

整活:玩家移动

终于来到了一个整活章节,本章的整活章节将共同制作一个小游戏,玩起来大概这样:

引擎交互章节整活游戏预览

本节咱们运用刚刚学习过的生命周期方法和获取输入来实现玩家的移动。

玩家节点创建

我们的主角需要具有上下左右移动的能力,并且我们不希望他能穿透障碍,因此我们选择 RigidBody2D 作为玩家的根节点,子节点则是玩家的碰撞体和显示玩家图片用的精灵:

玩家节点

此时需要注意以下几点:

  • Godot 中的 0 度表示向右,因此这里的玩家默认向右看。

  • 咱的游戏是俯视视角的,而 Godot 的 RigidBody2D 节点自带一个向下的重力,因此需要给 RigidBody2D 的 GravityScale 属性设置为 0 以取消重力。

  • CollisionShape2D 节点需要指定一个碰撞形状,也就是 Shape 属性,这里使用的是 CircleShape2D 形状,如果你还不太了解这些节点的使用,可以自己试着多点一点,界面操作肯定是比学习代码简单的。

代码编写

移动玩家的代码肯定是要写在玩家最外层的节点了,具体的代码如下:

extends RigidBody2D

var 移动速度: int = 200

func _physics_process(delta):
    var 移动输入 := Input.get_vector("左", "右", "上", "下")
    if 移动输入.length() > 0:
        move_and_collide(移动输入 * delta * 移动速度)

func _process(delta):
    var 鼠标方向 = get_global_mouse_position() - global_position
    rotation = 鼠标方向.angle()

其中 _physics_process 方法中先获取了玩家移动的按键输入,并保存到一个二维向量中,若该向量的长度为 0,则表示玩家没有输入,所以当输入不为 0 时调用 move_and_collide 方法进行移动。

_process 方法中使用 get_global_mouse_position 方法获取鼠标的坐标,并减去当前玩家的全局坐标,这样就得到了一个从玩家指向鼠标的方向向量,并存放到 鼠标方向 变量中。随后将这个方向的角度赋值给玩家的旋转角度(rotation 变量)。

[!tip]

移动行为涉及到障碍物碰撞等物理效果,所以移动逻辑被放在了 _physics_process 方法中,而在本游戏中的玩家旋转行为不涉及物理效果,所以放在了 _process 方法中,当你在制作自己的游戏时需要根据游戏需求选择不同的生命周期方法。

[!tip]

思考一个小问题,现在的玩家垂直向下的移动速度和向右下方斜向移动的速度相同吗?尝试使用 print(移动输入) 看一看吧。

PackedScene实例化

某些物品会在游戏中反复出现,以玩家发射的炮弹举例,炮弹可以被反复创建,且每颗炮弹都几乎相同,那么我们就可以制作一个炮弹模板,在玩家开火时创建它。

这个 PackedScene 就是模板,这俩单词翻译成中文是 打包的场景,也就是说模板的本质就是场景,也就是任意一个节点(及其子节点)。

如果学过 Unity,其实这个 PackedScene 就等于 Unity 的预制体。

创建 PackedScene

方法有三:

  1. 点击菜单栏[场景] -> [新建场景]后,开始制作你的模板,并保存当前场景。

  2. 在任意场景对着节点列表中的某个节点右键,点击 [将分支保存为场景]。

  3. 在节点列表中拖拽节点到下面的文件列表中。

Godot 中的一个场景就是个 PackedScene

生成 PackedScene

想要把一个 PackedScene 使用代码创建出来,就需要先在代码中获取到 PackedScene 这个文件。

使用 load("文件路径") 来读取一个 Godot 资源,这里的文件路径使用 res:// 开头表示项目中的资源。

假如咱们把某个 PackedScene 保存到了项目中的 物体 文件夹,例如这样:

PackedScene

在代码中我们使用 load("res://物体/某个packed_scene.tscn") 即可获取到这个 PackedScene。

当我们想要根据这个 PackedScene 创建新物体时,可以调用它的 instantiate 方法,这个方法会返回创建好的节点。

现在我们来看下完整流程:

var 保存好的场景 = load("res://物体/某个packed_scene.tscn")
var 新物体 = 保存好的场景.instantiate()
get_parent().add_child(新物体)

最后一句的 get_parent() 会获取当前节点的父节点,然后我们又调用了父节点的 add_child 方法,add_child 方法会给节点添加新的子节点,所以最后这一整行就是把新物体变成了与代码所在节点自身同级的节点。

[!tip]

instantiate() 只是把节点创建来出来,但还没有添加到场景中,所以是看不到的。

配合 add_child 才能真正创建一个新物体。

[!tip]

PackedScene 中的节点不能与外部的节点存在信号连接,比如 PackedScene 中某个按钮的信号不能连接到 PackedScene 外面的脚本上,毕竟在 Godot 眼中,她不知道这个 PackedScene 被创建出来的时候外面的脚本是否存在。

但是 PackedScene 内部的信号连接是没有问题的。

节点操作

Godot 使用节点作为组成游戏物体的基本单位,因此操作节点就等于操作游戏中的物体。

注意哈,本节是讲操作节点,而不是操作节点上的属性

我们上一节见到了 get_parentadd_child 方法,这一节我们多认识几个操作节点的方法。

获取节点

我们之前见到过 $xxx 这种写法,其实有一个和它功能相同的方法,叫做 get_node,不过 $ 符号用起来更方便,所以基本上很少会用 get_node

$节点名 这种写法大家应该都认识,就是获取子物体中叫做 节点名 的那个节点。

节点名可能包含一些奇怪的符号,直接把名字写在 $ 后面会出现语法错误,比如有个节点叫做 外.币 巴-伯,这时就可以使用字符串来表示节点名,变成 $"外.币 巴-伯" 即可。

准确来说,$ 符号后面填写的并不是节点名,而是节点路径,例如我们可以使用两个点 .. 表示上一级,或者使用 /root/ 开头表示场景根节点,下面来看几个例子:

  • $"../ABC" 获取和当前脚本所在节点同级的 ABC 节点

  • $"../../../" 获取自己的父节点的父节点的父节点

  • $"/root/BFG" 获取场景中最外层的 BFG 节点

添加节点

添加节点其实就是 add_child 方法,调用哪个节点的 add_child 就是给那个节点添加子节点。

例如 $ABC.add_child(新节点) 就是给 ABC 节点添加子节点。

删除节点

删除节点有两个方法:freequeue_free

一般情况下我更建议使用 queue_free 来删除节点,方法名中的 queue 是队列的意思,可以理解成排队,也就是说这是让节点排队删除,而不是立刻删除。

free 则是立刻删除节点,在调用 free 时,Godot 就会立刻删除这个节点。

我们来看个例子:

# 这是举例用的错误代码
free()
print(position)

运行这段代码游戏会报错,因为执行 free 时就会立刻删除这个节点,而下面的代码要输出 position 这个位置属性,可是节点已经被删除了,哪里还有位置呢。

如果我们将 free 换成 queue_free 则可以避免这个报错,Godot 会先将调用 queue_free 的节点记录下来,等咱们的代码执行完毕后,在空闲时间时再将它们删除。

某些生命周期或信号中使用 free 会直接报错,因为 Godot 内部有一种节点锁定机制。

整活:Fire

本节实现玩家开火效果。

首先需要创建一个子弹的 PackedScene,由于玩家的子弹可以与其他物体产生碰撞,因此我们使用 Area2D 作为子弹的根结点:

子弹PackedScene

最后那个 Timer 节点在本节最后讲解

子弹具有"飞行"能力,但飞行的方向是不固定的,需要根据玩家开火时的朝向确定,所以我们在子弹的代码中定义一个 移动速度 属性,当玩家发射子弹时由玩家来对这个属性赋值。

子弹的代码如下:

extends Area2D
class_name 子弹类

var 移动速度:Vector2

func _physics_process(delta):
    position += 移动速度 * delta

对应的,再给之前的玩家移动脚本添加上射击相关的逻辑:

var 子弹: PackedScene

func _ready():
    # 获取子弹的 PackedScene
    子弹 = load("res://子弹/子弹.tscn")

func _process(delta):
    var 鼠标方向 = get_global_mouse_position() - global_position
    rotation = 鼠标方向.angle()
    if Input.is_action_just_pressed("攻击"):
        var 创建的子弹:子弹类 = 子弹.instantiate()
        创建的子弹.移动速度 = 鼠标方向.normalized() * 1000
        $"/root/".add_child(创建的子弹)
        创建的子弹.global_position = global_position
        创建的子弹.rotation = 鼠标方向.angle()

鼠标方向.normalized() 可以把向量归一化,以此来去除鼠标位置对子弹速度的影响。

子弹清理

[!tip]

涉及到信号指示,可以先阅读 信号 章节。

如果你发射了太多子弹,例如100000发,当子弹飞出屏幕后,即便我们再也见不到它,但它仍然在我们的视野之外飞行,这会对电脑产生不小的压力。

因此我们需要删除掉一些"没有用"的子弹,这里我给子弹添加了一个定时器节点(Timer 节点),它将在子弹被创建的 10 秒后触发 timeout 信号:

定时器属性

将 timeout 信号连接到子弹脚本中即可实现定时删除:

func _on_timer_timeout():
    queue_free()

[!tip]

根据时间清理子弹不是唯一的手段,例如还可以根据子弹的位置来清理,或是子弹的移动距离等,你可以试着自己实现下其他的清理方式。

信号

有一种编程方式叫做“事件驱动式编程”,大意就是说当发生某件事的时候就执行一段代码,以此来实现整个程序的功能。其中的事件在 Godot 中被称作信号

一个信号可以连接到很多方法,当信号触发时则会执行这些方法。

你也可以把信号理解成一组方法的集合,并且可以同时调用这组方法。

我们之前接触过按钮节点(Button)的 pressed 方法,这是按钮被按下的信号,不同的节点有不同的信号,例如输入框节点(LineEdit)有 text_changed 信号,会在内容发生变化时触发,且还包含一个参数。

连接信号

在 Godot 引擎界面中可以双击信号来连接到某个脚本上的方法,这种操作没什么难度,这里不再讲解。我们重点看看使用代码连接信号。

我们来尝试实现一个这样的效果:

+3

场景中包含三个节点:

Control
    Button
    Label

我们将脚本写在了 Control 节点上:

extends Control

func _ready():
    $Button.pressed.connect(当点击按钮)

func 当点击按钮():
    $Label.text = str(int($Label.text) + 3)

重点就是 _ready 方法中的 $Button.pressed.connect(当点击按钮),其中的 pressed 属性就是按钮的 pressed 信号,信号对象有 connect 方法,这个方法的参数也是一个方法,表示将信号连接到方法上。

connect 这个单词的中文翻译:连接

断开连接

调用信号的 disconnect 方法就可以断开某个与方法的连接:

$Button.pressed.disconnect(当点击按钮)

[!tip] 代码自动补全没了?

个人感觉目前的 Godot 编辑器有时候有点小问题,我的 Godot 在输入 $Button.pressed. 后不会弹出 pressed 的属性提示,这种情况对于新人来讲属于是个灾难。

这时候,强类型语法就可以登场了,可以用一些拐弯的方法来得到代码提示:

var 点击信号: Signal = $Button.pressed
点击信号.connect(当点击按钮)

这样,在输入 点击信号. 的时候就能看到信号对象的代码提示了,这里使用的类型 Signal 就是信号类型。

Godot 中的组,作用是给节点打标签。

在游戏中,某些东西会被归为一类,例如 Minecraft 中的僵尸和骷髅是“亡灵生物”,蜘蛛和烈焰人是“节肢生物”,在使用高级的武器攻击它们时会产生不同的效果。对于代码来讲,当武器击中怪物时就需要判断敌人的种类,从而造成不同的伤害。

选中一个节点后,在屏幕右边的节点选项卡的分组页面中即可给节点分配组,例如现在创建一个僵尸节点:

添加组

这样,节点就加入了一个组。

组不用手动创建,但如果你想要更好的管理组,可以试试点管理分组按钮。

判断组

来到代码中,我们可以使用节点对象的 is_in_group 方法判断节点是否属于某个组,例如在刚刚的场景中,我们在根节点中加入下面代码:

func _ready():
    print($Zombie.is_in_group("亡灵生物"))

这就会输出一个 true

代码操作组

虽然应该不常用,但如果你想要使用代码操纵节点的组,可以使用 add_to_group 方法把节点添加到一个组中,或使用 remove_from_group 从组中移除节点:

$Zombie.remove_from_group("亡灵生物")
$Zombie.add_to_group("怪物")
print($Zombie.is_in_group("亡灵生物")) # 输出 false
print($Zombie.is_in_group("怪物")) # 输出 true

还有个 get_groups 方法可以获取节点的全部组,考虑到一般用不到,此处不再展示。

整活:靶子

本节来添加靶子,这是一个可以被子弹攻击到的物体,并且会阻碍玩家移动,因此使用 StaticBody2D 作为靶子的根结点:

靶子节点

靶子的唯一用途就是被子弹攻击,因此靶子自身不用编写任何方法。

要做到子弹攻击靶子,我们需要修改子弹脚本,利用子弹根结点 Area2D 的 body_entered 信号,我们可以在子弹碰到某个物理节点(例如 StaticBody2D)时做一些处理。

连接子弹根结点的 body_entered 信号到子弹脚本上,并且写下如下代码:

func _on_body_entered(body:PhysicsBody2D):
    if body.is_in_group("靶子"):
        body.queue_free()
        queue_free()

其中的第一个 if 判断碰到的节点是否在 靶子 组中,所以不要忘了给靶子节点添加到这个组中。if 里面的两行代码就分别是删除靶子和子弹。

[!tip]

我们也可以选择在靶子上编写逻辑,进行"靶子是否碰到子弹"的判断,但一般我们的认知应该是"子弹攻击靶子",所以我习惯把这个判定放到子弹身上。

属性导出

选中一个节点后,我们可以在屏幕右边看到好多节点的属性,其实我们也可以在这里添加自己的属性。

给咱们脚本中的属性变量加上 @export 前缀即可:

@export var 玩家名: String = "没名字吗?"
@export var 钱包: int = 5

func _ready():
    # 注意,成员变量是指脚本最外层的变量,不要定义在方法里面!
    pass

给节点加上上面代码后,即可在属性面板看到效果:

导出的属性

定义变量时指定的初始值就是面板上的默认值,在面板上修改属性值后也就等于修改变量的值。大家可以修改后利用 print 语句试试效果。

[!tip]

Godot 会根据属性变量的类型提供不同的输入框,例如 Color 类型会提供颜色选择器,PackedScene 类型则会让你选择一个保存的场景。

很多时候,属性导出可以代替掉 load 方法。

[!note]

@export var 哈 这样的属性是不能导出的,因为 Godot 不知道这个变量是个什么类型,也就不知道应该在面板上显示什么样的输入框,所以这种属性变量必须使用强类型指定类型或指定上初始值。

整活:分数

我们可以让游戏更有趣一些,让玩家在击中靶子时增加一些分数,且不同的靶子分数不同。

首先实现一点:不同靶子分数不同。现在给靶子添加一个脚本,脚本中仅需要一句代码:

@export var 分数: int = 1

这样,当我们创建多个靶子时(建议用 PackedScene),即可在引擎中任意修改某一个靶子的分数了。

通常在一个完整的游戏中会有单独的部分存储全局信息,但我们的游戏比较简单,我就将分数变量存放到玩家实例上了,也就是给玩家添加一个属性:

var 分数: int = 0

为了显示分数,我们在创建几个节点来组成 ui 界面:

分数UI

把上述 ui 节点添加到场景中后,接着修改子弹击中靶子后的代码:

if body.is_in_group("靶子"):
    # 玩家的现有分数加上靶子的价值分数
    $"/root/Game/玩家".分数 += body.分数
    
    # 更新 ui 界面
    $"/root/Game/UI/分数".text = "得分:" + str($"/root/Game/玩家".分数)

    body.queue_free()
    queue_free()

完成

深入面向对象

别看我们目前学的东西还不多,但其实已经可以实现很多的游戏效果了。

在学习这一章之前,请确保自己已经熟练掌握了前面的章节,并且建议尝试写一些小游戏。

字典

正式进入面向对象之前,我们先来学习一个新的数据类型:字典Dictionary)。

字典使用键值对存储数据,键值对是指一个键和一个值组成的一对数据,例如 我的名字是 Rika 这句话中,我的名字 就是键,Rika 是值。

让我们回顾一下数组,GDScript 的数组由方括号 [] 包裹,其中填入很多元素并用逗号 , 分隔。

字典的语法也类似,不过是使用花括号 {} 包裹起来,其中填入很多键值对,键值对之间也用逗号 , 分隔。至于键值对,则是两个数据之间用冒号 : 分隔。

例如存一个玩家信息:

var 玩家信息: Dictionary = {
    "名字": "Rika",
    "年龄": 22,
    "职业": "赤魔法师",
}

其中的 名字、年龄、职业 就是键,每个冒号后面的就是值。

注意一点,键值对的键和值都是数据,所以 名字、年龄、职业 这些键都是一个字符串,不能是变量那种直接写的名字。

获取元素值的方式也很类似数组,方括号里直接填写键即可,例如显示玩家名字:

print(玩家信息["名字"])

动态键

字典有什么有点呢,我们也可以直接写三个变量分别存储 名字、年龄、职业 对吧?

动态的元素是字典的一大特点,就类似数组,我们可以随时向里面添加或删除数据,而变量则需要提前声明好。

由于字典元素不确定,所以不能随意从中取值,例如在上面的代码中运行 print(玩家信息["啥"]) 就会得到一个错误。

如果不能保证某个键是否存在,可以使用 in 关键字(或者叫运算符)进行判断,in 的左边是键,右边是字典,结合 if 关键字:

if "武器" in 玩家信息:
    print("玩家手持 " + 玩家信息["武器"])
else:
    print("玩家没有武器")

这样的 if xxx in xxx 即可判断字典是否包含某个键。

如果只是简单的获取值,每次都加这个 if xxx in 也太麻烦了,所以 GDScript 为字典对象提供了一个方法,叫做 get

print(玩家信息.get("名字"))
# 等同于
print(玩家信息["名字"])

如果玩家信息中不包含名字键,则 get 方法会返回一个 null,而 ["名字"] 索引则会报错并停止游戏。

同时,get 方法的第二个参数可以指定一个默认值,若键不存在,则返回这个默认值:

print(玩家信息.get("名字", "无名"))

上例代码中,若 玩家信息 中不包含 名字 键,则输出 无名 二字。

增加、删除

若要向字典中添加数据,直接使用元素赋值语句即可:玩家信息["武器"] = "小棍子"。如果字典中存在武器键,则会修改对应的值,若没有武器键则会创建这个键并赋值。

若要删除,则调用 erase 方法并传入一个键,例如 玩家信息.erase("武器") 就会删除武器键值对。

[!note]

字典和数组一样,是引用类型数据!

[!tip]

或许你有概率在其他的资料中看到这样的字典写法:

var 玩家信息 = {
    名字 = "Rika",
    年龄 = 22,
    职业 = "赤魔法师"
}
print(玩家信息.名字)

看起来和咱们学的不太一样,不过本质逻辑还是一样的,这是 GDScript 支持的另一种字典语法,但是由于流行度不如本节重点介绍的那种,因此仅作为了解即可。

脚本与类

回想之前,我们学习过类的基本概念,如果印象不太清晰的话可以回看 认识面向对象 章节。

当时我们说过,写在节点上的脚本就是一个类,我们使用一个让玩家左右移动的脚本作为例子:

extends Node2D

var 移动速度: int = 100

func _physics_process(delta):
    var 移动 := 获取横向移动()
    if 移动 != 0:
        position.x += 移动 * delta * 移动速度

func 获取横向移动() -> int:
    if Input.is_action_pressed("Left"):
        return -1
    if Input.is_action_pressed("Right"):
        return 1
    return 0

上述代码就是一个类,这个类中声明了一个 移动速度 属性和 获取横向速度 方法,并在 _physics_process 方法中实现了左右移动的逻辑。

我们现在把这个脚本放到一个节点上,构成这样的场景:

玩家场景

现在我们来实现一个效果:

变速移动

如上图,我们可以使用一个滑动条来修改玩家速度,按照传统做法,我们可以把滑动条的数值更改信号 value_changed 连接到玩家脚本上的某个方法,并在其中修改玩家的 移动速度 属性。

但在复杂的游戏中,可能有多种因素影响玩家的移动速度,例如 游戏的地形、玩家负重、负面效果 等,如果都使用信号连接那肯定会特别麻烦,因此我们需要尝试一种直接修改玩家移动速度的方法。

现在我复制一段第二章中“变量”小节中的一句话:当变量放在方法外面,表示这个变量属于当前节点。现在我们知道这样的变量就是属性,但注意这个变量属于当前节点这几个字,这就表示,当其他的代码获取到玩家节点时,就可以通过这个节点直接访问这个变量,例如:

$"/root/玩家".移动速度 = 300

这样就可以修改玩家的移动速度了。

[!tip]

讲了半天就是说,节点.属性 这种语法也可以引用到咱们自己定义的属性变量。

现在来实现上图中的效果,我把代码写在了根节点上,滑动条的 value_changed 信号连接的方法如下:

func _on_h_slider_value_changed(value):
    $"玩家".移动速度 = value
    $Label.text = "移动速度:" + str(value)

同理,我们在节点上定义的方法也可以被上述方式访问:

$"/root/玩家".获取横向移动()

命名类

我说咱的脚本就是类,可我们接触过的类都有一个名字对吧,比如 Input 是类名,LineEdit 是类名,那我们的类是什么名字?

很明显,咱的类目前还没有名字,但我们可以通过 class_name 关键字指定一个名字,只需要在方法外面(与属性同级)的地方写上 class_name <类名> 即可,例如:

extends Node2D
class_name 玩家移动控制器

var 移动速度: int = 100

func _physics_process(delta):
    var 移动 := 获取横向移动()
    if 移动 != 0:
        position.x += 移动 * delta * 移动速度

func 获取横向移动() -> int:
    if Input.is_action_pressed("Left"):
        return -1
    if Input.is_action_pressed("Right"):
        return 1
    return 0

拥有类名后就可以与强类型变量使用了,例如 var 玩家: 玩家移动控制器 = $"/root/玩家"

整活:敌人

本章节的整活部分将在上一章的结果上,做出这样一个东西:

展示

在这个游戏中,玩家可以切换两种武器攻击敌人,敌人则会一直追着玩家,敌人接触玩家会让玩家受到伤害。

屏幕左上角可以看到玩家的血量和使用的武器。

敌人节点

本节来实现敌人,敌人作为一个会移动的物体,使用 RigidBody2D 作为根节点:

敌人节点

敌人移动

敌人的移动逻辑很简单,就是一直向玩家方向跑过去。

所以第一步是需要获取到玩家节点:

var 目标:RigidBody2D

func _ready():
    目标 = $"/root/Game/玩家"

接着,只需要在 _physics_process 方法中不断向玩家移动即可:

@export var 移动速度:float = 300

func _physics_process(delta):
    var 移动方向 = (目标.global_position - global_position).normalized()
    move_and_collide(移动方向 * 移动速度 * delta)

敌人受击

敌人会被玩家用子弹攻击,所以我们可以给敌人定义一个血量变量和一个受到攻击的方法:

var 生命值:int = 10

func 受伤(伤害:int):
    生命值 -= 伤害
    if 生命值 <= 0:
        queue_free()

接着修改子弹的代码:

func _on_body_entered(body:PhysicsBody2D):
    # 给敌人节点添加到“敌人”组中。
    if body.is_in_group("敌人"):
        body.受伤(4) # 对敌人造成 4 点伤害
        queue_free()

在场景中放几个敌人,运行游戏试试吧。

封装

已知,我们可以使用 <节点>.<属性变量> 的方式引用其他节点的属性变量并修改,但这样其实很危险,例如我们可能会不小心把玩家速度设置成负数,或将玩家的生命值设置过大导致超出生命上限。

在传统编程语言中,我们通常会给变量添加对应的访问方法,下例中的 设置移动速度 方法就是一个典型:

var 移动速度: int = 100

# 假设玩家最低移动速度是 10
func 设置移动速度(新速度:int):
    if 新速度 < 10:
        新速度 = 10
    移动速度 = 新速度

func 获取移动速度() -> int:
    return 移动速度

随后,我们只要保证每次用到 移动速度 时都通过调用 设置移动速度获取移动速度 方法即可。

[!note]

封装除了可以对属性值进行限制,还可以作为属性的唯一访问途径来监听属性值的变化,例如可以在 设置玩家速度 方法中加一个 print("玩家变速了!" + str(新速度)) 来提醒咱们玩家速度发生变化。

然后问题来了,肯定有一天我们会忘记 移动速度 属性还有两个对应的访问方法,这时候就需要用到 GDScript 为我们提供的 setget 关键字来指定变量的访问方法了:

var 移动速度: int = 100: set = 设置移动速度, get = 获取移动速度

在变量初始值后面加上了一个冒号,然后写上 set = XXX, get = XXX 这样的东西,这就为移动速度属性指定了两个访问方法。

在我们需要使用 移动速度 变量时,依旧按照普通变量的方式使用即可。就是说当执行 $"玩家".移动速度 = 400 这句代码时,就会自动调用 设置移动速度 方法并将 400 作为参数传入其中,相应的,执行 print($"玩家".移动速度) 时,实际输出的就是 获取移动速度 方法的返回值。

其实上例的代码可以简化,因为 获取移动速度 这个访问方法中没有进行任何操作,只是原样返回数值而已,所以可以省略掉这个方法和对应的 get = XXX

可运行的例子:

var 属性:int = 10:set = _设置属性, get = _获取属性

func _设置属性(新的值:int):
    if 新的值 > 0:
        属性 = 新的值
    else:
        属性 = 新的值 * 新的值

func _获取属性() -> int:
    if 属性 == 0:
        print("巧了,属性值竟然是零")
    return 属性

func _run():
    print(属性)
    属性 = 0
    print(属性)
    属性 = -22
    print(属性)

如果现在执行 _run 方法,则会看到如下输出:

10
巧了,属性值竟然是零
0
484

[!tip]

_设置属性_获取属性 方法作为属性的访问方法,咱一般不希望别人随便使用,所以起名字的时候给加上下划线前缀来告诉别人没事别用我。

简写形式

每次定义这种 设置xxx获取xxx 的方法也很麻烦,所以 Godot 给咱提供了一种简便的方式:

var 属性 = 100: 
    set(新的值):
        if 新的值 > 0:
            属性 = 新的值
        else:
            属性 = 新的值 * 新的值
    get:
        if 属性 == 0:
            print("巧了,属性值竟然是零")
        return 属性

这段代码与上面可运行的例子的逻辑相同,关键语法就是把 set = XXX 这种东西改成了一个类似名为 set 方法的结构,get 同理,但要注意这里不需要 func 关键字。

整活:玩家生命值

本节来给玩家加上生命值,当被敌人摸到的时候减血。

玩家代码

首先第一件事是给玩家定义一个生命值属性:

var 生命值:int = 100 : 
    set(value):
        生命值 = value
        $"/root/Game/UI/生命值".value = 生命值

此处对 生命值 进行了封装,当设置生命值的时候也会同时修改游戏的 UI。

生命值 UI

生命值 UI 使用了一个 ProgressBar 节点,这个节点会显示一个进度条,并通过 value 属性控制进度条的进度,默认情况下 value 的取值范围是 0 ~ 100,咱默认给它一个 100 即可。

生命值UI

别忘了玩家的 生命值 属性有个 set 封装,在封装代码中引用了这个 UI 节点,并在修改 生命值 时修改进度条的值。

敌人攻击玩家

目前的游戏逻辑是,当敌人碰到玩家时,不断对玩家造成 1 点伤害。

回到敌人节点上,敌人的根节点 RigidBody2D 拥有两个信号:body_enteredbody_exited,这两个信号会在敌人接触到另一个 RigidBody 和离开另一个 RigidBody 时触发,所以下面这段代码可以将“是否碰到玩家”这个值记录在变量 接触玩家 中:

var 接触玩家 = false

func _on_body_exited(body:Node):
    if body == 目标:
        接触玩家 = false

func _on_body_entered(body:Node):
    if body == 目标:
        接触玩家 = true

接着,在敌人的 _physics_process 方法中,根据 接触玩家 变量来对玩家造成伤害即可:

if 接触玩家:
    目标.生命值 -= 1

RigidBody2D 节点设置

此时如果运行游戏,其实并不会有攻击效果,此处还需要对敌人的 RigidBody2D 节点做下设置:

  1. 启用 Contact Monitor 属性

  2. 修改 Max Contacts Reported 使其大于 0,此处我用了 10。

其中 Contact Monitor 属性控制了能否触发 body_enteredbody_exited 信号。

继承

继承,放到三次元就是说长辈的东西留给子辈,在编程中的继承也差不多。

请再回忆起之前讲解面向对象时用过的例子:手机。

我们当时说过,手机有颜色、电量这些属性,还有开机、打电话这种方法,用我们现有的知识,我们可以把手机写成下面这种类:

class_name 手机

# 默认是个黑色的手机,后面的三个零分别代表红绿蓝三色值,范围是 0 到 1
var 颜色: Color = Color(0, 0, 0)

# 默认满电,且电量变量取值范围是 0 ~ 100
var 电量: int = 100 : 
    set(新电量):
        if 新电量 > 100: 新电量 = 100
        elif 新电量 < 0: 新电量 = 0
        电量 = 新电量

var 已经开机 := false

func 开机():
    print("加载中...")
    已经开机 = true
    print("开机完成")

func 打电话(电话号码:String):
    if 已经开机:
        print("给 " + 电话号码 + " 打了电话")

这个手机挺好用的,能开机能打电话,但是某天国外一小伙敲不死推出了一款新手机,竟然能发短信:

func 发短信(电话号码:String, 短信内容:String):
    if 已经开机:
        print("给 " + 电话号码 + " 发信息,内容如下:" + 短信内容)

直接添加这个方法会导致每个手机实例都支持发短信方法,然而实际上,并不是每款手机都能发短信,因此,我们需要区分这个手机实例是老款手机还是新款手机。目前这样简单的功能我们可以添加一个属性来表示手机型号,并在新功能中判断该手机是否是新型,但如果新功能很多,这样的粗暴解决方式就不太好用了。最终的解决方案是,我们希望游戏中有两个类,一个是手机类,能发短信,另一个也是手机类,但不能发短信。

如果你是个勤劳者,现在只需要把上面代码复制一遍,再写一个包含 发短信 方法的新手机类即可,这样我们的游戏中就拥有了两种手机。

不过,历史上那些创造编程语言的人可能并不勤劳,但好在他们都很聪明,为了不写重复的代码,他们发明了继承机制,可以在某个类的基础上作出修改以创造新的类,这种情况下,我们把原有的类称为 基类父类,修改出来的新的类称为 派生类子类

偷懒是进步的阶梯,我们从来不写重复的代码。

继承的核心思想就是把父类的东西传承给子类,也就是说,父类有的东西子类也都有,但子类有的父类不一定有。上面的手机就是很好的例子,老款手机就是父类,新款手机就是子类,老手机能做的事情新手机也都能做,但新手机能发短信,老手机则不行。

现在我们动手来写新手机:

extends 手机
class_name 能发短信的手机

func 发短信(电话号码:String, 短信内容:String):
    if 已经开机:
        print("给 " + 电话号码 + " 发信息,内容如下:" + 短信内容)

ok,很简单就写完了,我们使用一个 extends 关键字指定了当前类的父类,也就是告诉 GDScript 当前类是根据 手机 类修改而来,因此我们不用再次书写颜色、电量等属性或方法,只需要添加新功能即可。

[!note]

GDScript 仅支持单继承,就是说子类只能继承自一个父类。

不过,父类还可能继承自另一个父类(爷爷类),所以继承关系可以形成一条长链。

节点与继承

每次添加新节点,都能看到这样的界面:

节点继承关系

这些节点类型之间呈现出一种树状结构,层层递进,这样布局是怎么来的呢?

我们用 Button 节点举例,它的完整位置在 Node -> CanvasItem -> Control -> BaseButton -> Button ,开头的 Node 表示这是个节点,CanvasItem 表示这是一个 2D 绘制节点,Control 表示 UI 节点,然后就是 Button 按钮节点。

现在添加一个 Button 到场景中,我们能看到它的属性列表如下:

Button 的属性

从上向下,我们会发现这些属性分成了 Button、BaseButton、Control、CanvasItem、Node 这几组,很明显这就是 Button 节点在节点列表中的路径倒序。

结合本节标题,你应该也猜到了,Button 类正是继承自这些父类节点而来,所以我们可以在 Button 节点上看到 Control、Node 等父类的属性。同理,其他节点也相同。

脚本的继承

思考我们曾经写过的代码:position.x += 10,这里的 position 属性来自哪里呢?没错,就是继承自父类的。

每个脚本的开头,Godot 都会给我们生成一行继承语句,继承的父类通常是脚本所绑定到的节点类型。

根据所学,继承可以给原有的类增加功能,所以当我们的脚本继承自某个节点类时,我们实际上就是在一种节点的基础上创造了一种新的节点,并添加了我们的功能,例如玩家移动等效果。

[!tip]

我们的脚本并不一定需要继承自这个节点本身的类型,也可以是它的父类,例如我们只是希望调整按钮的位置,那么我们可以继承 Control 类,这样这个调整位置的脚本就可以用在所有 Control 的子类类型的节点上。

重写

现在,所有手机在开机时都会显示两句话:

加载中...
开机完成

因为所有手机的开机方法都是相同的:

func 开机():
    print("加载中...")
    已经开机 = true
    print("开机完成")

又一个新要求来了,之前那个能发短信的手机,为了显得与众不同,他们决定定制一个开机画面。

为了展示那个定制化开机画面,我们可以写一个新的 开机2.0 方法。不过,当一个外人拿到手机时,他可能会错误的使用老开机方法,导致没能看到炫酷的新开机画面,因此我们要做的不是加方法,而是改方法

重写是面向对象中一个重要的功能,它可以让我们在子类中覆盖父类的方法,从而实现方法名一样但效果不同的效果。

现在,来到我们的新手机类中,添加如下代码:

func 开机():
    print("嘟-嘟-嘟-.-.-.-")
    已经开机 = true
    print("Hello!")

我们又一次声明了开机方法,这与父类中的开机方法重名,这,就构成了重写,也就是子类的这个开机方法覆盖了父类的开机方法。

现在,当一个人拿到手机时,不论手机是什么型号,只要他开机,即可根据手机型号显示不同的开机画面。

子类调用父类

现在,这个新手机想要大力推广短信功能,它们决定在每次打电话前都显示一句“为什么不试试发短信呢?”这样的广告语。

为了达成这个目标,我们不希望完全覆盖父类的方法,而是希望在父类方法前或后添加自己的代码,这时候,我们可以在代码中使用 super 关键字引用父类实例并从中调用方法:

# 在子类中
func 打电话(电话号码:String):
    print("为什么不试试发短信呢?")

    # 调用父类的打电话方法
    super.打电话(电话号码)

现在,新手机在打电话时,会先显示那句广告,然后执行父类的打电话方法。

[!tip] 生命周期方法

其实那些 _ready、_process 等生命周期方法也是重写的父类方法。

多态

多态,就是说多种形态。

例如在之前的手机例子中,手机就拥有多种形态,一种形态能发短信,另一种形态不能发短信。但总的来说,它们都属于手机,它们都具备手机共有的功能:开机和打电话。

在代码中,我们可以把子类实例存放到父类类型的变量中,并且可以根据父类中的成员名称访问子类的成员:

var 某人的手机:手机 = 能发短信的手机.new()

某人的手机.打电话("10086")   
# 虽然 某人的手机 是手机类型,但其值是 能发短信的手机,所以调用的是 能发短信的手机 的打电话方法。

# 某人的手机.发短信("10086","Hello")
# 上面这句注释掉的代码是错误的。
# 虽然`能发短信的手机`能发短信,但是 某人的手机 是老手机类型,不包含发短信方法。

[!note]

能发短信的手机.new() 的意思是实例化一个能发短信的手机,就是创造一个能发短信的手机实例的意思。

例如我现在拥有三台手机:

var 手机们 := [
    手机.new(),
    能发短信的手机.new(),
    手机.new(),
]

这三台手机都存放在了数组中,现在我想给它们都开机:

for 手机之一 in 手机们:
    手机之一.开机()

在这段开机的遍历代码中,我们并不在乎手机是能发短信的手机还是老手机,我们只管调用它的开机方法即可,此时的 手机之一 变量就是具有多种形态的。

当然,使用多态的目的不是让我们的变量变得花里胡哨,而是规范一类操作,在刚才的例子中,我们就规范了所有手机的开机方式都应该使用 开机 方法,最终的作用就是实现了这一句话:不在乎手机是 能发短信的手机 还是老手机,我们只管调用它的开机方法

整活:多种武器

本节来实现玩家切换武器。

回顾目前玩家发射子弹时的代码,是这样写的:

func _process(delta):
    if Input.is_action_just_pressed("攻击"):
        var 创建的子弹:子弹类 = 子弹.instantiate()
        创建的子弹.移动速度 = 鼠标方向.normalized() * 1000
        $"/root/".add_child(创建的子弹)
        创建的子弹.global_position = global_position
        创建的子弹.rotation = 鼠标方向.angle()

这段代码写在了玩家节点上,此时我们为了能让玩家切换武器,可以来制作一个新的节点,用来表示武器,并将发射子弹的代码写在武器中。

武器节点

由于咱们的武器不会显示出来,所以武器节点其实就是一个 Node 节点,不需要其他东西,重点内容在 Node 节点的代码上。

当然如果你希望给玩家的武器显示一些图片,也可以在这个武器节点中添加一些 Sprite2D 节点。

武器节点的代码

extends Node2D
class_name 武器

@export var 子弹: PackedScene

func 开火(dir:float):
    pass

这里,我们定义了一个开火方法,并需要指定一个参数来表示开火的方向,但是这个方法内部我们什么都没写。

此时当我们要添加一种新武器时,即可让新武器类继承这个武器类,并重写其中的 开火 方法,在新的开火方法中创建子弹即可。

例如现在创建 步枪 类:

extends 武器

func 开火(dir:float):
    var zd:子弹类 = 子弹.instantiate()
    zd.global_position = global_position
    zd.rotation = dir
    $"/root/Game".add_child(zd)
    zd.移动速度 = Vector2.from_angle(dir) * 1000

霰弹枪 类:

extends 武器

func 开火(dir:float):
    创建子弹(dir - 0.6)
    创建子弹(dir - 0.3)
    创建子弹(dir)
    创建子弹(dir + 0.3)
    创建子弹(dir + 0.6)

func 创建子弹(dir:float):
    var zd:子弹类 = 子弹.instantiate()
    zd.global_position = global_position
    zd.rotation = dir
    $"/root/Game".add_child(zd)
    zd.移动速度 = Vector2.from_angle(dir) * 400

接着我们再创建两个 步枪霰弹枪 的 PackedScene,并在其节点上挂在上面两个脚本,然后在玩家切换武器时实例化这两个 PackedScene 即可:

func _process(delta):
    if Input.is_action_just_pressed("武器1"):
            切换武器(load("res://武器/步枪.tscn"))
        if Input.is_action_just_pressed("武器2"):
            切换武器(load("res://武器/霰弹.tscn"))

func 切换武器(武器:PackedScene):
    $"武器".free()
    var wq = 武器.instantiate()
    add_child(wq)
    wq.position = Vector2(0, 0)
    wq.name = "武器"

接着即可修改玩家的开火代码:

if Input.is_action_just_pressed("攻击"):
    $"武器".开火(rotation)

信号

信号这个东西我相信大家已经能够熟练运用了,但我们一直都在使用 Godot 节点给我们提供的信号。现在我们要定义我们自己的信号了。

信号类似属性和方法,也属于类成员,在脚本中使用 signal 关键字定义信号,具体格式和定义方法差不多:

signal <信号名>([参数列表])

例如我们给手机类定义一个 开机完成 方法:

signal 开机完成()

然后把手机脚本放到节点上,就能在这个节点的列表中看到这个信号了:

开机完成 信号

触发信号

信号需要手动触发,使用 emit 方法:

func 开机():
    print("加载中...")
    已经开机 = true
    print("开机完成")
    开机完成.emit()

带参数的信号

例如咱们再做一个发送短信的信号:

signal 发送短信完成(目标号码:String, 短信内容:String)

func 发短信(电话号码:String, 短信内容:String):
    if 已经开机:
        print("给 " + 电话号码 + " 发信息,内容如下:" + 短信内容)
        发送短信完成.emit(电话号码, 短信内容)

[!note] 不可滥用信号

信号可以向外界反应自身的状态,但这不是节点之间的唯一通信途径,别忘了我们可以直接使用 <节点变量>.属性或方法 这种形式修改其他节点的属性或是调用其他节点的方法。

整活:更换武器的UI提示

首先先加一个 Label 节点来显示玩家当前的武器:

武器Label

玩家的代码

首先,我们给玩家的定义一个切换武器信号:

signal 更换武器(武器名:String)

并在切换武器时触发这个信号:

if Input.is_action_just_pressed("武器1"):
    切换武器(load("res://武器/步枪.tscn"))
    更换武器.emit("步枪")
if Input.is_action_just_pressed("武器2"):
    切换武器(load("res://武器/霰弹.tscn"))
    更换武器.emit("霰弹")

接着我们就可以给这个显示武器的 Label 添加上这样的脚本:

extends Label

func _ready():
    $"/root/Game/玩家".更换武器.connect(修改武器名)

func 修改武器名(名称):
    text = "当前武器:" + name

进阶技巧

掌握前几章的内容就已经可以实现很多游戏效果了,再继续学习一些引擎和节点的知识,就能实现绝大多数的游戏功能。

但就像其他技能一样,掌握使用方法只是一个开始。如果你的工程稍微复杂些,下面这些内容将让你编写出更高质量的代码。

编写的代码不仅要保证能用,还要保证能读懂,且容易修改和添加新功能。

枚举

有时我们需要存储“有限数值”中的某一个值,例如角色的职业、元素的属性等。

此处用“星期”来举例子:var 今天是周几 = "星期一",此时当另一个人接手这个项目,或是你摸鱼许久再来填坑,可能不小心写了这样一行代码:if 今天是周几 == "周一":,在你的脑海中这是正确的,但在计算机眼里 "星期一""周一" 是两个完全不同的值,所以这个 if 语句的条件永远不会满足。

此时最简单的办法就是告知参与项目的所有人,规定 今天是周几 变量的值只能存放 "星期几",而不能写 周几 或 礼拜几。

但就像我在封装那一节说过的,总有一天这种规矩会被忘掉,所以我们干脆定义一种新的类型吧,就叫它 星期 类型:

enum 星期 {
    星期一,
    星期二,
    星期三,
    星期四,
    星期五,
    星期六,
    星期日,
}

上面这种语法就是在定义枚举,使用 enum 关键字表示定义枚举的开始,然后紧跟枚举的名称,此处枚举名是 星期,接着一对花括号,括号内部填写枚举的值,并用逗号分隔。(最后一个值后的逗号是可选的)

接下来结合强类型语法即可修改之前的 今天是周几 变量:

var 今天是周几: 星期 = 星期.星期一

[!tip]

枚举的定义位置应该在代码文件的最外层,不能在方法里定义枚举。

在游戏中枚举的用法例子:

enum 职业 {
    战士, 法师, 射手
}
enum 属性 {
    火, 水, 电,
}

常量

有时候我们需要一些不需要改变或不能改变的量,也就是常量,例如数学中的 π (3.14159)作为一个不变的数字,我们就可以把它存放在常量中。

当然圆周率这种常用的东西,Godot 已经给咱们存好了,这个常量叫做 PI

常量使用 const 关键字定义,语法格式与变量相同:

const <常量名> = <值>

由于常量不允许被修改,所以在定义时必须给它一个值。

单例/自动加载

我们的游戏经常需要有个地方存放一些全局性的信息,例如游戏的版本、当前游戏时间等,通常我们会单独制作一个节点来存放这些信息。

这就引出了一个问题,这个全局信息节点由谁来创建呢?首先排除手动创建,大家心里要明白当 Godot 自己不出问题时最容易出问题的东西就是人,所以 Godot 给咱提供了自动创建功能。

现在打开菜单栏中【项目】【项目设置】界面,点击其中的【自动加载】选项卡即可看到:

自动加载界面

在最上面的路径中填写需要被加载的 PackedScene 路径或点击后面的文件夹按钮来选择一个 PackedScene 后,再给它起个名字即可点击最后的添加按钮:

添加自动加载

同时注意自动加载列表中有一个全局变量按钮,当勾选了这个东西时即可在代码中的任意位置通过前面的名称使用这个节点或脚本的实例,例如现在在任意代码处即可使用:

print(player.global_position)

如果你学过其他编程语言中的设计模式,就会明白“自动加载”就是起到了单例模式的作用。

唯一名称

某些节点需要在其他位置反复通过 $"XXX" 语法访问,这时候 $ 符号后面长长的路径就会成为累赘。

如果这个节点的名称在场景中是唯一的,那么就可以给这个节点勾选上 唯一名称:

唯一名称的位置

此时即可在代码中通过 %"节点的唯一名称" 语法来获取这个节点,在这种语法中就只填写节点名即可,不需要节点的路径。

如果节点名称没有空格或者其他特殊符号、没有造成语法歧义的话,可以去除引号,也就是 %节点的唯一名称

[!note]

注意,不是不重名就可以用 % 符号获取,一定不要忘记给节点标记上 唯一名称。

load

之前在学习 PackedScene 的时候用过 load 方法,知道它可以从文件中读取一个 PackedScene。

但实际上,load 方法可以读取任何 Godot 认识的文件,例如脚本、图片、声音以及PackedScene等等。

例如加载图片并显示在 TextureRect 节点上:

$"TextureRect".texture = load("res://你的图片路径")

或者加载一段声音并播放:

$"AudioStreamPlayer".stream = load("res://你的音频路径")
$"AudioStreamPlayer".play()

定义类

我们已经感受到了,在 Godot 中,一个 GDScript 脚本文件就是一个类,但某些情况下,我们需要一些小巧的类,我们懒得去再创建一个新的脚本文件了,此时就可以用内部类语法:

class <类名>:
    <类成员(方法、属性、信号等)>

例如我们用这种形式定义一个 伤害来源 类:

class 伤害来源:
    var 攻击者 = null
    var 伤害值 = 0
    var 是魔法吗 = false

可见,除开第一行 class 关键字外,其他的语法都正常的类相同。

但注意,这样定义的类被称作内部类,就是说这个伤害来源类是位于当前脚本文件类内部的类,所以外部脚本想要使用这个伤害来源类时,就需要使用 外部类.内部类 的形式来访问。

假设刚刚定义 伤害来源 类的文件中有一行 class_name 伤害相关,那么外部代码在使用 伤害来源 类时则需要:

var 伤害 = 伤害相关.伤害来源.new() # 实例化这个类

[!note] 非节点的类实例

上面的 伤害来源 例子中,这个类就没继承自任何一个节点父类,也就是说 伤害来源 并不是一种节点,当实例化了这个类时,游戏中确实会多出一个 伤害来源 实例,但这个实例不会对游戏产生直接影响,它的属性和方法仅供我们其它代码使用。

类型判断

有时候我们需要判断一个实例是否是某个类的实例,此时可以使用 is 关键字。

例如,判断进入碰撞范围的节点是否是敌人:

func _on_body_entered(body):
    if body is 敌人:
        print("敌人进来了")
    else:
        print("进来的不是敌人")

其中,body is 敌人 会在 body 为敌人类或敌人子类的实例时得到 true,否则为 false。

静态

有时候我们需要定义一些“工具方法”,例如获取两个敌人中生命值较高的那一个:

func 获取生命值高(敌人1, 敌人2):
    if 敌人1.生命值 > 敌人2.生命值:
        return 敌人1
    return 敌人2

这样的方法只能在当前这个脚本中使用,其他脚本如果想用,就需要先获取上述代码所在脚本的实例,这明显会很麻烦。

回想我们之前使用过的方法 Input.get_action_strength,我们就直接通过 Input 这个类名使用了里面的方法,这种方法被称为静态方法,在 GDScript 中使用 static 关键字标注:

static func 获取生命值高(敌人1, 敌人2):
    if 敌人1.生命值 > 敌人2.生命值:
        return 敌人1
    return 敌人2

这样,如果上面代码所在的文件中定义了类名 class_name 工具,即可在任意代码处使用 工具.获取生命值高 来调用这个方法。

字符串格式化

我们经常需要给玩家显示一些文字,例如向玩家发送一条消息:获得了 10 点经验,你升到了 101 级!

var 经验 := 10
var 等级 := 101
发消息("获得了 " + str(经验) + " 点经验,你升到了 " + str(等级) + "  级!")

其中的发消息函数的参数看起来是在太丑了,这种中文与代码混合的形式简直不是给人看的。Godot 为了咱不被恶心到,提供了一种格式化字符串的语法:

<模版字符串> % <填入值>

我们现在修改上面的发消息方法:

发消息("获得了 %d 点经验,你升到了 %d 级!" % [经验, 等级])

前面字符串中的 %d 我们称之为占位符,此处会被替换成百分号后面的内容,上例中,经验变量的值填入到了第一个 %d 的位置,等级变量的值填入到了第二个 %d 的位置。

存档与读档

Godot 拥有对 Json 数据的完美支持,如果你学习过 Json,可以尝试使用 Json 保存游戏存档。

但面对零基础的新人,我会介绍一种纯 Godot 的数据保存方式。

元数据

我们在场景编辑器中,选中任意一个节点后,即可在引擎最右边看到属性面板,这个面板最下面有一个“添加元数据”按钮:

添加源数据按钮

所谓的元数据,你可以理解成给节点添加一些额外的信息,这些信息类似属性,也是由名称和值来表示的,点击添加元数据按钮后,即可看到一个元数据设置节点,只要在这里填入数据名和值的类型即可,例如咱们现在来存储一下玩家的分数:

元数据存储分数

截止至 4.0.1 版本,元数据名称不支持中文。

点击添加后,在属性列表的最下方即可看到这个分数字段,并且可以修改数值。

打包节点

本节讲的是存档,为什么我要先讲元数据呢,因为咱们 Godot 给咱提供了一种方法,可以将一个节点保存成文件,这个文件中自然也就包含元数据。

保存节点其实很简单,本质上就是利用了 PackedScene,不过我们之前都是通过 load 方法或 @export 属性来获取项目中已经存在的 PackedScene,而现在,咱们要凭空创造一个 PackedScene。

创造 PackedScene 很简单,只需要先实例化一个 PackedScene 实例,并调用它的 pack 方法即可:

var 打包包 := PackedScene.new()
打包包.pack(被打包的节点)

这个 pack 方法就是将某个节点放到这个 PackedScene 中,所以结合上面的节点元数据,我们就能把分数信息保存到一个 PackedScene 中了:

var 节点 := Node.new()
# set_meta 就是添加一条元数据
节点.set_meta("分数", 123)

var 打包包 := PackedScene.new()
打包包.pack(节点)

[!tip] 不需要 add_child

此处的 Node 节点只是存个数据,不需要添加到场景中去。

保存资源

接下来只剩下最后一步了,只要将 PackedScene 保存成文件即可,这需要使用 ResourceSaver.save 方法:

ResourceSaver.save(打包包, "user://存档.tscn")

这样,那个包含分数元数据的Node就被以 PackedScene 的方式保存到用户目录的 存档.tscn 文件中了。

完整的代码如下:

var 节点 := Node.new()
# set_meta 就是添加一条元数据
节点.set_meta("分数", 123)

var 打包包 := PackedScene.new()
打包包.pack(节点)

ResourceSaver.save(打包包, "user://存档.tscn")

# 删除这个用完了的节点
节点.free()

[!warning] 不要保存引用

不要保存任何实例的引用,在读取时这些引用都会失效。

建议只保存值类型数据,例如 数字、字符串、Vector3、Color 等。

读取存档

因为咱们保存的是个 PackedScene,所以读取存档就是实例化 PackedScene:

var 节点 = load("user://存档.tscn").instantiate()
# 显示之前保存的分数
print(节点.get_meta("分数"))
节点.free()

函数式编程

我们的变量可以存数字、字符串、节点实例等各种东西,现在,咱们试试在变量中存放方法。

此处所说的方法,就是咱们一直使用的,使用 func 关键字定义的方法,函数式编程允许我们在变量中存放一个方法的引用,并可通过这个变量调用对应的方法。

func _ready():
    var f = A
    f.call() # 输出 123

func A():
    print("123")
func B():
    print("666")

同时我们也可以简写,直接将方法定义到变量中,而不用定义新的方法:

var hello = func():
    print("Hello")
hello.call()

这种写法一般称为 Lambda 表达式,或者匿名方法。

注意,调用变量中的方法必须要使用 .call,如果方法有参数,则填入到 call 方法的参数中即可。

还没整理的内容

find_child

find_child 方法类似 find_path,不过它的参数是节点名而不是路径。

从名字可以看出,find 是寻找,可以理解成搜索节点,搜索范围是全部的子节点。

所以我们可以用 find_child 来获取藏在子节点内部甚至子子子节点中的某个节点。

节点名参数还可以使用 *? 这种通配符,具体使用方式可以看文档。

还可以了解一下 find_children 方法,可以获取多个节点。

遍历子节点

get_child_count 可以获取当前节点的子节点个数。

get_child 可以根据下标获取一个子节点。

结合 for 语句可以遍历全部子节点:

for i in range(get_child_count()):
    print(get_child(i))

字符串操作

查找位置、切割字符串

节点锁定???