目录
太空陨石
现在,你应该对在Godot中工作越来越得心应手了;添加节点、创建脚本、在Inspector中修改属性等等。随着你在本书中的进步,你将不会被迫一次又一次地重温基础知识。如果你发现自己被卡住了,或者觉得自己不太记得某件事情是怎么做的,可以随时跳回之前的项目,在那里有更详细的解释。当你重复使用Godot中比较常见的操作时,它们会开始感觉越来越熟悉。同时,每一章都会向你介绍更多的节点和技术,以扩展你对Godot功能的理解。
在接下来的这个项目中,你将制作一款类似于街机经典《小行星》的太空射击游戏。玩家将控制一艘可以旋转并向任何方向移动的飞船。目标将是避开漂浮的太空岩石,并用飞船的激光射击它们。参考下面的截图:
在本项目中,您将了解以下关键主题:
- 物理使用RigidBody2D
- 有限状态机
- 构建一个动态的、可伸缩的UI
- 音效与音乐
- 粒子效果
项目设置
创建一个新项目,并从https://github.com/ PacktPublishing/Godot-Game-Engine-Projects/releases下载项目资产。
在这个项目中,您将使用 "Input Map "设置自定义输入动作。利用这个功能,你可以定义自定义事件,并为它们分配不同的按键、鼠标事件或其他输入。这使你在设计游戏时更加灵活,因为你的代码可以写成响应跳跃输入,例如,不需要知道用户到底按了什么输入来使事件发生。这让你可以让相同的代码在不同的设备上工作,即使它们有不同的硬件。此外,由于许多游戏玩家希望能够自定义游戏的输入,这使得你也可以向用户提供该选项。
要设置本游戏的输入,请打开 "Project | Project Settings "并选择 "Input Map "标签。
你需要创建四个新的输入动作:rotate_left, rotate_right, thrust, 和 shoot。在 "Action "框中键入每个动作的名称,然后单击 "Add"。然后,对于每个动作,点击+按钮,选择要分配的输入类型。例如,要让玩家同时使用方向键和流行的WASD替代,设置将像这样:
如果你有一个游戏手柄或其他控制器连接到电脑上,你也可以用同样的方式将其输入添加到动作中。注意:现阶段我们只考虑按键输入,所以虽然你可以在这个项目中使用d-pad,但使用模拟操纵杆则需要修改项目代码。
刚体物理
在游戏开发中,你经常需要知道游戏空间中的两个物体何时相交或接触。这就是所谓的碰撞检测。当检测到碰撞时,你通常希望发生一些事情。这就是所谓的碰撞响应。
Godot提供了三种物理体,归入PhysicsBody2D对象类型:
- StaticBody2D:静态体是指不被物理引擎移动的体。它参与碰撞检测,但不会因碰撞而移动。这种类型的物体最常用于作为环境的一部分或不需要有任何动态行为的物体,如墙壁或地面。
- RigidBody2D:这是Godot中提供模拟物理的物理体。这意味着你不能直接控制一个RigidBody2D。取而代之的是,你对它施加力(重力、脉冲等),Godot内置的物理引擎就会计算出运动的结果,包括碰撞、弹跳、旋转和其他效果。
- KinematicBody2D:这种体型提供碰撞检测,但不提供物理作用。 所有运动都必须用代码实现,并且您必须自己实现任何碰撞响应。 Kinematic Body 通常用于玩家角色或其他需要街机风格的物理效果而非现实模拟效果的角色。
了解何时使用特定的物理体类型是构建游戏的重要组成部分。使用正确的节点可以简化你的开发,而试图强行使用错误的节点来完成工作可能会导致挫折和糟糕的结果。当你使用每一种类型的物体时,你会来了解它们的优点和缺点,并了解它们何时可以帮助构建你所需要的东西。
在这个项目中,你将使用RigidBody2D节点来制作玩家飞船以及太空岩石本身。你将在后面的章节中学习其他物体类型。
单个RigidBody2D节点有许多属性,您可以使用这些属性来定制它们的行为,如质量、摩擦力或反弹。这些属性可以在检查器中进行设置:
刚体也会受到世界属性的影响,这些属性可以在Project Settings下Physics | 2D中进行设置。这些设置适用于世界中的所有物体。请参考下面的截图:
在大多数情况下,你不需要修改这些设置。但是,请注意,默认情况下,重力的值为98,方向为(0,1)(向下)。如果你想改变世界重力,你可以在这里做。你还应该注意最后两个属性,Default Linear Damp和Default Angular Damp。这些属性分别控制了一个物体失去前进速度和旋转速度的速度。将它们设置为较低的值会让世界感觉没有摩擦力,而使用较大的值则会感觉你的物体在泥泞中移动。
Area2D节点也可以通过使用空间覆盖属性来影响刚体物理。然后,自定义的重力和阻尼值将被应用于任何进入该区域的物体。
由于这款游戏将在外太空进行,所以不需要重力,所以将Default Gravity设置为0,其他设置可以保持不变。
玩家飞船
玩家的飞船是游戏的核心。在这个项目中,你要写的大部分代码都是关于让飞船工作的。它将以经典的Asteroids风格进行控制,有左右旋转和向前推力。它还将检测到射击输入,让玩家发射激光并摧毁陨石。
物体设置和物理
新建一个场景,并添加一个名为Player的RigidBody2D作为根节点,并添加Sprite和CollisionShape2D子节点。将res://assets/player_ship.png图片添加到Sprite的Texture 属性中。船只图片比较大,所以将Sprite的Scale 属性设置为(0.5,0.5),Rotation 属性设置为90。
船的图像是指向上的。在Godot中,0度的旋转指向右边(沿x轴)。这意味着你需要将Sprite节点的Rotation设置为90,这样它就会与身体的方向相匹配。
在CollisionShape2D的Shape 属性中,添加一个CircleShape2D,并将其缩放,使其尽可能地覆盖图像(切记不要移动矩形尺寸的手柄):
保存场景。在进行较大规模的项目时,建议根据每个游戏对象将场景和脚本整理成文件夹。例如,如果你做了一个玩家文件夹,你可以把玩家相关的文件保存在那里。这样可以更容易找到和修改你的文件,而不是把它们一起放在一个文件夹里。虽然这个项目比较小,但随着你的项目规模和复杂程度的增加,养成这个好习惯是很有必要的。
状态机
在游戏过程中,玩家的飞船可以处于多种不同的状态。例如,当活着的时候,船是可见的,可以被玩家控制,但容易被石头击中。另一方面,当无敌时,飞船应该呈现半透明状态,并且对伤害免疫。
程序员经常处理这种情况的一种方法是在代码中添加布尔标志变量。例如,当玩家出生时,无敌标志被设置为真,或者当玩家死亡时,活着标志被设置为假。然而,这可能会导致错误和奇怪的情况,即活着和无敌标志同时被设置为真。在这种情况下,当石头砸到玩家时会发生什么?这两种状态是相互排斥的,所以不应该允许这种情况发生。
解决这个问题的一个办法是使用Finite State Machine有限状态机(FSM)。当使用有限状态机时,一个实体在给定的时间内只能处于一种状态。要设计你的有限状态机,您可以定义一些状态,以及哪些事件或动作可以导致从一种状态过渡到另一种状态。
下图概述了玩家飞船的FSM:
有四种状态,箭头指示允许进行哪些转换以及触发该转换的事件。 通过检查当前状态,你可以决定允许玩家做什么。 例如,在DEAD 状态下,不允许输入,在INVULNERABLE 状态下,不允许射击。
高级FSM的实现可能会变得相当复杂,细节超出了本书的范围(详见附录以进一步阅读)。在最纯粹的意义上,你在技术上不会创建一个真正的FSM,但对于这个项目来说,它将足以说明这个概念,并使你不会遇到布尔标志问题。
向Player节点添加一个脚本,并从创建FSM实现的框架开始:
extends RigidBody2D
enum {INIT, ALIVE, INVULNERABLE, DEAD}
var state = null
枚举(enumeration的缩写)是一种创建常量集的便捷方式。前面代码片段中的枚举语句相当于下面的代码:
const INIT = 0
const ALIVE = 1
const INVULNERABLE = 2
const DEAD = 3
你也可以为一个枚举指定一个名称,当你在一个脚本中拥有一个以上的常量集合时,这很有用。例如:
enum States {INIT, ALIVE}
var state = States.INIT
然而,在这个脚本中不需要这个,因为你将只使用一个枚举来跟踪船的状态。
接下来,创建change_state函数来处理状态转换:
func _ready():
change_state(ALIVE)
func change_state(new_state):
match new_state:
INIT:
$CollisionShape2D.disabled = true
ALIVE:
$CollisionShape2D.disabled = false
INVULNERABLE:
$CollisionShape2D.disabled = true
DEAD:
$CollisionShape2D.disabled = true
state = new_state
每当您需要更改播放器的状态时,都将调用change_state()函数并将新状态的值传递给该函数。 然后,通过使用match语句,您可以执行过渡到新状态时应执行的任何代码。 为了说明这一点,CollisionShape2D通过new_state值启用/禁用。 在_ready()中,您可以指定初始状态-当前为ALIVE状态,以便可以进行测试,但是稍后将其更改为INIT。
控制
在脚本中添加以下变量:
export (int) var engine_power
export (int) var spin_power
var thrust = Vector2()
var rotation_dir = 0
engine_power和spin_power控制飞船的加速和转向速度。推力将代表船的引擎所施加的力:滑行时可以是(0,0),也可以是启动时长度为engine_power的向量。rotation_dir将代表船转向什么方向,并施加一个扭矩或旋转力。
默认情况下,物理设置提供了一些阻尼,它可以降低物体的速度和旋转。在太空中,没有摩擦力,所以对于现实主义来说,根本不应该有任何阻尼。然而,对于街机风格的感觉,最好是当你松开键时,飞船应该停止。在Inspector中,将玩家的Linear/Damp设置为1,其Angular/Damp设置为5。
下一步是检测输入和移动飞船:
func _process(delta):
get_input()
func get_input():
thrust = Vector2()
if state in [DEAD, INIT]:
return
if Input.is_action_pressed("thrust"):
thrust = Vector2(engine_power, 0)
rotation_dir = 0
if Input.is_action_pressed("rotate_right"):
rotation_dir += 1
if Input.is_action_pressed("rotate_left"):
rotation_dir -= 1
func _physics_process(delta):
set_applied_force(thrust.rotated(rotation))
set_applied_torque(spin_power * rotation_dir)
get_input()函数捕捉按键动作,并设置飞船的推力开启或关闭,以及旋转方向(rotation_dir)为正值或负值(代表顺时针或逆时针旋转)。这个函数在_process()的每一帧都会被调用。请注意,如果状态为INIT或DEAD,get_input()将在检查按键动作之前使用return退出。
当使用物理体时,它们的运动和相关函数应该在_physics_process()中调用。在这里,你可以使用 set_applied_force() 将引擎推力应用于飞船所面对的任何方向。然后,你可以使用 set_applied_torque() 使飞船旋转。
玩这个场景,你应该可以自由地飞来飞去。
屏幕包裹
经典2D街机游戏的另一个功能是屏幕包裹。 如果播放器离开屏幕的一侧,则它们会出现在另一侧。 实际上,您可以通过传送或立即将船的位置更改为相反的一侧。 将以下内容添加到脚本顶部的类变量中
var screensize = Vector2()
并将这个添加到_ready():
screensize = get_viewport().get_visible_rect().size
稍后,游戏的主脚本将处理设置所有游戏对象的屏幕大小,但现在,这将允许你只使用玩家场景来测试屏幕包裹。
当第一次接触这个问题时,你可能会认为你可以使用身体的位置属性,如果它超过了屏幕的边界,就把它设置到对面。然而,当使用RigidBody2D时,你不能直接设置它的位置,因为那会与物理引擎计算的运动相冲突。一个常见的错误是尝试在_physics_process()中添加类似这样的内容:
func _physics_process(delta):
if position.x > screensize.x:
position.x = 0
if position.x < 0:
position.x = screensize.x
if position.y > screensize.y:
position.y = 0
if position.y < 0:
position.y = screensize.y
set_applied_force(thrust.rotated(rotation))
set_applied_torque(rotation_dir * spin_thrust)
这将失败,将玩家困在屏幕边缘(偶尔在角落出现意外故障)。 那么,为什么这行不通呢? Godot文档建议_physics_process()用于与物理相关的代码-甚至名称中也包含物理名称。 乍看之下,这应该可以正常工作。
其实,解决这个问题的正确方法不是使用_physics_process()。 To quote the RigidBody2D docs:
引用RigidBody2D的文档。
"你不应该每一帧甚至非常频繁地改变一个RigidBody2D的位置或linear_velocity。如果你需要直接影响物体的状态,使用_integrate_forces,它可以让你直接访问物理状态。"
而在_integrate_forces()的描述中:
"(它)允许你读取并安全地修改对象的模拟状态。如果你需要直接改变物体的位置或其他物理属性,请使用它来代替_physics_process。(强调是后加的)"
答案是将物理回调改为_integrate_forces(),这样你就可以访问物体的Physics2DDirectBodyState。这是一个Godot对象,包含了大量关于物体当前物理状态的有用信息。就位置而言,关键信息是物体的Transform2D。
变换是一个矩阵,代表一个或多个二维空间的变换,如平移、旋转和/或缩放。通过访问Transform2D的原点属性可以找到平移(即位置)信息。
利用这些信息,您可以通过将 _physics_process() 改为 _integrate_forces() 并改变变换的原点来实现环绕效果:
func _integrate_forces(physics_state):
set_applied_force(thrust.rotated(rotation))
set_applied_torque(spin_power * rotation_dir)
var xform = physics_state.get_transform()
if xform.origin.x > screensize.x:
xform.origin.x = 0
if xform.origin.x < 0:
xform.origin.x = screensize.x
if xform.origin.y > screensize.y:
xform.origin.y = 0
if xform.origin.y < 0:
xform.origin.y = screensize.y
physics_state.set_transform(xform)
请注意,该函数的参数名称已经从默认的:state改为physics_state。这是为了防止任何可能与已经存在的状态变量混淆的情况,该变量跟踪玩家当前被分配到的FSM状态。
再次运行场景,检查是否一切正常。确保你试着从四个方向绕来绕去。一个常见的错误是不小心翻转了一个大于或小于的符号,所以如果有一个或多个屏幕边缘有问题,请先检查。
射击
现在,是时候给你的船配备一些武器了。当按下射击动作时,应该会在船的前方产生一颗子弹,并沿直线行驶,直到退出屏幕。然后,直到过了一小段时间,枪才可以再次开火。
子弹场景
这就是子弹的节点设置:
- Area2D (命名Bullet)
- Sprite
- CollisionShape2D
- VisibilityNotifier2D
使用assets文件夹中的res://assets/laser.png作为Sprite的纹理,CapsuleShape2D作为碰撞形状。你必须将CollisionShape2D的Rotation 设置为90,这样它才能正确地适应。你还应该将Sprite缩小到一半大小((0.5, 0.5))。
在Bullet节点中添加以下脚本:
extends Area2D
export (int) var speed
var velocity = Vector2()
func start(pos, dir):
position = pos
rotation = dir
velocity = Vector2(speed, 0).rotated(dir)
func _process(delta):
position += velocity * delta
设置导出的速度属性为1000。
VisibilityNotifier2D是一个节点,它可以在一个节点变得可见/不可见时通知你(使用信号)。你可以用它来自动删除一个子弹,当它离开屏幕时。连接VisibilityNotifier2D的screen_exited信号,并添加这个:
func _on_VisibilityNotifier2D_screen_exited():
queue_free()
最后,连接子弹的body_entered信号,这样你就可以检测到子弹什么时候撞到石头。子弹不需要知道任何关于岩石的信息,只需要知道它撞到了什么东西。当你创建岩石时,你会把它们添加到一个名为rocks的组中,并给它们一个explode()方法:
func _on_Bullet_body_entered( body ):
if body.is_in_group('rocks'):
body.explode()
queue_free()
发射子弹
现在,每当玩家开火时,你需要创建子弹的实例。但是,如果你把子弹作为玩家的子节点,那么它将会随着玩家移动和旋转,而不是独立移动。相反,应该将子弹作为主场景的子代添加。一种方法是使用get_parent().add_child(),因为当游戏运行时,主场景将是玩家的父场景。然而,这意味着你不能再像以前那样单独运行玩家场景,因为get_parent()会产生一个错误。或者,如果在主场景中,你决定以不同的方式安排,让玩家成为其他节点的子节点,那么子弹就不会被添加到你期望的位置。
一般来说,编写假设固定树形布局的代码是个坏主意。尤其是如果可能的话,尽量避免使用get_parent()。一开始你可能会觉得这样想很困难,但它会使设计更加模块化,并避免一些常见的错误。
相反地,玩家将使用信号将子弹发送到主场景中。基于这种方式,玩家场景不需要知道关于主场景是如何设置的任何内容,甚至不需要知道主场景是否存在。制造子弹并将其发射出去是玩家对象的唯一职责。
为玩家添加一个Position2D节点,并将其命名为Muzzle。这将标志着枪口--子弹将产生的位置。设置它的Position 为(50,0),将其直接放置在飞船的前面。
接下来,添加一个名为GunTimer的定时器节点。这将为枪提供一个冷却时间,防止新的子弹在一段时间内发射。勾选One Shot 和Autoplay选项。
将这些新变量添加到玩家的脚本中:
signal shoot
export (PackedScene) var Bullet
export (float) var fire_rate
var can_shoot = true
将Bullet.tscn拖动到Inspector中的新Bullet 属性上,并将Fire Rate设置为0.25(该值以秒为单位)。
$GunTimer.wait_time = fire_rate
而这个到get_input():
if Input.is_action_pressed("shoot") and can_shoot:
shoot()
现在,创建shoot()函数,它将处理创建子弹:
func shoot():
if state == INVULNERABLE:
return
emit_signal("shoot", Bullet, $Muzzle.global_position, rotation)
can_shoot = false
$GunTimer.start()
当发出射击信号时,你要传递Bullet本身以及它的起始位置和方向。然后,用 can_shoot 标志禁用射击,并启动 GunTimer。为了让枪再次射击,连接 GunTimer 的超时信号:
func _on_GunTimer_timeout():
can_shoot = true
现在,制作你的Main场景。添加一个名为Main的Node和一个名为Background的Sprite。使用res://assets/space_background.png作为Texture。在场景中添加一个Player的实例。
在Main中添加一个脚本,然后连接Player节点的shoot信号,并在创建的函数中添加以下内容:
func _on_Player_shoot(bullet, pos, dir):
var b = bullet.instance()
b.start(pos, dir)
add_child(b)
播放主场景和测试,你可以飞和射击。
岩石
游戏的目标是摧毁漂浮的太空岩石,所以,现在你可以射击了,是时候增加它们了。和飞船一样,这些岩石也将是RigidBody2D,这将使它们以稳定的速度直线行驶,除非受到干扰。它们也会以一种逼真的方式相互反弹。为了让事情变得更有趣,岩石一开始会很大,当你射击它们时,会碎裂成多个小岩石。
场景设置
通过创建一个RigidBody2D开始一个新场景,命名为Rock,并使用res://assets/rock.png纹理添加一个Sprite。添加一个CollisionShape2D,但还不要给它添加形状。因为你会产生不同大小的岩石,所以需要在代码中设置碰撞形状,并调整到正确的大小。
将岩石的Bounce 属性设置为1,Linear/Damp和Angular/Damp都设置为0。
可变大小
附上一个脚本并定义成员变量:
extends RigidBody2D
var screensize = Vector2()
var size
var radius
var scale_factor = 0.2
主脚本将处理新岩石的生成,包括在关卡开始时以及在大岩石爆炸后出现的小岩石。一块大岩石的大小为3,然后分解成大小为2的岩石,以此类推。scale_factor乘以size来设置精灵的比例,碰撞半径等。你可以在以后调整它来改变每一类岩石的大小。
所有这些都将由start()方法设置:
func start(pos, vel, _size):
position = pos
size = _size
mass = 1.5 * size
$Sprite.scale = Vector2(1, 1) * scale_factor * size
radius = int($Sprite.texture.get_size().x / 2 * scale_factor * size)
var shape = CircleShape2D.new()
shape.radius = radius
$CollisionShape2D.shape = shape
linear_velocity = vel
angular_velocity = rand_range(-1.5, 1.5)
这里是你根据岩石的大小计算出正确的碰撞形状,并将其添加到CollisionShape2D中。注意,由于size已经作为类变量使用,所以你可以使用_size作为函数参数。
岩石也需要环绕屏幕,所以使用你用于玩家的相同技术:
func _integrate_forces(physics_state):
var xform = physics_state.get_transform()
if xform.origin.x > screensize.x + radius:
xform.origin.x = 0 - radius
if xform.origin.x < 0 - radius:
xform.origin.x = screensize.x + radius
if xform.origin.y > screensize.y + radius:
xform.origin.y = 0 - radius
if xform.origin.y < 0 - radius:
xform.origin.y = screensize.y + radius
physics_state.set_transform(xform)
不同之处在于,把身体的半径也算进去,会产生看起来更流畅的瞬间移动。在进入屏幕的另一侧之前,岩石会完全离开屏幕。你可能想对玩家船做同样的事情。试一下,看看哪种效果你更喜欢。
实例化
当新的岩石产生时,主场景将需要选择一个随机的开始位置。要做到这一点,你可以使用一些几何体沿着屏幕的周边随机选取一个点,但你可以利用另一个Godot节点类型。你将在屏幕边缘画一条路径,脚本将沿着路径随机选取一个位置。添加一个Path2D节点,并将其命名为RockPath。当你点击Path2D时,你会看到编辑器的顶部出现一些新的按钮:
选择中间的(Add Point),通过点击添加所示点来绘制路径。要使点对齐,请确保勾选 "Snap to grid"。这个选项在锁定按钮左边的 "Snapping Options "按钮下找到。它显示为一系列的三个垂直点。请参考下面的截图:
按照下面截图所示的顺序画出各点。点击第四个点后,点击 Close Curve按钮(5),你的路径就完成了:
现在路径已经定义好了,添加一个PathFollow2D节点作为RockPath的子节点,并将其命名为RockSpawn。这个节点的作用是当它移动时,使用它的set_offset()方法自动跟随路径。偏移量越大,它沿着路径走得越远。由于我们的路径是封闭的,所以如果偏移值大于路径的长度,它就会循环。
接下来,添加一个Node,并将其命名为Rocks。这个节点将作为一个容器来存放所有的石头。通过检查它的子节点数量,你可以知道是否还有剩余的石头。
现在,将其添加到Main.gd中:
export (PackedScene) var Rock
func _ready(): randomize()
screensize = get_viewport().get_visible_rect().size
$Player.screensize = screensize
for i in range(3):
spawn_rock(3)
脚本首先获取屏幕尺寸并将其传递给玩家。然后,它使用 spawn_rock() 生成三个大小为 3 的岩石,定义在下面的代码中。不要忘了在Inspector中把Rock.tscn拖到Rock 属性上。
func spawn_rock(size, pos=null, vel=null):
if !pos:
$RockPath/RockSpawn.set_offset(randi())
pos = $RockPath/RockSpawn.position
if !vel:
vel = Vector2(1, 0).rotated(rand_range(0, 2*PI)) * rand_range(100,150)
var r = Rock.instance()
r.screensize = screensize
r.start(pos, vel, size)
$Rocks.add_child(r)
这个函数有两个用途。当只使用一个大小参数调用时,它会沿着RockPath随机选择一个位置和一个随机的速度。然而,如果也提供了这些值,它将使用它们来代替。这将让你在爆炸的位置产生较小的岩石。
运行游戏,你应该会看到三块石头飘来飘去。然而,你的子弹不会影响它们。
爆炸的岩石
子弹正在检查岩石组中的物体,所以在岩石场景中,单击Node 选项卡并选择Groups。键入rocks并点击添加:
现在,如果你运行游戏并射击一块岩石,你将看到一个错误消息,因为子弹试图调用岩石的explode()方法,你还没有定义。这个方法需要做三件事:
- 移除岩石
- 播放爆炸动画
- 通知Main产生新的、较小的石头。
爆炸将是一个独立的场景,你可以将其添加到Rock中,之后再添加到Player中。它将包含两个节点:
- Sprite (命名Explosion)
- AnimationPlayer
对于sprite的Texture,使用res://assets/explosion.png。你会注意到这是一个精灵表--一个由64个较小的图像组成的图像,以网格模式排列。这些图像是动画的单个帧。你会发现动画经常以这种方式打包,Godot的Sprite节点支持将它们作为单个帧使用。
在检查器中,找到精灵的Animation 部分。将Vframes 和Hframes 都设置为8。这将把精灵表切成单个图像。您可以通过将Frame 属性改为0和63之间的不同值来验证这一点。确保在完成后将Frame设回0:
AnimationPlayer可以用来动画任何节点的任何属性。您将使用AnimationPlayer随时间改变帧属性。首先点击节点,你会看到底部打开动画面板,如下图所示:
点击New Animation按钮,并将其命名为爆炸。设置Length 为0.64,Step 为0.01。现在,点击Sprite节点,你会注意到,现在检查器中的每个属性旁边都有一个钥匙按钮。每次点击键,都会在当前动画中创建一个关键帧。Frame 属性旁边的钥匙按钮上还有一个+号,表示当你添加一个关键帧时,它会自动递增数值。
点击钥匙并确认您想要创建一个新的动画轨道。注意,Frame 属性已增加为1。反复点击按键,直到你到达最后一帧(63)。
点击Animation板中的Play 按钮,可以看到正在播放的动画。
添加岩石
在Rock场景中,添加一个Explosion的实例,并在start()中添加这一行。
$Explosion.scale = Vector2(0.75, 0.75) * size
这将确保爆炸的比例与岩石的大小相匹配。
在脚本顶部添加一个名为exploded的信号,然后添加explode()函数,当子弹击中岩石时将被调用。
func explode():
layers = 0
$Sprite.hide()
$Explosion/AnimationPlayer.play("explosion")
emit_signal("exploded", size, radius,position, linear_velocity)
linear_velocity = Vector2()
angular_velocity = 0
layers属性确保爆炸将绘制在屏幕上的其他精灵之上。然后,你将发送一个信号,让Main知道产生新的岩石。这个信号还需要传递必要的数据,以便新岩石具有正确的属性。
当动画播放结束后,AnimationPlayer会发出一个信号。要连接它,你需要使AnimationPlayer节点可见。右键单击实例Explosion 并选择Editable Children,然后选择AnimationPlayer并连接其animation_finished信号。确保在 "Connect to Node "部分选择 "岩石"。动画的结束意味着可以安全地删除岩石。
func _on_AnimationPlayer_animation_finished( name ):
queue_free()
现在,测试游戏,检查当你射击岩石时,你可以看到爆炸。此时,你的岩石场景应该是这样的:
Rock正在发出信号,但它需要在Main中连接。你不能使用Node选项卡来连接它,因为Rock实例是在代码中创建的。信号也可以在代码中连接。在 spawn_rock()的末尾添加这一行。
r.connect('exploded', self, '_on_Rock_exploded')
这将岩石的信号连接到Main中一个名为_on_Rock_exploded()的函数。创建该函数,每当岩石发出爆炸信号时,该函数就会被调用。
func _on_Rock_exploded(size, radius, pos, vel):
if size <= 1:
return
for offset in [-1, 1]:
var dir = (pos - $Player.position).normalized().tangent() * offset
var newpos = pos + dir * radius
var newvel = dir * vel.length() * 1.1
spawn_rock(size - 1, newpos, newvel)
在这个函数中,会创建两个新的岩石,除非刚刚被破坏的岩石是最小的尺寸。偏移循环变量将确保它们的产生和前进方向相反(也就是说,其中一个将是另一个的负值)。dir变量找到玩家和岩石之间的向量,然后使用tangent()找到与该向量垂直的方向。这就保证了新的岩石会远离玩家。
再次玩游戏,并检查是否一切正常。
UI
创建一个游戏UI可能是非常复杂的,或者至少是费时间的。精确地放置各个元素,并确保它们在不同尺寸的屏幕和设备上工作是许多程序员最不感兴趣的游戏开发部分。Godot提供了各种各样的Control 节点来协助这个过程。学习如何使用各种Control 节点将有助于减少创建游戏UI的痛苦。
对于这个游戏,你不需要很复杂的UI。游戏需要提供以下信息和交互。
- Start button
- Status message (Get Ready or Game Over)
- Score
- Lives counter
以下是你将能够创建的预览。
创建一个新场景,并添加一个名称为HUD的CanvasLayer作为其根节点。 用户界面将使用Godot的ControlLayout功能在此层上构建。
布局
Godot的控制节点包括一些专门的容器。这些节点可以相互嵌套,以创建您所需要的精确布局。例如,MarginContainer会在其内容周围自动添加padding,而HBoxContainer和VBoxContainer则分别按行或列组织其内容。
首先添加一个MarginContainer,它将保存分数和生命计数器。在Layout 菜单下,选择TTop Wide。然后,向下滚动到 "Custom Constants自定义常量 "部分,并将所有四个边距设置为20。
接下来,添加一个HBoxContainer,它将在左边放置分数计数器,在右边放置生命计数器。在这个容器下,添加一个Label(命名为ScoreLabel)和另一个HBoxContainer(命名为LivesCounter)。
将 ScoreLabel Text 设置为 0,在大小标志下,将Horizontal 设置为Fill、Expand。在Custom Fonts下,添加一个DynamicFont,就像你在第一章介绍中做的那样,使用assets文件夹中的res://assets/kenvector_future_thin.ttf,并设置大小为64。
在LivesCounter下,添加一个TextureRect并命名为L1。将res://assets/player_small.png拖入Texture 属性中,并将Stretch Mode设置为Keep Aspect Centered。确保你选择了L1节点,然后按Duplicate(Ctrl + D)两次来创建L2和L3(它们会被自动命名)。在游戏过程中,HUD将显示/隐藏这三个纹理,以指示用户还剩多少生命。
在更大、更复杂的UI中,你可以将这部分保存为自己的场景,然后嵌入到UI的其他部分。但是,此游戏的UI仅需要几块,所以把它们都组合在一个场景中就可以了。
作为HUD节点的子节点,添加一个TextureButton(命名为StartButton)、一个Label(命名为MessageLabel)和一个Timer(命名为MessageTimer)。
在res://assets文件夹中,有两个StartButton的纹理,一个是正常的(play_button.png),一个是当鼠标悬停在它上面时显示的(play_button_h.png)。将它们分别拖到Textures/Normal 和Textures/Hover属性中。在 "Layout "菜单中,选择 "Center"。
对于MessageLabel,请确保在指定布局之前先设置字体,否则它将无法正确居中。您可以使用您用于 ScoreLabel 的相同设置。设置字体后,将布局设置为Full Rect。
最后,将MessageTimer的One Shot属性设置为On ,其Wait Time设置为2。
完成后,你的UI的场景树应该是这样的。
UI函数
你已经完成了UI布局,现在我们给HUD添加一个脚本,这样你就可以添加功能了。
extends CanvasLayer
signal start_game
onready var lives_counter = [$MarginContainer/HBoxContainer/LivesCounter/L1,
$MarginContainer/HBoxContainer/LivesCounter/L2,
$MarginContainer/HBoxContainer/LivesCounter/L3]
当玩家点击StartButton时,start_game信号将被发出。lives_counter 变量是一个数组,包含三个生命计数器图像的引用。这些名称相当长,所以一定要让编辑器的自动完成器帮你填上,以避免错误。
接下来,你需要处理更新显示信息的功能。
func show_message(message):
$MessageLabel.text = message
$MessageLabel.show()
$MessageTimer.start()
func update_score(value):
$MarginContainer/MarginContainer/HBoxContainer/ScoreLabel.text = str(value)
func update_lives(value):
for item in range(3):
lives_counter[item].visible = value > item
当一个值发生变化时,将调用每个函数来更新显示。
接下来,添加一个函数来处理Game Over状态。
func game_over():
show_message("Game Over")
yield($MessageTimer, "timeout")
$StartButton.show()
现在,将StartButton的pressed 信号连接起来,使其能向Main发出信号。
func _on_StartButton_pressed():
$StartButton.hide()
emit_signal("start_game")
最后,连接MessageTimer的timeout 信号,这样就可以隐藏消息。
func _on_MessageTimer_timeout():
$MessageLabel.hide()
$MessageLabel.text = ''
Main场景代码
现在,你可以在主场景中添加一个HUD的实例。在Main.gd中添加以下变量。
var level = 0
var score = 0
var playing = false
这些将跟踪变量的数目。下面的代码将处理开始一个新的游戏。
func new_game():
for rock in $Rocks.get_children():
rock.queue_free()
level = 0
score = 0
$HUD.update_score(score)
$Player.start()
$HUD.show_message("Get Ready!")
yield($HUD/MessageTimer, "timeout")
playing = true
new_level()
首先,您需要确保删除上一个游戏中遗留的所有现有岩石并初始化变量。 不用担心player上的start()函数; 您将很快添加。
在显示 "Get Ready!"消息后,你将使用yield等待消息消失后再真正开始关卡。
func new_level():
level += 1
$HUD.show_message("Wave %s" % level)
for i in range(level):
spawn_rock(3)
每次关卡变化时,这个函数都会被调用。它宣布关卡的编号,并产生一些与之匹配的石头。注意,由于你把关卡初始化为0,所以在第一关会把它设置为1。
为了检测关卡是否已经结束,你要不断地检查Rocks节点有多少个子节点。
func _process(delta):
if playing and $Rocks.get_child_count() == 0:
new_level()
现在,你需要将HUD的start_game信号(按下Play按钮时发出)连接到new_game()函数。选择HUD,点击Node 标签,连接start_game信号。将Make Function设置为Off ,并在Method In Node字段中输入new_game。
接下来,添加以下函数来处理游戏结束时的情况。
func game_over():
playing = false
$HUD.game_over()
播放游戏,并检查按下Play按钮是否启动了游戏。请注意,玩家目前停留在INIT状态,所以你还不能到处飞--玩家不知道游戏已经开始。
Player 代码
在Player.gd中添加一个新信号和一个新变量。
signal lives_changed
var lives = 0 setget set_lives
GDScript中的setget语句允许你指定一个函数,每当给定变量的值发生变化时就会被调用。这意味着当生命值减少时,你可以发出一个信号,让HUD知道它需要更新显示。
func set_lives(value):
lives = value
emit_signal("lives_changed", lives)
新游戏开始时,Main会调用start()函数。
func start():
$Sprite.show()
self.lives = 3
change_state(ALIVE)
当使用setget时,如果你在本地(在本地脚本中)访问变量,你必须在变量名前加上self.。如果不这样做,setget函数将不会被调用。
现在,你需要将这个信号从Player 连接到HUD中的update_lives方法。在Main中,点击Player实例,在Node 标签中找到它的lives_changed信号。点击Connect,在连接窗口中,在Connect to Node下,选择HUD。对于 Method In Node,键入 update_lives。确保关闭了Make Function,然后点击Connect,如下截图所示。
游戏结束
在本部分中,您将使玩家检测到何时被岩石击中,添加无敌功能,并在玩家耗尽生命时结束游戏。
在Player中添加一个爆炸的实例,以及一个Timer节点(命名为InvulnerabilityTimer)。在Inspector中,将InvulnerabilityTimer的Wait Time设置为2,其One Shot设置为On。将其添加到Player.gd的顶部。
signal dead
这个信号会通知主场景,玩家的生命已经耗尽,游戏结束。不过在这之前,你需要更新状态机,对每个状态进行更多的操作。
func change_state(new_state):
match new_state:
INIT:
$CollisionShape2D.disabled = true
$Sprite.modulate.a = 0.5
ALIVE:
$CollisionShape2D.disabled = false
$Sprite.modulate.a = 1.0
INVULNERABLE:
$CollisionShape2D.disabled = true
$Sprite.modulate.a = 0.5
$InvulnerabilityTimer.start()
DEAD:
$CollisionShape2D.disabled = true
$Sprite.hide()
linear_velocity = Vector2()
emit_signal("dead")
state = new_state
精灵的modulate.a属性设置其alpha通道(透明度)。 设置为0.5使其半透明,而1.0则是实体。
进入INVULNERABLE状态后,启动InvulnerabilityTimer。连接其timeout 信号。
func _on_InvulnerabilityTimer_timeout():
change_state(ALIVE)
另外,像在Rock场景中那样,把Explosion动画中的animation_finished信号连接起来。
func _on_AnimationPlayer_animation_finished( name ):
$Explosion.hide()
检测物理物体之间的碰撞
当你飞来飞去的时候,玩家的飞船会从岩石上弹起,因为两个体都是RigidBody2D节点。然而,如果你想让两个刚体碰撞时发生一些事情,你需要启用contact monitoring(接触监控)。选择Player节点,在Inspector中,将Contact Monitoring设置为On。默认情况下,不会报告任何接触,所以你还必须将Contacts Reported设置为1。现在,当物体接触到另一个物体时,它将发出信号。点击 "Node "选项卡,连接body_entered信号。
func _on_Player_body_entered( body ):
if body.is_in_group('rocks'):
body.explode()
$Explosion.show()
$Explosion/AnimationPlayer.play("explosion")
self.lives -= 1
if lives <= 0:
change_state(DEAD)
else:
change_state(INVULNERABLE)
现在,进入主场景,将玩家的dead 信号连接到game_over()函数。玩游戏并尝试冲进一块石头。你的飞船应该会爆炸,变得无敌(两秒钟),并失去一条生命。检查一下,如果你被击中三次,游戏就结束了。
暂停游戏
许多游戏都需要某种暂停模式来让玩家在游玩中休息一下。在Godot中,暂停是场景树的一个功能,可以使用get_tree().paused = true来设置。当SceneTree被暂停时,会发生三件事。
- 物理线程停止运行
- _process和_physics_process不再被调用,所以这些方法中的代码不会被运行。
- _input和_input_event也不再调用
当暂停模式被触发时,正在运行的游戏中的每个节点都可以根据你的配置方式做出相应的反应。这种行为是通过节点的PPause/Mode属性来设置的,你会在Inspector列表的底部找到它。
暂停模式可以设置为三个值。INHERIT(默认值)、STOP和PROCESS。STOP表示在树暂停时节点将停止处理,而PROCESS则设置节点继续运行,忽略树的暂停状态。因为要对整个游戏中的每个节点都设置这个属性会非常繁琐,所以INHERIT让节点使用与其父节点相同的暂停模式。
打开 "Input Map "选项卡(在 "Project Settings "中),并创建一个名为 "pause "的新输入操作。选择一个你想用来切换暂停模式的键,例如,P就是一个不错的选择。
接下来,在Main.gd中添加以下函数来响应输入动作。
func _input(event):
if event.is_action_pressed('pause'):
if not playing:
return
get_tree().paused = not get_tree().paused
if get_tree().paused:
$HUD/MessageLabel.text = "Paused"
$HUD/MessageLabel.show()
else:
$HUD/MessageLabel.text = ""
$HUD/MessageLabel.hide()
如果你现在运行游戏,你就会遇到一个问题--所有节点都暂停了,包括Main。这意味着,由于它没有处理_input,所以它无法再次检测到输入来解除游戏的暂停!你需要将Main的暂停/模式设置为PROCESS。要解决这个问题,你需要将Main的Pause/Mode设置为PROCESS。现在,你有一个相反的问题:Main下面的所有节点都会继承这个设置。这对大多数节点来说都没有问题,但你需要在这三个节点上将模式设置为STOP:Player、Rocks和HUD。
敌人
太空中充满了更多的危险,而不仅仅是岩石。在本节中,你将创建一个敌人的飞船,它会定期出现并向玩家射击。
跟随路径
当敌人出现时,它应该沿着一条路径穿过屏幕。为了不让它看起来太过重复,你可以创建多条路径,并在敌人开始时随机选择一条。
创建一个新场景,并添加一个Node。将其命名为EnemyPaths并保存场景。为了绘制路径,添加一个Path2D节点。正如你前面所看到的,这个节点允许你绘制一系列连接点。当你添加节点时,会出现一个新的菜单栏。
这些按钮可以让你绘制和修改路径的点。点击带有+号的那个按钮可以增加点。点击在游戏窗口外的某处开始路径(蓝紫色的矩形),然后再点击几个点来创建一条曲线。先不要担心它的平滑。
当敌舰沿着路径前进时,碰到尖角会显得不是很平滑。要平滑曲线,点击路径工具栏上的第二个按钮(其工具提示写着Select Control Points)。现在,如果你点击并拖动曲线的任何一个点,你将添加一个控制点,允许你对线条进行角度和曲线。平滑前面的线条的结果是这样的。
向场景中添加更多Path2D节点并根据需要绘制路径。 添加圆环和曲线而不是直线将使敌人看起来更动态(并且更难击中)。 请记住,单击的第一点将是路径的起点,因此请确保将它们放置在屏幕的不同侧,以保持多样性。 这是三个示例路径:
保存场景。你会把这个添加到敌人的场景中,让它有路径可循。
敌人场景
为敌人创建一个新的场景,使用Area2D作为其根节点。添加一个Sprite并使用res://assets/enemy_saucer.png作为它的Texture。将Animation/HFrames设置为3,这样你就可以在不同颜色的船之间进行选择。
就像你之前做的那样,添加一个CollisionShape2D,并给它一个CircleShape2D,按比例覆盖精灵图像。接下来,添加一个EnemyPaths场景的实例和一个AnimationPlayer。在AnimationPlayer中,你需要两个动画:一个是让飞碟在移动时旋转,另一个是在飞碟被击中时产生闪光效果。
- Rotate animation:添加一个名为rotate的新动画,并将其Length 设置为3,将Sprite Transform/Rotation Degrees属性设置为0后添加一个关键帧,然后将播放条拖到最后,添加一个关键帧,旋转设置为360。单击 "Loop "按钮和 "Autoplay "按钮。
- Hit animation:添加第二段名为flash的动画 将其Length 设置为0.25,Step 设置为0.01。你要做的动画属性是Sprite的Modulate (在Visibility下找到)。为Modulate 添加一个关键帧来创建轨迹,然后将播放条移动到0.04,并将Modulate 颜色改为红色。再向前移动0.04,将颜色改回白色。
再重复这个过程两次,这样你一共有三次闪光。
像对其他对象做的一样,添加一个爆炸场景的实例。另外,像处理岩石一样,连接爆炸的AnimationPlayer animation_finished信号,并设置它在爆炸结束时删除敌人。
func _on_AnimationPlayer_animation_finished(anim_name):
queue_free()
接下来,添加一个名为GunTimer的Timer节点,它将控制敌人向玩家射击的频率。将其Wait Time设置为1.5,Autostart设置为On。连接它的timeout 信号,但暂时让代码内容为pass.
最后,点击Area2D和Node 选项卡,并将其添加到一个名为enemies的组中。与岩石一样,这将为你提供一种识别对象的方法,即使屏幕上同时有多个敌人。
移动敌人
将脚本附加到敌人场景中。 首先,您将创建将选择路径并沿其移动敌人的代码:
extends Area2D
signal shoot
export (PackedScene) var Bullet
export (int) var speed = 150
export (int) var health = 3
var follow
var target = null
func _ready():
$Sprite.frame = randi() % 3
var path = $EnemyPaths.get_children()[randi() % $EnemyPaths.get_child_count()]
follow = PathFollow2D.new()
path.add_child(follow)
follow.loop = false
PathFollow2D节点是指可以沿着父Path2D自动移动的节点。默认情况下,它被设置为围绕路径循环,所以你需要手动将该属性设置为false。
下一步就是沿着这条路前进。
func _process(delta):
follow.offset += speed * delta
position = follow.global_position
if follow.unit_offset > 1:
queue_free()
当偏移量大于路径总长度时,你可以检测路径的终点。然而,使用单位偏移量(unit_offset)更直接,它在路径长度上从0到1变化。
产生敌人
打开主场景,添加一个名为EnemyTimer的定时器节点。将其One Shot属性设置为On。然后,在Main.gd中,添加一个变量来引用你的敌人场景(保存脚本后将其拖入Inspector 中)。
export (PackedScene) var Enemy
在new_level()中添加以下代码:
$EnemyTimer.wait_time = rand_range(5, 10)
$EnemyTimer.start()
连接EnemyTimer timeout信号,并添加以下内容:f
func _on_EnemyTimer_timeout():
var e = Enemy.instance()
add_child(e)
e.target = $Player
e.connect('shoot', self, '_on_Player_shoot')
$EnemyTimer.wait_time = rand_range(20, 40)
$EnemyTimer.start()
每当EnemyTimer超时,这段代码就会实例化敌人。当你给敌人添加射击时,它将使用与你对玩家使用的相同过程,所以你可以重复使用相同的子弹发射函数,即_on_Player_shoot()。
玩游戏,你应该看到一个飞碟出现,将沿着你的路径之一飞行。
敌人射击和碰撞
敌人需要向玩家射击,以及被玩家或玩家的子弹击中时做出反应。
打开Bullet场景,选择 "Save Scene As "将其保存为EnemyBullet.tscn(之后,别忘了把根节点也重命名)。选择根节点,点击Clear the script 按钮,删除脚本。
您还需要通过单击 "Node "选项卡并选择 "Disconnect"来断开信号连接。
在assets文件夹中还有一个不同的纹理,你可以用它来使敌人的子弹与玩家的子弹显得不同。
脚本会和普通子弹非常一样。连接区域的body_entered信号和VisibilityNotifier2D的screen_exited信号。
extends Area2D
export (int) var speed
var velocity = Vector2()
func start(_position, _direction):
position = _position
velocity = Vector2(speed, 0).rotated(_direction)
rotation = _direction
func _process(delta):
position += velocity * delta
func _on_EnemyBullet_body_entered(body):
queue_free()
func _on_VisibilityNotifier2D_screen_exited():
queue_free()
目前,子弹不会对玩家造成任何伤害。下一节你会给玩家加一个护盾,所以你可以同时加这个。
保存场景并将其拖入敌人的Bullet 属性中。
在Enemy.gd中,添加射击功能。
func shoot():
var dir = target.global_position - global_position
dir = dir.rotated(rand_range(-0.1, 0.1)).angle()
emit_signal('shoot', Bullet, global_position, dir)
首先,你必须找到指向玩家位置的向量,然后在其中加入一点随机性,这样子弹就不会遵循完全相同的路径。
为了获得额外的挑战,你可以让敌人以脉冲方式射击,或者多次快速射击。
func shoot_pulse(n, delay):
for i in range(n):
shoot()
yield(get_tree().create_timer(delay), 'timeout')
这个函数可以创建一个给定数量的子弹,并在它们之间有延迟时间。您可以在GunTimer触发射击时使用此函数:
func _on_GunTimer_timeout():
shoot_pulse(3, 0.15)
这将会射出3颗子弹的脉冲,间隔0.15秒。很难躲避!
接下来,当敌人被玩家的一枪打中时,它需要受到伤害。它会使用你制作的动画进行闪现,当它的生命值达到0时,就会爆炸。
在Enemy.gd中添加这些函数:
func take_damage(amount):
health -= amount
$AnimationPlayer.play('flash')
if health <= 0:
explode()
yield($AnimationPlayer, 'animation_finished')
$AnimationPlayer.play('rotate')
func explode():
speed = 0
$GunTimer.stop()
$CollisionShape2D.disabled = true
$Sprite.hide()
$Explosion.show()
$Explosion/AnimationPlayer.play("explosion")
$ExplodeSound.play()
另外,连接该区域的body_entered信号,这样如果玩家撞到敌人就会爆炸:
func _on_Enemy_body_entered(body):
if body.name == 'Player':
pass
explode()
同样,你也在等着玩家的护盾给玩家加伤害,所以暂时把pass占位符留在那里。
现在,玩家的子弹只检测物理体,因为其body_entered信号被连接。然而,敌人是一个Area2D,所以它不会触发该信号。要检测敌人,你还需要连接area_entered信号。
func _on_Bullet_area_entered(area):
if area.is_in_group('enemies'):
area.take_damage(1)
queue_free()
试着再玩一次游戏,你将会和一个咄咄逼人的外星对手进行战斗! 确认所有的碰撞组合都得到了处理。另外注意,敌人的子弹可以被石头挡住--也许你可以躲在石头后面做掩护!
附加功能
游戏的结构已经完成。 您可以开始游戏,进行游戏,结束时再玩一次。 在本部分中,您将为游戏添加一些其他效果和功能,以改善游戏体验。 效果是一个广义术语,可能意味着许多不同的技术,但是在这种情况下,您将专门解决三件事:
音效和音乐:音效经常被忽视 但它是游戏设计中非常有效的一部分 好的声音能改善游戏的感觉。坏的或烦人的声音会让人产生厌烦或挫折感。你要添加一些动作感十足的背景音乐,以及游戏中几个动作的一些音效。
粒子:粒子效果是一种图像,通常是小的,通过粒子系统生成大量的动画。它们可以用来制作无数种令人印象深刻的视觉效果。Godot的粒子系统相当强大;太强大了,在这里无法完全探索,但你会学到足够的东西来开始实验它。
玩家护盾:如果你觉得游戏太难了,尤其是在较高的关卡,那里有很多石头,给玩家加个盾牌会大大增加你的生存机会。你还可以让大的石头比小的石头对盾牌的伤害更大。你还可以在HUD上做一个漂亮的显示栏来显示玩家的剩余护盾等级。
音效和音乐
在res://assets/sounds文件夹中,有几个包含不同声音的OggVorbis 格式的音频文件。默认情况下,Godot在导入时将.ogg文件设置为循环播放。在explosion.ogg、laser_blast.ogg和levelup.ogg的情况下,你不希望这些声音循环,所以你需要改变这些文件的导入设置。要做到这一点,请在FileSystem dock中选择文件,然后单击位于编辑器窗口右侧Scene 选项卡旁边的 "Import 导入 "选项卡。取消选中 "Loop 循环 "旁边的框,然后单击 "Reimport重新导入"。对三个声音中的每一个都这样做。参考下面的截图。
要播放一个声音,它需要由一个AudioStreamPlayer节点加载。在Player场景中添加两个这样的节点,将它们命名为LaserSound和EngineSound。将各自的声音拖到Inspector中每个节点的Stream 属性中。要在射击时播放声音,在Player.gd中的shoot()中添加以下行。
$LaserSound.play()
玩游戏,尝试射击。如果你觉得声音有点大,你可以调整Volume Db属性。试试值为-10。
引擎声音的工作方式有点不同。它需要在推力开启时播放,但如果你试图在get_input()函数中只播放()声音,只要你按下输入,它就会每一帧重启声音。这听起来并不好,所以你只想开始播放声音,如果它还没有播放的话。这里是get_input()函数的相关部分。
if Input.is_action_pressed("thrust"):
thrust = Vector2(engine_power, 0)
if not $EngineSound.playing:
$EngineSound.play()
else:
$EngineSound.stop()
请注意,可能会出现一个问题--如果玩家在按住推力键时死亡,发动机的声音会一直卡在上面。这可以通过在change_state()中的dead状态下添加$EngineSound.stop()来解决。
在主场景中,再添加三个AudioStreamPlayer节点。ExplodeSound,LevelupSound,和Music,在它们的Stream 属性中,放入explosion.ogg,levelup.ogg,和Funky-Gameplay_Looping.ogg。
将$ExplodeSound.play()添加为_on_Rock_exploded()的第一行,并在new_level()中添加$LevelupSound.play()。
要开始/停止音乐,在new_game()中添加$Music.play(),在game_over()中添加$Music.stop()。
敌人还需要一个ExplodeSound和一个ShootSound。你可以使用和玩家一样的爆炸声,但是有一个enemy_laser.wav声音用来射击。
粒子
玩家飞船的推力是粒子的完美运用,从引擎中制造出流光溢彩的火焰。在玩家场景中添加一个Particles2D节点,并将其命名为Exhaust。在做这部分的时候,你可能需要放大飞船的图像。
当第一次创建时,Particles2D节点有一个警告。没有指定处理粒子的材料。直到你在检查器中分配一个处理材料,粒子才会被发射出来。有两种类型的材质:ShaderMaterial和ParticlesMaterial。ShaderMaterial允许你用类似GLSL的语言编写着色器代码,而ParticlesMaterial是在Inspector中配置的。在Particles Material旁边,点击向下箭头,选择New ParticlesMaterial。
你会看到一排白点从玩家飞船的中心流下。你现在的挑战是把这些变成排气火焰。
在配置粒子的时候,有非常多的属性可以选择,尤其是在ParticlesMaterial下。在开始之前,先设置一下Particles2D的这些属性。
- Amount: 25
- Transform/Position: (-28, 0)
- Transform/Rotation: 180
- Visibility/Show Behind Parent: On
现在,点击ParticlesMaterial。在这里你会发现大部分影响粒子行为的属性。从Emission Shape开始--将其改为Box。这将显示Box Extents,应该设置为(1,5,1)。现在,粒子会在一个小区域内发射,而不是单个点。
接下来,将Spread/Spread设置为0,Gravity/Gravity设置为(0,0,0)。现在,粒子并没有下降或扩散,但它们移动得很慢。
下一个属性是Initial Velocity。将Velocity 设置为400。然后,向下滚动至Scale 并将其设置为8。
为了使大小随时间变化,你可以设置一个Scale Curve。点击 "New CurveTexture新建曲线",然后点击它。会出现一个新的标签为 "Curve "的面板。左手点代表起始比例,右手点代表结束比例。向下拖动右边的圆点,直到你的曲线看起来像这样。
现在,粒子随着时间的增长而缩小。点击 "Inspector "顶部的左箭头,回到上一部分。
最后要调整的部分是Color。为了使粒子看起来像火焰,粒子应该从明亮的橙黄色开始,并在淡出的同时转向红色。在Color Ramp属性中,点击New GradientTexture。然后,在Gradient属性中,选择New Gradient新渐变:
标有1和2的滑块选择开始和结束的颜色,而3则显示当前选择的滑块上设置的颜色。点击滑块1,然后点击3,选择橙色,再点击滑块2,设置为深红色。
现在我们可以看到粒子在做什么,它们持续的时间太长了。回到Exhaust节点,将Lifetime 改为0.1。
希望你的飞船的排气管看起来有点像火焰。如果不像,请随意调整ParticlesMaterial属性,直到你满意为止。
现在飞船的排气系统已经配置好了,需要根据玩家的输入来开启/关闭它。进入玩家脚本,在get_input()的开头添加$Exhaust.emitting = false,然后在检查推力输入的if语句下添加$Exhaust.emitting = true。然后,在检查推力输入的if语句下添加$Exhaust.emitting = true。
你也可以使用粒子在敌人身后制造一个轨迹效果。将Particles2D添加到敌人场景,并按如下所示设置属性:
- Amount: 20
- Local Coords: Off
- Texture: res://assets/corona.png
- Show Behind Parent: On
注意,你使用的效果纹理是黑色背景上的白色。这个图像需要改变它的混合模式。要做到这一点,在粒子节点上,找到Material 属性(它在CanvasItem部分)。选择New CanvasItemMaterial,在生成的材质中,将Blend Mode改为Add。
现在,像之前那样创建一个ParticlesMaterial,并使用这些设置:
- Emission Shape:
- Shape: Box
- Box Extents: (25, 25, 1)
- Spread: 25
- Gravity: (0, 0, 0)
现在,创建一个ScaleCurve,就像你为player排气管做的那样。这一次,让曲线看起来像下面这样:
尝试运行游戏,看看它是什么样子。你可以随意修改设置,直到有你喜欢的东西。
玩家护盾
在本节中,你将为玩家添加一个护盾,并在HUD中添加一个显示元素,显示当前的护盾等级。
首先,在Player.gd脚本的顶部添加以下内容。
signal shield_changed
export (int) var max_shield
export (float) var shield_regen
var shield = 0 setget set_shield
shield变量的工作原理与生命相似,每当它发生变化时,就会向HUD发出一个信号。保存脚本并在Inspector中设置max_shield为100,shield_regen为5。
接下来,添加以下函数,处理改变盾牌的值。
func set_shield(value):
if value > max_shield:
value = max_shield
shield = value
emit_signal("shield_changed", shield/max_shield)
if shield <= 0:
self.lives -= 1
另外,由于有些东西,比如重生,可能会增加护盾的值,你需要确保它不会超过最大允许值。然后,当你发送shield_changed信号时,你要传递shield/max_shield的比率。这样一来,HUD的显示就不需要知道任何关于实际值的信息,只需要知道护盾的相对状态。
在start()和set_lives()中加入这一行。
self.shield = max_shield
撞击石头会对盾牌造成伤害,更大的石头应该会造成更多的伤害。
func _on_Player_body_entered( body ):
if body.is_in_group('rocks'):
body.explode()
$Explosion.show()
$Explosion/AnimationPlayer.play("explosion")
self.shield -= body.size * 25
敌人的子弹也会造成伤害,所以对EnemyBullet.gd做这样的修改:
func _on_EnemyBullet_body_entered(body):
if body.name == 'Player':
body.shield -= 15 queue_free()
另外,碰到敌人应该会对玩家造成伤害,所以在Enemy.gd中更新一下。
func _on_Enemy_body_entered(body):
if body.name == 'Player':
body.shield -= 50 explode()
玩家脚本的最后一个补充是每一帧再生护盾。在_process()中添加这一行。
self.shield += shield_regen * delta
下一步是将显示元素添加到HUD中。你将使用TextureProgress节点,而不是在Label中显示盾牌的值。这是一个控制节点,它是 ProgressBar 的一种类型:将给定值显示为填充条的节点。TextureProgress 节点允许您为条形图的显示分配一个纹理。
在现有的HBoxContainer中,添加TextureRect和TextureProgress。将它们放在 ScoreLabel 之后,LivesCounter 之前。将TextureProgress的名称改为ShieldBar。你的节点设置应该是这样的:
将res://assets/shield_gold.png纹理拖到TextureRect的Texture属性中。 这将是一个指示条显示的图标。
ShieldBar有三个纹理属性:Under, Over, 和 Progress. Progress是将作为条的值显示的纹理。将res://assets/barHorizontal_green_mid 200.png拖入这个属性。另外两个纹理属性允许您通过设置在进度纹理下方或上方绘制图像来自定义外观。将res://assets/glassPanel_200.png拖入Over纹理属性中。
在 "Range范围 "部分,您可以设置条的数字属性。Min Value和Max Value应设置为 0 和 100,因为该条将显示护盾的百分比值,而不是其原始值。Value是控制当前显示的填充值的属性。将其改为75,可以看到条部分填充。另外,将其Horizontal 大小标志设置为Fill、Expand。
现在,你可以更新HUD脚本来控制护盾栏。在顶部添加这些变量。
onready var ShieldBar = $MarginContainer/HBoxContainer/ShieldBar
var red_bar = preload("res://assets/barHorizontal_red_mid 200.png")
var green_bar = preload("res://assets/barHorizontal_green_mid 200.png")
var yellow_bar = preload("res://assets/barHorizontal_yellow_mid 200.png")
除了绿色的条纹理,你在资产文件夹中还有红色和黄色的条纹理。这将允许你随着数值的减少而改变盾牌的颜色。以这种方式加载纹理,使它们更容易在以后的脚本中访问,当你想将适当的图像分配给TextureProgress节点时:
func update_shield(value):
ShieldBar.texture_progress = green_bar
if value < 40:
ShieldBar.texture_progress = red_bar
elif value < 70:
ShieldBar.texture_progress = yellow_bar
ShieldBar.value = value
最后,点击主场景的Player节点,将shield_changed信号连接到刚才创建的update_shield()函数。运行游戏并验证你是否能看到护盾,以及它是否在工作。你可能会想要增加或减少再生率来调整到你喜欢的速度。
总结
在本章中,您学习了如何使用RigidBody2D节点,并了解了更多关于Godot物理学的工作原理。你还实现了一个基本的有限状态机--随着你的项目越来越大,你会发现它越来越有用。你看到了容器节点如何帮助组织和保持UI节点的一致性。最后,你还添加了一些音效,并通过使用AnimationPlayer和Particles2D节点首次体验了高级视觉效果。
你还使用标准的Godot层次结构创建了一些游戏对象,例如CollisionShapes被附加到CollisionObjects上。此时,其中一些节点配置应该开始让你觉得熟悉了。
在继续前,再看一遍项目。运行它。确保你明白每个场景在做什么,并通过脚本阅读来回顾一切是如何连接在一起的。
在下一章中,你将学习运动体的知识,并使用它们来创建一个横向卷轴平台游戏。