平台跳跃游戏中从平台上落入万丈深渊通常都伴随着死亡。有了前面积累的知识,实际上我们容易想到实现这种效果的做法。
最简单的做法就是在画面下方放一个足够宽的Area2D。当玩家碰到它的时候,我们就执行和死亡相关的代码。考虑到我们可能不止在有万丈深渊的地方放上这样的一个area——比如我们可以用它来实现其它的惩罚玩家的机制,我们把它作为一个单独的场景来创建。
新建场景,比如叫它KillArea(你叫它KillZone我觉得也可以)。由于它没有过多的花哨功能,一般来说也不会移动,所以我们可以直接把根节点改成Area2D。和之前一样,我们需要给它添加一个CollisionShape2D,具体的形状创建一个矩形就好。场景很简单,就这样:
把它直接放到我们的主场景中,调整它的大小,让它的宽度足以覆盖住场景——当然你也可以根据自己的需要来调整。简要复习一下。调整大小就是调整它的scale。除了在检视面板中的Transform栏中的Scale属性中调整(记得解锁X和Y同时调整),也可以在2D场景视口上方的工具按钮中选择缩放工具直接在场景中调整:
我的场景现在大概是这样:
CollisionShape的形状在启动游戏时不会显示,如果想要在游戏运行时也能看到它可以启用Debug菜单中的这一选项:
现在需要为KillArea添加脚本实现相关功能。
脚本不会太复杂,主要是连接上Area2D的body_entered信号——毕竟我们的玩家角色使用CharacterBody2D制作的。
碰到KillArea之后玩家就会死,死的时候有些什么要做的呢。首先我们想播放一个动画,素材里刚好有个可以用的素材叫player_hurt(虽然叫“受伤”但是不影响)。然后我们自然想让这个节点死掉,或者说消失。
我们在前面已经添加了好几段动画,现在应当不是问题。除了添加动画之外,我们再给表示玩家状态的State枚举类型再加上一个DEAD状态,这样在其他处理输入和重力的相关方法中如果发现玩家已经死亡我们就不再进行相关处理。
动画添加好后,修改播放动画的方法:
注意我把处理跳跃和下落的代码也修改了。由于我们会跌落到KillArea上然后修改状态,所以跳跃和下落的动画只有在状态不为DEAD时才播放。
然后修改_physics_process。发现状态为DEAD时直接返回。当然你也可以把if的条件反过来,发现状态部位DEAD时才执行后面的代码。但是像我这样写的话可以马上return,不用多一级缩进。
随后我们添加一个die方法,这样在KillArea中我们就可以调用这个方法让玩家死掉。目前来说就简单在这个方法中把状态设为DEAD即可。
然后在KillArea中调用——但是等等。body_entered信号连接的函数的参数是Node2D类型,但是我们的die方法定义在玩家——也就是Player类型(或者其他你给它取的名字)上。我们应当在试图调用die方法之前,先检查一下到底是谁进入了area。由于目前我们只考虑在这个area里杀死玩家,所以我们只检查它是不是Player就行。在GDScript精要部分我提到过,如果要把脚本作为一个类型让它在其他脚本中也可以使用,需要额外写一行代码:
在玩家角色的脚本中,我添加了一行class_name Player。这样在其他脚本中就可以通过Player这个类名来指代它了。
要检查某个对象的类型,直接用is运算符加上类型名即可:
很遗憾的是目前GDScript没法把它写成body is not Player。顺口提一句在Python和C#中是可以写is not的,只不过Python没法用它做类型检查。is的优先级高于not,所以这里不写括号也可以,当然拿不准的时候加上括号准没错。
判断类型之后,我们用as运算符将body转换为Player,然后调用die方法。这里为了方便我就写到一行没有单独定义一个局部变量。由于前面有类型检查作为保证,这里不用as转换也没问题,但是在写代码时就没有自动补全提示了。
另一方面,我们其实也可以设置Area的物理层,让它只检测玩家所在层。不过现在我们没有那么多不同的场景节点要处理,所以就简单检查一下是不是玩家就行了。
现在运行游戏,玩家落下的时候碰到KillArea就会停止并播放死亡动画。
现在播放可以正常播放,但是玩家场景节点依然存在。我们要调用一个方法让它消失。queue_free方法可以销毁节点。它定义在Object(绝大部分东西的基类,也是Node的基类)上,是一个方法,没有参数,指的就是销毁“这个”东西。queue_free不会立即销毁,它会等到当前帧结束,并且一些函数调用完毕后才会销毁对象,即使一帧多次调用它也是安全的。所以我们一般都会用它来执行销毁节点的工作。
在die方法中加上queue_free的调用,运行游戏。
嗯……玩家是没了,但是我们看不到动画了。
我们希望,在碰到KillArea,播放动画,然后过一段时间再执行queue_free。聪明的你可能已经想到,可以用之前学习过的Timer节点做一个计时器在timeout信号连接的函数上销毁自己。
这样做可以,但是没必要。因为我们并不会多次用到这个计时器,计时结束整个玩家节点都会没,让它一直背着一个不怎么用的节点有点浪费。因此我们学习一个新东西。
SceneTimer
SceneTimer如其名,也是一种计时器。但是它不是节点(不继承Node)。场景树会负责管理SceneTimer实例。正如文档中所说的那样,常用于创建一次性计时器。
要创建一个SceneTimer直接调用场景树的`create_timer`方法。要获得当前场景树则需要调用get_tree。create_timer只有一个必填参数那就是时间。SceneTimer和Timer节点一样,有一个timeout信号,我们只需要把计时结束时要执行的代码连上即可:
SceneTimer不是节点,所以也就没法在编辑器中连接信号了。所以这里在代码中用connect方法连接信号。我们目前的想法是“一段时间之后销毁节点”,就一行代码,所以没必要单独写一个方法,这里直接写了一个lambda函数传给connect。
现在运行游戏,碰到KillArea之后3秒,玩家就会被销毁。
await关键字
上面的代码实际上可以用Godot 4+中引入的await关键字来简化。首先来看简化后的版本:
运行游戏,效果和之前完全一样。
await关键字的作用和它的字面意思类似,会“等待”某个东西。await后面只能是两种东西,要么是一个信号,要么是另一个含有await的函数调用(注意是函数调用而不是函数本身)——即协程。含有await关键字的函数会成为一个协程(coroutine)。协程是一段可以被挂起(暂停),并在合适的时候可以继续执行的程序。
代码执行过程中遇到await时,会暂停(挂起)当前函数的执行,直到await后面的信号发出或者协程执行完毕,再继续执行后面的代码(如果有)。
调用协程的函数(不在协程前加上await关键字)在代码执行到首个await时会立即返回执行自己接下来的代码,直到协程中的代码可以继续执行时才会接着执行协程中的代码。试看这个例子:
运行一下代码看看效果。“吃吃吃”和“再来一个汉堡”毫无疑问首先按顺序输出。紧接着调用coroutine函数,此时“老板:好”紧接着也正常输出。按道理来说接下来需要等待计时器3秒倒计时,但由于调用coroutine时我们没有使用await关键字“等待”它,因此遇到coroutine中的await时执行过程立即返回到ready中,然后接着执行后面的代码输出“吃吃吃喝喝喝”。range函数会构造一个含有5个数字的数组,我们这里的循环会执行5次、每次间隔一秒,输出“吃吃吃喝喝喝”。与此同时,三秒后(吃吃吃喝喝喝输出三次)coroutine中的await等到了计时器的timeout信号发出,然后老板才说汉堡好了。
这给人的感觉就好像是两段代码在同时运行、互不干扰一般。从某种程度上来说这种特性类似于各种编程语言中的异步编程(asynchronous programming)模型。并且一些编程语言同样使用await(以及async)关键字来实现相关功能。异步编程旨在遇到耗时的方法调用时不阻塞(block,它可以是一个术语)当前的代码执行。在游戏开发中过程中,从直观感受(和某些层面)上来说,我们的时间单位更像是“一帧”。在一帧中会有很多操作完成。但是,有时候我们不希望(比如刚才的死亡机制)或者说不能(比如说耗时的下载过程)让某些操作在一帧内完成,但又希望不影响其他代码的执行,此时就可以用协程来解决。
注意,真正的异步编程和协程可能和多线程、多任务、多进程有关,但并不能划等号。
重生
各种游戏在死了之后还得重生。怎么死我们知道了,现在还得知道怎么生。
现在的Player实际上是一个放在主场景中的一个场景实例。我们要学习如何从场景中构造一个场景实例。
先来新建一个Node2D场景作为一个spawner。目前来说我们只是需要一个位置来重生玩家,所以就不需要其他的节点了,创建好场景添加一个脚本就行。
Spawner只产生玩家可不够,我们以后可能还需要用它来产生各种不同的东西,所以我们必然不能写死让它只产生玩家。那么怎么去引用一个场景呢?场景也是一种资源,它也有对应的类型。代表场景资源的类型是PackedScene。它就是对我们正在编辑的、可以保存为文件的各种场景的抽象。为了方便随时编辑,我们直接定义一个变量并且用export暴露出来:
接下来,只需要编写一个简单的spawn方法来实例化这个场景就好了:
场景的instantiate方法顾名思义就是实例化的意思。它会直接根据场景造一个实例出来。但是光有这样一个实例还不行,我们要把它添加到场景中。下一行代码就是获得spawner所在的场景树。add_child顾名思义就是给某个节点添加子节点。我们这里是在场景树的root节点上调用的,它会把玩家加到场景根节点下面,而不是添加到spawner下面。
此外为了便于在其他脚本中引用Spawner,我还加上了class_name Spawner这行来暴露它的类名。
接下来我们给主场景添加一个脚本。在脚本中我们需要找到这个生成玩家的spawner,然后在合适的地方调用spawn。我们之前为了方便是直接把玩家场景丢到主场景中的,现在我们需要利用spawner来生成玩家,所以我们就在ready中调用spawn方法。现在删除之前放在主场景中的Player场景节点,然后放入Spawner场景,调整到合适的地方,并把玩家场景放到它的Scene To Spawn属性槽中,然后主场景的脚本如下:
我们希望在游戏开始时生成玩家,就是这样,很简单。
启动游戏,啊?报错了。错误信息说父节点忙于准备子节点,添加节点失败了。让我们考虑在add_child上使用call_deferred方法(要记得函数/方法也是对象,它们是Callable类型的,这个deferred是定义在Callable上的)。这个call_deferred啥意思呢,字面上来说是延迟调用的意思。从文档中可知,在Callable上调用这个方法会把方法的调用延迟到这帧结束时。
_ready会在节点及其子节点进入场景树时调用,并且此时子节点的ready都先于父节点调用完毕了。但是我们偏偏在这个时候加入新的子节点,虽然其他节点ready了,但是新加入的节点还没有ready,这样的操作不太合适。因此需要延迟一下它的执行。不过考虑到我们后面会编写用于重生的代码,因此不会总是在某个场景的ready中调用spawn,所以我们不延迟add_child而是直接延迟spawn:
注意回忆Callable的相关知识。加上括号是函数调用,不加括号就是在引用函数(Callable)本身。
再次运行游戏,没有报错,有玩家角色成功生成,但是它位置不在spawner的位置!
位置默认是(0, 0),我们把生成的玩家场景节点加到了主场景的根节点中,所以它是在场景中的左上角生成的。所以我们在spawn方法中把实例化出来的玩家场景的位置设置为spawner的位置就好:
注意,我没有给instance标注类型,而instantiate返回类型为Node。Node上面没有定义position属性,所以编写这段代码时不会有提示。但是,我们要生成的场景(不是场景资源本身)必然是Node2D的子类,所以我可以断定这个position是存在的。如果还是不放心,可以在instantiate后面加上as Node2D来保险(不写Player是因为我们可能会用来生成其他类型的场景)。
不过,我们还是没有完整实现重生的功能。比如我们希望在玩家死亡(销毁玩家场景节点)后,按任意键重生。我们知道怎么死,也知道怎么生,但是什么时候让谁去生呢?
考虑到玩家死亡时我们需要进行不止一项操作,比如除了重生玩家之外,我们还可能需要展示UI元素(后面会讲)、播放声音等等。这些操作不止涉及玩家场景自己,但是其他场景却不一定知道玩家什么时候死亡。这种时候就是在暗示我们可以自定义一个信号。
发出死亡宣告
其实之前讲信号的时候简单提了一下如何自定义信号,忘了也没关系,本身也很简单。
我们希望玩家死亡时发出一个died信号表示玩家已经死亡。目前来说这个信号不会提供任何参数。定义信号时和定义一个“没有主体的函数”类似,只不过func变成了signal。在玩家的脚本中加上:
任何连接到died信号且函数签名兼容(和信号定义的参数类型相同)的函数,都会在died信号发出时得到通知(被调用)。
然后我们在处理玩家死亡的函数中发出该信号。信号的emit方法就是发射信号,如果信号有参数则需要传入相应的参数:
和其他信号类似,我们需要连接它。我们在主场景的脚本中连接它。不过……
我们的spawn方法没有返回值,所以我们拿不到对玩家场景节点的引用。当然你也有方法可以直接找到主场景中的Player场景,但是没必要。我们直接修改Spawner的spawn方法,让它返回spawn出来的东西:
方便起见我同时给它标注了返回值。然后修改主场景的代码,但是……
我们在主场景中的代码使用了call_deferred来延迟调用spawn。但是call_deferred不会返回Callable的返回值(其返回值类型为void),毕竟这个call_deferred立刻就返回了,真正的调用会发生在未来。我们拿不到生成的玩家角色。不过好办,我们可以先拿到刚生成的玩家,但是我们自己延迟把它加入场景树中。
这里你可以选择直接修改spwan方法,让它不调用add_child,也可以像我这样单独写一个不加入场景树的方法,需要的时候调用它:
然后在主场景中:
嗯……看起来有点复杂。我们慢慢来看。首先我们调用spawner实例化场景后不会自行把新场景实例加入当前场景树的spwan方法,这样我们就能在主场景的代码中拿到新产生的player。
随后我们连接玩家的died信号。因为这个player是我们用代码生成的所以没办法在编辑器中连接信号,这里用signal的connect方法连接我们定义的一个方法来响应玩家死亡的信号。
接下来,我们希望延迟加入刚生成的玩家角色,因此需要call_deferred。但是add_child(player)是一条表达式而不是Callable,你没法直接在它身上调用call_deferred,所以我们用一个lambda函数把它包起来获得一个Callable然后延迟调用。
实际上`call_deferred`还有另一个定义在Object上的版本(节点也是Object),它需要传入要调用的方法的名称,然后传入它的参数:
个人不太喜欢用函数名字符串传函数,但是这样写也可以达成目的。
在这个信号响应方法中,我们等待半秒钟——这是随便写的,然后调用spawner的spawn方法重新生成玩家。由于此时场景的ready早已调用完毕,我们也就没必要延迟加入新生成的玩家。但是我们依然需要连接上新生成的玩家的died信号。
现在运行游戏,死亡和自动重生的相关功能就能正常工作了!