这篇文章中,我们来学习如何构建一个2D平台跳跃游戏的主角。我们终于要运用前面所学的各种知识,来构建游戏中最常见、最基础也是最必要的元素了!激不激动?
我们先来看用我们现在已经学到的知识能够做到哪种程度。
毕竟我们算是正式开始做游戏了,我们可以新建一个项目,然后新建一个空的2D主场景。
最后再新建一个2D场景(暂时以Node2D作为根节点)作为主角单独的场景。假设我们只知道前面讲解过的节点,那么主角首先得有一个sprite作为它的图像表示,因此给根节点加上一个Sprite2D节点。保存场景,重命名为Player或者你喜欢的名字。然后给它一个脚本。
下面我们导入需要用的素材,机器人logo也还算好看但不可能一直用下去。这里我们用在Godot官方文档中也出现过的素材。
这个小狐狸来自由ansimuz创作的Sunny Land系列素材包。许可为CC0。
其实一开始我也考虑过用Keney的素材库。但是Keney的2D素材的动画帧太少了,做出来可能没有那么“有成就感”。
下载这个小狐狸素材。下载完成后直接拖入Godot的文件系统面板中即可开始导入。当然你也可以导入你自己喜欢的素材。不过要记得建好文件夹,好好整理素材!这里方便起见直接把所有素材都导入到Godot中(其实PSD文件夹可以删掉)。
我们将PNG/sprites/player/idle文件夹中的player-idle-1作为我们的玩家场景的Sprite2D的纹理,拖动到检视面板Texture处。此时检视面板会显示成这样:
莫名其妙地有点糊。这里需要修改一个设置。在Project/Project Setting对话框中,找到Rendering/Textures,把右侧的Default Texture Filter(默认纹理过滤器)从Linear改成Nearest,我们就可以看到场景中的小狐狸没有那么糊了。
目前Sprites2D节点虽然位置位于(0, 0),但是实际上(0, 0)在狐狸的脑门上。为了便于后续的操作,我们把Sprite2D的Y坐标降低16个单位(这套素材的尺寸为33x32),这样它大概就位于地面(姑且将Y=0的平面视作地面)上了。
节点的position属性是位置,不过更准确地说是相对于父节点的位置,文档中也是这样描述的。
想象一辆在行驶过程中的汽车。汽车载着我不断前进,汽车的position相对于世界(场景)来说在不断变化。我作为汽车的子节点,如果我坐在座位上(0, 0)处不动,我的position就一直是(0, 0)——尽管我相对场景来说也在移动。如果我从座位上站起来向右走到(1, 0),这实际上也是相对于汽车来说的位置。
在某些情况下我们可能需要获得“绝对位置”即在场景中的确切位置而不是相对于父节点的位置,这时需要用到一些函数来转换。当然具体如何求得这些值是实现细节(当然要用到线性代数的知识),我们在现阶段可以忽略。
另一方面,我们的玩家场景用了一个没有任何图像呈现的Node2D节点作为根节点(而不是直接用Sprite2D)也是为了方便调整sprite的位置而不会影响整个场景(在其他场景中)的位置。当然直接用Sprite2D作为根节点也可以,要调整sprite到地面上就必须调整Offset(偏移)属性。
这套素材是按照像素游戏的尺寸来做的。如果我们把Player节点放到主场景中,大概率它会显得特别小。注意场景中的紫色框,它是作为游戏画面尺寸的参照物的:
这里需要调整一下窗口的设置。在Project/Project Settings中找到Display部分。右侧的Viewport Height/Width分别代表视口的高度和宽度。默认的数字对于我们的素材来说太大了。把它修改成一个较小的数字,例如384x216。
还没完,在下方的Stretch部分修改Mode为canvas_item,其他可以保持不变。
现在启动游戏,窗口变小了,但是如果我们拉伸或者直接最大化它,我们可以看到画面会根据设置直接放大,我们就能看清楚了。也可以在刚才的设置里直接让它以最大化窗口启动。
在早先的文章中已经介绍到了如何接收输入并移动节点。我们先在Project/Project Settings/Input Map中添加两个新的输入映射作为左右移动的按键设置。你可以随意设置自己喜欢的按键,也可以连接手柄设置手柄的按键。
之前我们直接在process中每帧处理输入,但是考虑到处理操作的代码会逐渐增多,我们把处理输入的代码写到一个单独的方法中。同时为了便于随时修改角色的移动速度,我们用一个export变量把速度暴露到编辑器中。目前的代码大概是这样:
永远要记得,不要追求和我的代码一模一样。你在创作你自己的游戏!即使你想要达成同样的效果,你的代码也有无数种写法,而我的写法也不一定是最好的!我只能引导你思考,但是不能代替你思考!
注意Input中有两个名字类似的方法的区别。pressed和just_pressed。pressed在按下一个键(不松开)后会持续返回true;just_pressed顾名思义是刚按下的时候才会返回true,要松开再按下才会再次返回true。
现在我们来思考一个之前一直忽略的问题。process会每帧调用。但是玩家朋友们大多知道,每台设备的硬件水平不一样,玩游戏的帧率也不一样。这就造成一个问题,如果一台设备以每秒90帧的速度运行这个游戏,那么一秒钟process就会大概调用90次,那么一秒之内这个节点就会移动90x5个单位;另一台电脑差一点,每秒85帧,那么它移动得就会慢一点!这显然不是我们想要的结果,一般来说我们不希望玩家玩同一个游戏时能因为电脑更好而在游戏中获得明显优势!
显然process有一个参数,这个参数是什么意思呢。它是两次调用process之间的时间差。如果单位时间内一台设备运行游戏的速度越快,那么单位时间内process调用次数就越多,进而delta就越小——每次移动它时就应该少移动一点;相反,慢一点的设备就应该在同样的时间内移动更多才能赶上快的设备!因此为了解决这个问题,我们就要乘上delta来作为移动的距离。由于delta是一个很小的数,因此speed也必须得改大一点:
好吧,现在能动了,但是又没动。无论怎么移动这个小狐狸都是一个样——我们明明有那么多素材!
容易想到的是,我们可以在不同的状态下设置Sprite2D的texture属性为不同的素材,这样就可以让角色有不同的视觉效果了。
我们需要用一个变量来记录角色当前的状态。比如它是否在移动,是否在跳跃。这样我们就可以根据其状态来显示不同的图片。角色的状态在一瞬间必然是几种状态中的一个,这就暗示我们可以用枚举类型来表示。定义一个State枚举,暂时给它两个状态。然后定义一个变量来表示角色的当前状态。
为了方便,我们还可以定义一个export变量,这样我们就可以直接把素材拖到检视面板中同时也可以在代码中引用:
我们把素材文件夹中的player/player-idle-1.png拖到检视面板的Run Texture槽中。提醒一下,如果发现检视面板中没有出现Run Texture,先保存一下编辑后的脚本。
由于我们在从跑动的状态转换回闲置状态后需要切换会闲置的小狐狸素材,这里给闲置的素材也准备一个export变量便于编辑。此外还需要在代码中创建一个onready变量用于引用Sprite2D节点。于是乎我们现在有了这几个带标记的变量:
如果忘记了onready和引用节点的相关内容,可以复习一下前面的文章:
接下来需要适时改变状态。道理很简单,任意方向移动时我们修改状态为RUN,否则就是IDLE:
注意和前面相比修改了哪些地方。由于在没有任何操作的情况下就相当于默认是IDLE状态,所以我在最后加上了这一句。而前面处理不同操作的if中我加上了return来提早结束方法的执行。
接受完输入、状态修改完毕后,我们在最后调用播放动画的方法来修改图片:
这里用的是match语句(见上一篇文章),当然用if也不是不行。由于目前就简单两个状态,也不用写通配符来兜底了。不同的状态下我们设置不同的素材。效果大概是这样:
第一我们只能“朝一个方向跑”,这里我的意思是即使我们往左走还是播的向右的素材。毕竟素材里只有向右的版本。
很多时候2D素材在左右两个方向只有一个方向的。如果角色是左右对称的其实直接水平翻转一下也不会露馅。
要翻转sprite,我们需要关注Sprite2D的两个flip_属性。flip就是我们所说的翻转,而flip_h和flip_v分别对应水平(horizontal-ly)和垂直(vertical-ly)翻转。这两个属性都是bool类型,可以修改它们来控制sprite翻转与否。
由于素材的默认方向是朝右,所以要朝左时我们就进行水平翻转。否则就不翻转:
素材包里自然有多帧跑动动画。我们知道如何export单个Texture2D变量,但是如何引用多个素材呢?这就要用到上一篇文章中提到的“有类型的数组”。这里我们新建一个Texture2D数组并将其export:
我们可以添加多个纹理到里面。这里有两种操作方式。第一种可以点击检视面板中的这个条目,显示详细内容之后按按钮添加新的条目,然后一个一个拖进去。
也可以直接选中多个纹理直接拖进去。显然这种要简单不少。
利用数组的各种属性和方法,可以用索引去引用其中的纹理。那么我们的问题就变成了找到一个方法“隔一段时间给索引加一,然后又变回0,周而复始”。
在Godot中计时的方法其实不止一种,比如我们可以在process中根据delta记录时间,然后按照我们需要的频率来检查是否被整除。也可以直接调用函数获取操作系统时间。不过这里我们学习一个专门用来计时的节点:Timer(计时器)。
打开玩家的场景,添加新的节点,搜索Timer并添加。计时器节点的图标是一个沙漏。
选中节点树中的Timer节点,在右侧的检视面板中可以看到它的各种属性。第一个属性暂时忽略,它是设置要在何时更新计时器。
Wait Time(等待时间)是我们要关注的。它用于控制计时器多久报告一次,也就是我们需要的“隔多久”。我们把它修改得稍微小一点比如0.1,这样几帧的动画看起来会连贯一些。
One Shot(一次性)用于控制在等待时间结束时计时器是停止还是重新计时。我们需要隔一定时间调整动画帧索引,所以我们需要重新计时而不是一次性的。
Autostart顾名思义是自动启动。由于我们在需要播放动画时才启动所以这里不需要勾选。
添加引用计时器的onready变量以便在后续代码中使用。现在我们准备在ready方法中对计时器进行一些设置。
还有一个问题,我们有计时器了,也设置了时间间隔。但是我们怎么在“计时器到点时执行代码更新索引”呢?
在上一篇文章中我提到函数也是对象,并且可以通过变量引用。我们马上就要了解到它的实际用途。
Timer节点有一个“属性”叫timeout(这个一般翻译叫“超时”,但实际上叫“时间到”比较好理解)。文档中提到“在计时器到0时发出”。
实际上这是一个信号(signal)。在某种条件下各种节点会发出各种信号告诉各个节点发生了某种事件。当然,场景中必然存在很多节点,一个节点只会对某些特定的事件(信号)感兴趣。因此我们需要告诉信号,“我对这个事情很感兴趣,请在发出信号时告诉我”。
信号有一个connect(连接)方法,信号发出时,它会告诉所有和它连接的东西。那么什么东西才能和信号连接呢。
connect方法的必填参数的类型是Callable。这就是我说的“表示函数的类型”。也就是说我们要在这里传入一个函数。信号发出时,所有连接到信号的函数都会被调用,我们就可以得知事件的发生。
连接信号时,需要注意函数签名(参数列表和返回类型)必须和信号匹配。例如计时器的timeout信号在文档中是这样写的:
这个括号就表示它没有参数。实际上也没有返回值。因为和信号连接的函数都是通过信号调用的,它根本不关心你返回什么,它只负责告诉你什么时候调用。
以另一个信号为例,这个信号会在子节点进入场景树时发出。它就有一个代表这个子节点的node参数:
因此我们连接到信号的函数就可以使用这些信号交给我们的参数,并按需进行进一步的处理。
现在编写一个方法,用于处理计时器发出的到点信号。比如叫它timer_timeout,我们就这样把它连接到计时器上:
注意不要加括号!编辑器的自动补全会为你加上括号,但是这里不需要。因为加上括号的意思是我们要调用这个函数,不加括号把函数名给它才叫“以函数为参数传递”。
为了测试一下有没有正常工作,我们可以在这个函数中写个print,然后勾选自动启动(也可以在ready中调用timer的start方法启动),启动游戏检查一下!
这里顺带一提,如果要连接到信号的函数比较简单甚至是只会在这里使用的“一次性函数”,可以像这样写:
这种简单的、没有名字的函数写法叫lambda(λ)。直观上来看它和一般函数定义的区别在于它没有名字(有些编程语言中也称之匿名函数,但是在某些语言中匿名函数和lambda有微妙的区别),并且可以把冒号和函数主体(很多时候只有一句)写到一行。
另外,我们也可以用编辑器帮我们连接信号。选中某个节点时,右侧的检视面板旁边的Node标签页中默认显示的就是节点的信号。可以折叠的行实际上展示了下列的信号来自哪个类型的定义。节点的基类中定义的信号也会显示在这里。
双击信号名称就会弹出对话框。首先要选择连接到哪个节点的脚本,默认情况下一般就是打开的脚本。下面的Receiver Method(接收者方法)用于选择要连接哪个方法。默认是以_on_节点名_信号名为名称新建一个方法。右侧的Pick(选择)按钮可以选择一个方法签名兼容的现有方法。然后点击connect就连上了。这里和我们写代码调用connect方法的作用是相同的。
信号也可以自行定义。它的定义方式和函数类似,只不过func改成了signal,并且不需要定义函数主体:
发出信号时使用信号的emit方法,并传入适当的参数。这样所有连接到这个信号的方法就会被调用了。
目前暂不需要自定义信号,我们会在后续有必要时细说。
此外,C#在语法层面有自己的事件机制,用C#编写Godot脚本时除了可以用C#的事件/委托之外,也可以利用Godot提供的功能将C#事件作为Godot信号供GDScript使用。
至于Godot的事件系统为什么要叫信号,合理推测是根据Qt的信号机制来的。创始人早年应当有Qt开发经历。
回到主线。我们知道如何定时完成工作,接下来就让动画播起来。我们定义一个变量用于记录当前动画播到哪里了:
我们首先定义一个局部变量用于引用当前在播放的那些帧。然后根据状态来给它赋值。由于目前只设置了跑动的动画帧所以其他的暂时不管。
由于我们只给跑动设置了那些帧,所以这里在match之后要判断它是不是为空。如果没有可以播的帧我们就直接返回不管了。
此外还要注意的是,由于给current_frames变量标注了类型,我们就没法给它初始化为null。数组的默认值是空数组([])。所以这里用is_empty方法来判断它是否为空,而不是用null来检查。
随后给帧索引加一,如果发现超出了当前动画的长度(注意索引从0开始因此这里是等于其size作为条件)就让它回到0。
随后我们编写一个play_animation方法,根据当前状态和帧索引播放。这个方法需要在process方法中,调用处理输入的方法之后调用:
状态为RUN时,我们根据当前帧索引获得那一帧的纹理然后设置。
按照同样的套路,你可以给角色添加更多的动画。但是你会说,哇,就播个动画也太麻烦了。这才一个动画呢,就搞了这么多事情!
关于动画,上面的做法是最笨的办法——当然层层面纱之后什么东西都是“笨”的。
要播放帧动画的sprite有一个更合适的节点那就是AnimatedSprite2D。这个节点自带播放帧动画的相关功能。
停,先别急着一边骂我一边新建场景添加节点。我们可以直接在Sprite2D节点上右键选择Change Type(更改类型)把它改成AnimatedSprite2D就好。
由于进行了类型标注,所以之前的sprite变量的类型不兼容了。需要把它改成AnimatedSprite2D。
由于AnimatedSprite2D的存在,我们也不需要Timer和相关计时代码了!删掉!
我们在开发过程中可能会不止一次修改场景树的结构,每当结构发生改变,通过节点路径引用节点的变量就会在运行时报错。但是对于一些有特殊性的节点,比如我们这里的sprite,我们可以给它取一个专用的名字比如(Sprite),然后通过右键菜单标记:
这样一来,在场景变得复杂时,我们在代码中可以直接以下述形式直接找到它而不需要用复杂且写死的路径来引用它:
$改成了%,这表示我们在使用Unique Name引用节点。
我们接下来配置一下AnimatedSprite2D节点的相关属性并修改代码。
可以看到Sprite节点旁边有个警告标志,它说我们没有设置它的Frames(帧)。我们就给它设置吧。视线来到右边的检视面板:
展开Animation部分,新建Sprite Frames。点击一下框中新出现的SpriteFrames,下方的SpriteFrames面板自动打开:
default所在的列表是所有动画的列表。现在只有个default,为了和玩家角色的状态对应,我们把它改成idle。拖入player/idle文件夹中的帧到右侧的窗口中即可设置该动画的帧:
实际上我们现在切换到2D视图然后点击SpriteFrames面板上方的播放按钮就可以预览动画!
现在给它添加跑动的动画!点击这个按钮新建动画,取名为run:
接下来添加帧。这里用另一种方式来添加。有时候你的素材可能是一系列帧构成的单张图片,这种图片叫spritesheet(sheet是表的意思)。也可以用spritesheet来添加动画。点击这个按钮:
这个素材包中作者也包含了spritesheet的版本,所以选中这个文件:
我们需要指定一些参数让它知道如何切分这几帧。看到右侧的输入框。
Horizontal和Vertical分别代表这个表有几行几列。显然这里是1行6列。所以Horizon改成6,Vertical改成1。由于这个spritesheet比较简单且规整,实际上这里两个参数设置好之后切分就算完成了:
如果spritesheet不够规整,我们可能就还需要调整右侧的各项参数比如按尺寸来设置切分。
然后按下Select All(选中全部)!这样才算选择了我们要添加哪些帧!如果spritesheet很大,可能我们只需要其中一部分的sprite,所以我们是可以手动选择需要的部分的。
使用AnimateSprite2D时我们就不能再直接设置texture属性进行动画切换了,这里全部交给这个节点自己的功能,所以这些代码全部都要修改。那些用于设置不同状态的纹理的export变量也不需要了!保存帧索引的变量也不需要了!
接下来只需要修改play_animation方法的代码即可。我们这里需要用AnimatedSprite2D的play方法并传入动画名称(SpriteFrames面板中我们给动画取的那些名字)即可播放对应动画! 运行游戏试一下!一切照常!并且我们还有了闲置动画!左右翻转的功能也依旧照常工作!
你还可以在SpriteFrames面板中编辑播放速度,注意这个面板要选中AnimatedSprite2D节点(你可能已经跟我一样把它改名成Sprite了)才会显示内容:
现在默认是5FPS。左侧的循环图标表示已启用自动循环播放动画。
实际上整个移动的实现又更方便、更地道的做法。我们下一篇文章再进行讲解!
没错,我又骗你用了笨办法。但是不管怎么说,在这篇文章中,我们学到了不少以后会用到的东西!
评论区
共 22 条评论热门最新