在这篇文章中我们继续完善之前还没做完的角色。在上一篇文章中我们已经实现了水平移动和帧动画的基础设施。
碰撞
碰撞(collision)是游戏中颇为常见的事件之一。即使游戏玩法和物理没有直接关系,很多时候也需要感知碰撞的发生。以平台跳跃游戏为例,我们很可能需要知道玩家角色和墙壁、地板、敌人发生了碰撞。
现在我们的场景中除了玩家角色什么也没有。我们先来构造一个会挡住玩家去路的柱子。知道怎么和墙壁碰撞了,构造一个供玩家站立的平台也就简单了。
新建一个场景比如叫它Platform。简单给它一个Sprite2D。在没有合适的素材时,其实可以新建一个PlaceholderTexture占位。默认是粉色的。当然你也可以在那个素材包中找一个喜欢的先放里面。
我们可以点击Texture中的PlaceholderTexture2D来编辑它的属性,给它一个大一点的尺寸,比如30x50。要注意到此时Sprites2D的原点在这个纹理的中心位置。为了方便对齐,我们直接调整Offset(偏移)属性,将y调整为-25。这样原点就到了底部。由于我们调整的是它的偏移,因此Sprite2D在场景中的位置依然保持为0。
然后我们把它放到主场景中,位于角色的右边。由于目前我们没有一个真正的地面,所以看一下玩家角色的位置然后直接设置平台的位置。
当然现在角色走过去会直接穿过这个柱子。要实现碰撞,你完全可以自己用数学方法去求解。但是这个需求太常见,游戏引擎一般都会内置的。在Godot要让节点“感知”碰撞,“至少”需要给它一个Area2D节点。按文档所说,它定义了一个2D空间,可以用来检查和其他CollisionObject2D(2D碰撞物体)发生的碰撞。Area2D本身也是派生自CollisionObject2D,也就是说我们只要有两个有Area2D的场景,就可以让它们进行互动了。
给柱子的场景添加Area2D之后,我们会看到一个警告标识。它说这个Area2D没有形状,也就没有任何碰撞会被检测到。Area2D本身并没有一个具体的形状,“形状”需要一个专门的定义来表示。这个节点就叫CollisionShape2D(2D碰撞形状)。
选中Area2D节点,然后给它添加一个CollisionShape2D作为子节点。添加之后,这个CollisionShape2D又有一个感叹号,说还是没有形状。我们需要给它的Shape(形状)属性给值才行。
这里有多种选择。它们代表了不同形状的Shape资源。当然你会说我们的狐狸是“狐狸形状”的该选什么。实际上定义碰撞形状的节点除了CollisionShape还有个CollisionPolygon(碰撞多边形),这个节点允许你通过编辑器自行创建碰撞形状。但实际上碰撞检测这个东西往往不需要完全精确的形状——因为很多时候没必要,精确的多边形也会增加消耗。我们只需要一个大概的形状把我们的Sprite围住就可以了。因此选择矩形(RectangleShape2D)。
这里顺便提一下,可以看到很多节点的某些属性的输入处都是这样的,比如上面的CollisionShape2D和Sprite2D:
这样的样式表示这个属性是“资源”类型的。资源是可以复用的。我们前面都是按右边的箭头然后选择一种类型新建。如果需要复用,我们可以直接在文件系统中创建各种资源,然后直接拖到这种输入框处。
比如在文件系统任意位置右键,Created New(新建)选项中除了文件夹、场景、脚本之外还有个Resource(资源)选项:
点击它就可以看到一个对话框。这里显示的是所有继承了Resource类的资源类型,比如我们可以查找到RectangleShape2D。这样创建了资源之后它就会出现在文件系统中,可以到处拷贝到处放,也可以直接给需要资源的节点属性。资源在加载后是大家共享的,多个需要同一资源的节点不会出现多份拷贝,所以这就是为什么说它可以复用。
创建完形状之后,场景中就会出现一个框,我们用框上的点调整大小让它覆盖住这个占位用的纹理。也可以点击Shape槽中的值直接调整大小。
现在玩家还是会穿过场景,因为我们玩家的角色还没有碰撞形状。所以按照同样的办法给玩家的场景添加碰撞。
感知碰撞
现在我们准备好了感知碰撞的基础设施,但是我们还是不知道“何时”发生了碰撞。这种情况就是在暗示我们要检查一下有没有相应的的信号可以接收。
答案是肯定的。Area2D中有一对信号叫area_entered和area_exited。顾名思义是在其他Area2D进入和离开自己找个Area2D时发出。在玩家的脚本中连接entered信号(如果忘记了怎么操作请看上一篇文章)。在响应这个信号的方法中随便写个print,运行游戏,玩家进入这个平台时就会在输出面板中看到相应内容。
当然目前我们检测到了碰撞发生但是玩家并没有被柱子挡住。自然,我们可以编写更多的代码来让玩家在发生碰撞时停止运动,比如在area_entered里直接让角色停下。
但实际上效果并不好。很有你可能你的角色会在进入柱子之后(想想entered的意思)才停下。
专业的角色节点
碰撞相关功能会和引擎的物理系统进行交互。但是我们之前移动的实现实际上是直接修改节点的位置而没有和物理系统发生交互。因此在处理碰撞时显得有点别扭。
玩家角色在物理系统中有一定特殊性。根据游戏类型和设计的不同,它可能需要受重力控制,可能需要和其他物体发生碰撞,但是很有可能不需要物理系统来控制它的移动——这往往由玩家控制,并且受到各种游戏机制的影响。
在Godot中有一个叫做CharacterBody2D的节点(3D场景中有对应的3D版节点),图标是一个貌似在跑的小人。它就是为了方便我们实现角色相关的功能,它能够检测碰撞,但是其运动可以不受物理系统影响。
这次我们可能真的需要对代表玩家的场景进行大改。你可以直接修改,也可以把原来的场景复制一份并改名。如果你选择留一份原来的版本,那么脚本也要复制并改名,然后把这个用作备份的场景的脚本改成复制后的脚本。
我们可以直接把根节点改成CharacterBody2D,因为我们的脚本在根节点上,很多代码也是从根节点的角度来说的。
CharacterBody2D和Area2D一样是CollisionObject的子类,它也需要一个CollisionShape。修改完根节点类型之后它也会提示需要一个形状。这里我们不再需要Area2D,所以直接把它的CollisionShape移到外面,让它成为CharacterBody2D的直接子节点即可:
此外,虽然目前直接运行游戏也不会报错,但是首先要修改一个地方。之前的脚本根节点为Node2D,所以脚本最上方默认生成的是extends Node2D。由于CharacterBody2D毫无疑问也是Node2D的子类,所以代码没有问题。不过为了使用CharacterBody2D的各种功能,我们还是需要把它改成extends CharacterBody2D。
此外由于不再使用Area2D,相应的和信号连接的方法也要删掉。
当然现在还是没有和墙壁发生碰撞。
Area和Body的区别
Area和Body系列节点都可以检测碰撞。但是Area是一个抽象的空间,而Body往往是一个有具体形状的物体(尽管它的具体形状也是由CollisionShape决定)。Body在碰到Body时不会相互重叠,如果设置得当运动的一方碰到静止的一方会停止移动。Area的用法可以想象一下地板上机关,玩家可以踩到地板上(而不是碰到地板边缘就停下来了),在代码中可以连接到Area的信号来感知有玩家站了上来。
目前主场景中的这个柱子一样的东西显然是有实体的,我们应该考虑把它做成一个Body来和玩家角色碰撞。由于目前这个柱子不会动,所以应该考虑用StaticBody2D来表示它。StaticBody2D顾名思义,是不会受外力影响而移动的静止物体。
可以删去之前给它的Area2D重新添加StaticBody2D,也可以直接把Area2D的类型改成StaticBody2D。当然这样做的话最好给这个节点改个名字。StaticBody和Area都需要一个CollisionShape,所以直接改类型就不需要重新添加CollisionShape了。
移动CharacterBody2D
现在我们还需要修改移动代码让物理系统明白发生了什么。使用了CharacterBody2D之后就不建议再继续使用修改position来实现移动的方法了。
CharacterBody2D主要有两个用来移动的方法,一个叫move_and_slide,一个叫move_and_collide(这个方法实际上来自它的基类PhysicsBody2D)。从名字上我们可以了解到,前者在碰到障碍物时(根据运动速度的情况)会滑走,而后者发生碰撞后会停止移动。
说白了,move_and_slide更加简单粗暴。它依靠的是CharacterBody2D的velocity属性来移动。如果发生了碰撞返回值就为true。发生碰撞和会根据与之碰撞的物体的情况来修改自身的速度。
而move_and_collide发生碰撞之后会停下,除非你让它再次运动起来。它需要你给他一个motion(运动)参数来移动物体。它的返回值是一个更复杂的表示碰撞信息的KinematicsCollision2D对象。你可以根据碰撞信息和设计需要让它以你想要的速度继续移动,也可以就这样让它停下。
就目前来说,我们可以简单地用move_and_slide。
稍微了解一下Vector2
我们接下来会不断地使用Vectors2类型的值——实际上我们在前面的很多地方都已经用到了Vector2类型。position就是Vector2类型,它有x和y两个最主要的属性。前面提到的velocity属性也是Vector2
Vector是向量的意思。2指的是二维向量,也就是说它有两个分量(component)。向量在一些领域中表示的是一种既有方向也有长度的量。向量和矩阵(matrix)是线性代数中的两个核心概念。
但是在实际的开发工作中,向量很多时候并不单单只表示“有方向的量”——尽管它们可能有几何含义,很多时候会被用来表示“各种有几个分量的量”。例如position是位置,它本身表示的是一个点,本身没有方向含义,但是仍然用Vector2表示。因为刚好我们常用的坐标系是笛卡尔坐标系,二维坐标系中的向量和点都可以用二元组(两个数构成的序列)表示,所以用Vector2来表示一个点也是可以的。此外Godot中的很多属性比如Size(大小)都和”方向“没有直接关系,但都用Vector2表示。在很多时候颜色也会用三/四维向量表示。所以当你看到向量类型用来表示一些看似和方向无关的数据时也不用惊讶。
“速度”(speed)和“速度”(velocity)
关于前面提到CharacterBody2D的velocity属性,在日常会话中,speed和velocity我们都说“速度”。不过这两个概念虽然相互关联但是也有区别。回忆一下中学上物理课时老师说的“速度”和“速率”之间的区别。speed就是速率,它是一个标量(一个数字类型的值);velocity是速度,是一个向量(Vector2)。
此外了解一下单位向量的含义。单位向量表示的是”长度为1的向量“。注意单位向量不是“各分量均为1”的向量,如(1, 1)。回忆一下,用二元组(坐标)表示的向量的长度实际上是各分量平方之和再开方。画图时向量一般是一条线加上箭头来表示。向量(1, 1)的长度就是点(1, 1)和原点之间的距离。也就是说是求点(1, 1)向坐标轴作垂线,这条垂线和坐标轴的交点、点(1, 1)、原点构成一个直角三角形,其斜边刚好是向量(1, 1)(对应的一条线段),这条线段的长度就是向量(1, 1)的长度。怎么求它不言自明,就是用勾股定理求直角三角形的斜边。
有些时候,我们会把单位向量认为是”只表示方向的向量“。向量可以和标量(单个数字)做乘法。在计算上只向量乘标量的结果也是向量,它的分量就是把之前的向量的各分量和这个标量乘起来。得到的新向量方向不变,长度变为这个标量倍。
在GDScript中我们直接用*运算符就可以把Vector2和数字相乘了:
移动
现在我们修改handle_input中的代码。此前我们直接修改position,现在我们修改velocity的值。Godot的二维坐标系原点在左上角,右侧和下侧为正方向。因此向右移动时velocity设为(1, 0),向左为其反方向(-1, 0):
Vector2是一个内置的类。我们直接用Vector2函数而不是Vector.new这种形式来构造向量(毕竟Vector2没暴露new这个方法)。两个参数的版本就是二维向量的两个分量,如果不填参数就会构造一个0向量——和常量Vector2.ZERO等价。不过提醒一句由于内置类型的特殊性,向量类型的值在变量之间相互赋值是会发生向量本身的拷贝,所以不用担心修改向量影响了其他引用同一个向量的变量。
此外如果怕上下左右分不清,可以用Vector2类型几个常量。例如LEFT就等于(-1, 0),RIGHT、UP、DOWN同理。后面我可能会更多地用这些常量。
当然这里的修改很简单,就是把修改position的地方改成了修改velocity。为了表达”停下“,就把速度设为零向量。由于不再需要在这里根据速度修改位置,所以delta参数也不需要了。
单单是这样我们还无法移动。我们还需要调用move_and_slide方法才行。不过要注意在哪里调用这个方法。由于我们现在要和物理系统互动,因此涉及物理的代码应当放到另一个特殊方法_physics_process中来。从名字中可以看出它和_process有一定的相似之处。_process的调用频率和帧率有关,而physics和物理系统的更新速度有关。物理系统会“尽量”以一个恒定频率进行更新,这个频率来自于项目设置中的General/Physics/Common/Physics Ticks Per Second。默认为60。你可以在_physics_process中直接print一下delta。它的值很可能在绝大部分时候都在0.01666666666667左右。
这样一来核心代码大概就变成了这样:
现在水平移动就算完成了。也能够和障碍物发生碰撞了!
重力
现在还不能跳跃。跳跃和水平移动有一个最大的不同,那就是我们需要在跳起来之后受重力影响又掉回来。
现实生活中人跳起来为什么会掉下来?那是因为重力、地球上重力加速度的存在。我们跳起来之后,和我们速度方向大致相反的重力会把我们的速度拉回来。
游戏里呢,道理很简单,我们只需要”时刻根据重力加速度修改CharacterBody的速度即可“。
我们定义一个重力加速g。当然这个g你可以根据需要设置成任何你需要的值,比如你想做一个在低重力环境下的游戏。
Godot的文档中提到可以选择获取设置中的重力加速度值,以便于和RigidBody2D的重力加速度保持一致。RigidBody(刚体)是另一种body,它和CharacterBody不一样,一般情况下它们的运动完全受物理系统控制。像这样获得设置中的重力加速度:
这个路径形式的字符串就是设置中的项目路径,你可以试着找到对应的设置项目。这个默认值是980。
现在如何让重力影响角色的速度呢?我们实际上只需要在_physics_process中修改速度的y值就好了,因为重力总是影响垂直方向上的速度:
当然这里有两个问题值得思考一下。首先,重力加速度也应该是向量,但是我们用的是一个float来表示。当然这就是为了方便没啥说的,因为重力加速度我们默认它总是方向向下的。用它去修改速度也就只考虑y分量就好了。
另一个问题是,为什么这里要乘delta?为什么计算速度的时候没有乘delta?为什么之前修改position的时候要乘delta?一方面来说,`_physics_process`也不一定100%以恒定频率调用,乘上它可以做到和调用频率无关。另一方面乘上delta更符合加速度的定义。
我们以一个独特的角度来思考这个问题。position表示位置,假设它的单位是米(m)。速度的单位应该是米每秒(m/s)。delta就是一小段经过的时间,它的单位就是秒(s)。因此计算position时,速度(m/s)要乘上时间(s)才能得到m。加速度的单位是m/s²,乘上时间s后,单位是m/s,是一个速度,也就是delta这一小段时间内速度的变化量,这样我们才能把它加到速度上。
当然这都是理论,实际上你修改速度时不乘delta可能也没太大问题。
不过启动游戏你会发现还是没有下落。这是因为handle_input
中还有一处代码需要修改。我们之前在不进行操作时默认将速度设为了ZERO。不过这样一来不操作时重力加速度的影响就被抹掉了。因此我们这里只把水平方向上的速度设为0就好了。
启动游戏,角色应该会直接往下掉。
地板
接下来建一个代表地板的平台。目前来说我们也不用重复新建一个和Platform差不多的场景了。我们先复制一个Platform在主场景中,调整它的位置。但是它的大小不太像地板。我们可以修改一下。当然由于它是Platform场景的实例,所以我们看不到它内部的各个节点也没法编辑。这里需要用到一个菜单项叫Make Local(令其为本地<节点树>)。它可以直接展开场景,这样我们就可以编辑它而不影响原本的场景本身了。
但是要注意的是目前没法在检视面板中直接修改占位纹理和CollisionShape的尺寸。如果试图在场景中直接调整这个复制出来的东西的CollisionShape,你会发现原本的柱子的CollisionShape也跟着变了!你不是说不会影响原本的场景吗?
没错,但是我提到过CollisionShape和Texture一样都是资源(Resource)它们是可以被共享的。此时你点击复制出来的CollisionShape的Shape属性中的值,在下面的Resource栏中的Path,你会看到它的路径指向了Platform场景。
如果你也尝试了Make Local,可以直接新建这些资源再进行调整。如果想避免这种问题(或者说偷下懒),复制了Platform后可以直接修改Scale来调整尺寸。由于目前这个用的是占位的纹理,所以随便缩放都无所谓。修改Scale属性时,你可能会发现x和y会一起修改。此时只要点一下锁链图标(锁定/解锁分量比例)就可以解除。
当然我只是演示一下Make Local的作用,最终我还是偷懒直接复制然后缩放了。
地板放在角色下方有一点距离的地方,这样在启动游戏时就可以看到它落到了地板上。
跳跃
增加操作映射并在处理输入的方法中增加代码。同时我们需要在State中增加一个JUMP状态表明正在跳跃过程中。这里不赘述。动画暂时不管。
跳跃和水平移动类似,我们在要跳跃时给它一个向上的速度即可。不过跳跃的初速度大小应该更大,所以我们单独export一个jump_speed变量。接下来就是在handle_input中修改velocity即可:
现在你会发现其实如果不断按跳跃它会连着跳。我们需要限制这种行为。比如如果按下跳的时候发现正在跳那就不跳了:
但是这样还是会跳!为什么呢,这是因为跳了之后由于没有操作,状态又回到了IDLE。所以再次按下跳还是会跳。因此,我们需要考虑给IDLE状态有一个更严格的定义。比如我们要在角色没有在空中且没有操作的时候才能被判定为IDLE状态。要判断一个CharacterBody2D是否在空中,可以用它的一个表示相反意思的方法is_on_floor(是否在地板上):
现在跳起来再按跳就不会继续跳了!当然还是那句话,你的游戏你可以给它加上不一样的跳。
不过还有个问题,我们现在在移动中按下跳是不会跳的。因为我们处理了水平方向的输入之后就直接return了。好吧,你说我把前两个return删掉——好吧,这下直接动不了了。因为这样只要不跳跃就会直接进入是否在地板上的判断,然后水平速度又被设为0。
好吧你说加上一个条件,那就是在地板上且水平速度为0时才IDLE:
现在一开始移动就停不下来了!因为我们刚松开按键时速度还没为0,这个时候这个判断的条件为假,因此永远不可能停下来。
我们换个思路。现在的代码是把可能会按下的按键挨个走一遍再觉得我们可能没按键要停下来。何不一开始就判断一下是不是有哪个键按了呢?如果没按我们就停下来,否则再进一步处理。
Input中确实有个is_anything_pressed方法。如果啥都没按(这个方法的字面意思是“是不是按了啥”),那就试着停下来:
现在可以跳,可以走,跳了还可以左右移动。但是!还有个bug,如果跳了之后按下方向键,再按跳,它还是会一直跳!
依然分析原因。跳了之后,按下左右,状态为RUN。由于return被删掉,还会继续作JUMP的判断,如果按下了左右并且同时按下了跳,那么此时由于状态不是JUMP,看起来还是会一直跳!这里就不再用JUMP判断了,依然用is_on_floor来判断是否在空中。问题基本上就解决了!
当然这个方法有个问题就是,它失去了在下落过程中跳一次的机会。解决也很简单,我们用速度的y分量来判断当前是在起跳还是下落即可!
看看起来条件有点复杂。稍微分析一下。首先确定角色在空中,同时还要满足右侧的条件。要么状态是JUMP(按键起跳了),要么垂直速度向上(我们没准备二段跳)。此时我们就不能接着跳了。
现在,自由落体可以跳一次,但是我们又有bug了!按住方向键时又可以一直跳了!
这个问题好说,我们在水平移动的部分如果发现在空中我们就不设置状态为RUN就好啦。这样我们在后面配置动画时也是必要的!
向量加法
最后补充一点数学知识。在前面的代码中我们实际上简化了很多运算。我们直接在velocity的分量上加减。实际上这些运算本质应该是向量之间的加减,只不过我们刚好需要控制水平和垂直方向上的速度,这些改变量的两个分量上总是有一个为0。
两个向量可以做加法。在计算上就是把各分量对应相加,得到一个新的向量。在几何上,它满足三角形定律。形象得说相加时,把一个向量整个移动起来,让尾巴在另一个向量的尖尖上。然后让一个向量的尾巴指向另一个向量的尖尖,就得到了两个向量的和,比如(1, 1) + (0, 2):
在GDScript中我们有:
向量减法也存在,但是减法无非就是加上一个反向的向量。负号同样可以应用到向量上,结果是一个各分量均为原来相反数的的向量,在几何上就是一个方向和原来相反的向量,比如(1, 1) - (0, 2)即(1, 1) + (0, -2),黑色向量即为结果:
同样,也可以视作是“减数(向量)的尖尖往被减数(向量)尖尖画的向量”。
可以看到,同一个向量在笛卡尔坐标系中画出来实际上有无数种画法。但是它们都是表示同一个向量,它们在几何上都是平行的,长度也一样——那就是同一个向量。
两个点坐标相减就可以得到一个从减数指向被减数的向量,试试看!
GDScript中不言自明,用减号就可以了。
动画
最后完成跳跃的动画。跳跃的动画实际上有两个不同的情况,一个是起跳,一个是下落。这个很简单,依靠速度的y分量正负即可区分。给玩家的Sprite加上jump和fall两个动画,这两个动画要用到的素材在player/jump里面,实际上也就是两张图片,拖进去即可。
play_animation方法的match最后加入:
我们还有最后一个bug。现在我们主动起跳和下落时动画可以按照预期播放。但是,如果我们从平台上落下去,或者从空中落到平台上,下落的动画是不会播放的。
当然解决方案也很简单,只要在任何状态下我们发现不在地面上,我们就根据垂直方向速度方向来播放起跳和下落的方向。现在我们就不把控制起跳和落下动画的代码放到match中了,我们把它挪到前面,这样就不会受到其他状态的影响。注意如果确实在空中那么我们播放动画之后就直接返回,因为我们目前在空中无论怎么操作都只有可能播放这两种动画。
太棒了!现在看起来真的是一个像模像样的玩家角色了!当然这个过程中有些行为可能并不是你想要的,你完全可以根据自己的想法来改进代码!