让我们通过制作 RPG 来学习 Godot 4 — 第 12 部分:玩家射击和造成伤害
如果不能射击敌人,敌人还有什么用呢?在这一部分中,我们将创建子弹,让玩家可以朝他们面对的方向射击。然后,我们将添加一个动画,显示如果我们的子弹击中敌人,敌人会受到伤害。如果子弹击中敌人的程度足以使其生命值耗尽,他们也会死亡。稍后,我们将提高玩家的 XP,并在敌人死亡时为其添加战利品。但现在,让我们系好安全带,因为这部分会很长!
您将在本部分中学习到的内容:
· 如何使用 AnimationPlayer 节点。
· 如何使用 RayCast2D 节点。
· 如何使用调制值。
· 如何使用 Time 类。
生成的子弹
让我们首先创建玩家射击或攻击时产生的子弹。此场景将类似于我们的 Pickups 场景的结构。创建一个以 Area2D 节点为根的新场景。将此节点重命名为 Bullet 并将其保存在您的 Scenes 文件夹下。
由于没有形状,因此会出现警告消息。我们可以通过向其添加 CollisionShape2D 节点并以 RectangleShape2D 作为形状来修复此问题。
我们还需要查看我们的节点/子弹。我们的子弹将有一个撞击动画,因此我们需要添加一个 AnimatedSprite2D 节点。
在 Inspector 面板中添加新的 SpriteFrames 资源,然后在下面的 SpriteFrames 窗格中添加一个名为“impact”的新动画。我们将用于子弹的 spritesheet 可以在 Assets > FX > Death Explosion.png 下找到。
水平方向我们算 8 帧,垂直方向我们算 1 帧,因此让我们相应地更改值以裁剪动画帧。此外,只为动画选择前三帧。
将 FPS 值更改为 10,并将其循环值关闭。
我们还需要将一个启用了自动播放的 Timer 节点添加到场景中。当子弹在未击中或撞击任何东西后需要“自毁”时,此计时器将发出timeout()信号。
最后,向场景添加脚本并将其保存在脚本文件夹下。
好的,现在我们可以开始讨论我们的 Bullet 场景需要做什么。我们首先需要定义一些变量来设置子弹的速度、方向和伤害。我们还需要再次引用我们的 Tilemap 节点,以便我们可以忽略与某些层的碰撞,例如我们的水,它添加了碰撞,但它不应该阻止子弹。
### Bullet.gd
extends Area2D
# Bullet variables
@onready var tilemap = get_tree().root.get_node("Main/Map")
var speed = 80
var direction : Vector2
var damage
然后我们需要计算子弹射出后的位置。如果玩家移动,则应为每一帧重新计算此位置。因此,我们可以在内置的process()函数中计算它,以便它在每一帧移动期间更新其方向。我们可以通过将当前位置乘以方向乘以当前帧捕获的速度(作为增量)来计算此位置。
### Bullet.gd
extends Area2D
# Bullet variables
@onready var tilemap = get_tree().root.get_node("Main/Map")
var speed = 80
var direction : Vector2
var damage
# ---------------- Bullet -------------------------
# Position
func _process(delta):
position = position + speed * delta * direction
当我们进行拾取时,我们将 Area2D 的 body_entered() 信号连接到我们的场景,以将拾取物添加到我们玩家的库存中。我们想对我们的 Bullet 场景做同样的事情,但这次我们将检查进入的身体是否是我们的敌人,以便我们可以伤害他们。我们还想检查进入 Bullets 碰撞的身体是否是来自 TileMap 的 Player 或“水”层,因为我们想忽略这些碰撞。
将 body_entered() 信号连接到 Bullet 脚本。您将看到它在脚本末尾创建了一个新的“func _on_body_entered(body):”函数。在这个新函数中,让我们实现上面设定的目标。
#Collision detection for the bullet
func _on_body_entered(body):
# Ignore collision with Player
if body.name == "Player":
return
# Ignore collision with Water
if body.name == "Map":
#water == Layer 0
if tilemap.get_layer_name(0):
return
# If the bullets hit an enemy, damage them
if body.name.find("Enemy") >= 0:
#todo: add damage/hit function to enemy scene
pass
我们还不能伤害敌人,因为他们没有设置任何生命值变量或函数。我们稍后会这样做,但现在,让我们停止子弹的移动,并将其动画更改为“撞击”,如果它没有击中任何东西。我们这样做是因为我们不想让一颗随机的子弹在我们的场景中漂浮。
### Bullet.gd
extends Area2D
# Bullet variables
@onready var tilemap = get_tree().root.get_node("Main/Map")
var speed = 80
var direction : Vector2
var damage
# ---------------- Bullet -------------------------
# Position
func _process(delta):
position = position + speed * delta * direction
# Collision
func _on_body_entered(body):
# Ignore collision with Player
if body.name == "Player":
return
# Ignore collision with Water
if body.name == "Map":
#water == Layer 0
if tilemap.get_layer_name(Global.WATER_LAYER):
return
# If the bullets hit an enemy, damage them
if body.name.find("Enemy") >= 0:
#todo: add damage/hit function to enemy scene
pass
我们还不能伤害敌人,因为他们没有设置任何生命值变量或函数。我们稍后会这样做,但现在,让我们停止子弹的移动,并将其动画更改为“撞击”,如果它没有击中任何东西。我们这样做是因为我们不想让一颗随机的子弹在我们的场景中漂浮。
### Bullet.gd
extends Area2D
# Node refs
@onready var tilemap = get_tree().root.get_node("Main/Map")
@onready var animated_sprite = $AnimatedSprite2D
# older code
# ---------------- Bullet -------------------------
# Position
func _process(delta):
position = position + speed * delta * direction
# Collision
func _on_body_entered(body):
# Ignore collision with Player
if body.name == "Player":
return
# Ignore collision with Water
if body.name == "Map":
#water == Layer 0
if tilemap.get_layer_name(Global.WATER_LAYER):
return
# If the bullets hit an enemy, damage them
#todo: add damage/hit function to enemy scene
# Stop the movement and explode
direction = Vector2.ZERO
animated_sprite.play("impact")
我们还需要删除场景中的子弹,因为它停止移动并停止播放“撞击”动画。我们之前在 Pickups 场景中做过这个,所以让我们继续将 AnimationSprite2D animation_finished信号连接到 Bullet 脚本。
### Bullet.gd
# older code
# ---------------- Bullet -------------------------
# older code
# Remove
func _on_animated_sprite_2d_animation_finished():
if animated_sprite.animation == "impact":
get_tree().queue_delete(self)### Bullet.gd
# older code
# ---------------- Bullet -------------------------
# older code
# Remove
func _on_animated_sprite_2d_animation_finished():
if animated_sprite.animation == "impact":
get_tree().queue_delete(self)
最后,在 Bullet 场景中,将 Timer 节点的timeout()信号连接到 Bullet 脚本。我们希望此函数在两秒钟内未击中任何东西后也播放“撞击”动画,因为播放此动画将触发animation_finished信号,从而将子弹从场景中删除。
### Bullet.gd
# older code
# ---------------- Bullet -------------------------
# older code
# Self-destruct
func _on_timer_timeout():
animated_sprite.play("impact")
不要忘记将 Timer 节点的等待时间更改为 2,以便它在 2 秒后播放动画,而不是 1 秒。
射手
现在,我们已经完成了 Bullet 场景,因为我们已经设置了子弹运动和动画,并且能够从场景中移除子弹。现在我们需要返回 Player 场景,以便我们可以通过ui_attack输入发射子弹。
让我们在 Global 脚本中预加载 Bullet 脚本。
### Global.gd
extends Node
# Scene resources
@onready var pickups_scene = preload("res://Scenes/Pickup.tscn")
@onready var enemy_scene = preload("res://Scenes/Enemy.tscn")
@onready var bullet_scene = preload("res://Scenes/Bullet.tscn")
在您的播放器脚本中,让我们定义一些变量来设置子弹伤害、重新加载时间和子弹发射时间。
### Player.gd
# older code
# Bullet & attack variables
var bullet_damage = 30
var bullet_reload_time = 1000
var bullet_fired_time = 0.5
现在,我们可以将ui_attack输入更改为生成子弹、计算重新装填时间并移除弹药。生成这些子弹时,我们需要考虑子弹发射的时间与重新装填时间。我们希望玩家休息 1000 毫秒后才能发射下一轮。要在 Godot 中获取时间,我们可以使用Time 对象。Time 单例允许在各种格式之间转换时间,还可以从系统获取时间信息。我们将使用方法.get_ticks_msec()进行精确的时间计算。
首先,我们将检查当前时间是否大于或等于bullet_fired_time并且我们是否有弹药可以射击(请确保首先为玩家分配一些弹药)。
如果更大,则意味着我们可以发射一发子弹。然后我们将is_attacking布尔值返回为 true 并播放射击动画。我们将通过将 reload_time 添加到当前时间中来更新 bullet_fired_time。这意味着我们的玩家在发射下一发子弹之前将有 1000 毫秒的暂停时间。最后,我们将更新弹药拾取量。
### Player.gd
# older code
func _input(event):
#input event for our attacking, i.e. our shooting
if event.is_action_pressed("ui_attack"):
#checks the current time as the amount of time passed in milliseconds since the engine started
var now = Time.get_ticks_msec()
#check if player can shoot if the reload time has passed and we have ammo
if now >= bullet_fired_time and ammo_pickup > 0:
#shooting anim
is_attacking = true
var animation = "attack_" + returned_direction(new_direction)
animation_sprite.play(animation)
#bullet fired time to current time
bullet_fired_time = now + bullet_reload_time
#reduce and signal ammo change
ammo_pickup = ammo_pickup - 1
ammo_pickups_updated.emit(ammo_pickup)
# older code
我们将在_on_animated_sprite_2d_animation_finished函数中生成子弹,因为只有在播放完射击动画后,我们才希望将子弹添加到主场景中。
我们必须创建子弹场景的另一个实例,在其中我们将更新其伤害、方向和位置,作为玩家发射子弹时所面对的方向,以及玩家前方 4-5 像素的位置(你不希望子弹来自玩家内部,而是来自枪的“枪管”)。
##
### Player.gd
# older code
# Reset Animation states
func _on_animated_sprite_2d_animation_finished():
is_attacking = false
# Instantiate Bullet
if animation_sprite.animation.begins_with("attack_"):
var bullet = Global.bullet_scene.instantiate()
bullet.damage = bullet_damage
bullet.direction = new_direction.normalized()
# Place it 4-5 pixels away in front of the player to simulate it coming from the guns barrel
bullet.position = position + new_direction.normalized() * 4
get_tree().root.get_node("Main").add_child(bullet)
现在,如果您运行场景,按下 CTRL 发射子弹(ui_attack 输入操作)后应该会产生一颗子弹。对于子弹来说,它有点大,所以让我们改变它的大小!
在 Bullet 场景中,选择 AnimatedSprite2D 节点,将其 Transform 属性下的比例值从 1 更改为 0.4。
另外,将子弹特效的第四帧添加到“冲击”动画中。抱歉现在添加它给您带来不便,我只是觉得它在最后一刻看起来更好!
现在,如果你运行你的场景,你的子弹应该小得多。它还应该在 2 秒后或撞击另一个碰撞体时自毁。现在我们需要添加敌人受到子弹撞击伤害的功能。
敌人的攻击
回到我们的 Bullet 脚本,我添加了一个#todo,因为我们仍然没有在敌人场景中设置伤害函数或健康变量。我们现在就去做。
在 Enemy 脚本中,让我们设置其健康变量。就像玩家一样,我们需要变量来存储其健康、最大健康、健康再生,以及死亡时发出的信号。
### Enemy.gd
extends CharacterBody2D
# Node refs
@onready var player = get_tree().root.get_node("Main/Player")
@onready var animation_sprite = $AnimatedSprite2D
# Enemy stats
@export var speed = 50
var direction : Vector2 # current direction
var new_direction = Vector2(0,1) # next direction
var animation
var is_attacking = false
var health = 100
var max_health = 100
var health_regen = 1
# Direction timer
var rng = RandomNumberGenerator.new()
var timer = 0
# Custom signals
signal death
我们将在其_process(delta)函数中计算其健康再生,因为我们想在每一帧都计算它。当我们计算其 updated_health 值时,我们已经在 Player 脚本中计算了这一点。
### Enemy.gd
extends CharacterBody2D
# older code
#-------------- Damage & Health ------------------
func _process(delta):
#regenerates our enemy's health
health = min(health + health_regen * delta, max_health)
让我们继续创建伤害函数。这将是我们在 Player 和 Bullet 场景中调用的函数,用于在子弹/攻击撞击时对敌人造成伤害。在此之前,我们还需要为敌人提供一些攻击变量。我们可以从 Player 脚本中复制攻击变量。
### Enemy.gd
extends CharacterBody2D
# older code
# Bullet & attack variables
var bullet_damage = 30
var bullet_reload_time = 1000
var bullet_fired_time = 0.5
伤害函数会根据玩家或攻击者传入的子弹伤害来减少敌人的生命值。如果他们的生命值大于零,敌人就会受到伤害。如果小于或等于零,敌人就会死亡。
### Enemy.gd
# older code
#------------------------------------ Damage & Health ---------------------------
func _process(delta):
#regenerates our enemy's health
health = min(health + health_regen * delta, max_health)
#will damage the enemy when they get hit
func hit(damage):
health -= damage
if health > 0:
#damage
pass
else:
#death
pass
我们将慢慢完成伤害函数,因此让我们首先添加一个动画来表明我们的敌人已被击中。为此,我们可以通过AnimationPlayer节点更改敌人的调制值。调制值指的是节点的颜色,因此简单来说,我们将使用 AnimationPlayer 将敌人的颜色短暂地变成红色,以便我们可以看到它们受到了伤害。每当您需要为非精灵项目(例如标签或颜色)制作动画时,您都将使用此 AnimationPlayer 节点。让我们将此节点添加到我们的敌人场景中。
16.jpg
何时使用 AnimatedSprite2D 节点与 AnimationPlayer 节点?
使用 AnimatedSprite2D 节点实现简单的逐帧 2D 精灵动画。使用 AnimationPlayer 实现涉及多个属性、节点或音频和函数调用等附加功能的复杂动画。
我们将动画添加到编辑器底部动画面板中的 AnimationPlayer 节点。
要添加动画,请点击“动画”标签并输入“新建”。我们将这个新动画命名为“损坏”。
您的新动画将分配长度“1”,您可以看到值从 0 到 1。让我们将此长度更改为 0.2,因为我们希望此损坏指示器非常短。您可以按住 CTRL 并使用鼠标滚轮缩放来放大/缩小您的轨迹。
图 14:动画面板概览
我们希望此动画短暂地改变 AnimatedSprite2D 节点的 modulate(颜色)值,这是我们可以在节点的 Inspector 面板中更改的属性。因此,我们需要添加一个新的 Property Track。单击“+ Add Track”并选择 Property Track。
将此轨迹连接到您的 AnimatedSprite2D 节点,因为这是您想要设置动画的节点。
接下来,我们需要选择要更改的节点的属性。我们需要调节值,因此请从列表中选择它。
现在我们有了想要更改的属性,我们需要在轨道上插入关键帧。这些将是动画关键帧,定义动画的起点和/或终点。让我们插入两个关键帧:一个在我们的 0 值(起点)上,一个在我们的 0.2 值(终点)上。要插入关键帧,只需右键单击轨道并选择“插入关键帧”。
如果您单击立方体或键,则会打开“检查器”面板,您将看到您可以在“值”下方的该帧中更改节点的调制值。
我们希望调制值从关键帧 0 处的红色变为关键帧 1 处的白色。为了使调制值变为红色,我们只需将其RGB值更改为 (255, 0, 0)。
我们还想改变动画的轨迹速率。如果你现在运行动画,调制颜色会逐渐从红色变为白色。相反,我们希望它在达到 0.2 关键帧值时立即从红色变为白色。为此,我们可以改变它的轨迹速率,它位于弯曲的白线下方的轨迹旁边。
Godot 有三种跟踪速率选项:
连续:在每一帧上更新属性。
离散:仅更新关键帧上的属性。
触发器:仅更新关键帧或触发器上的属性。
我们需要将跟踪速率从连续改为离散。它应该看起来像这样:
现在,如果你运行此动画,它会暂时将敌人精灵的颜色更改为红色,然后再恢复为其默认颜色。
我们可以回到我们的伤害功能,如果敌人受到伤害就播放此动画。
### Enemy.gd
extends CharacterBody2D
# Node refs
@onready var player = get_tree().root.get_node("Main/Player")
@onready var animation_sprite = $AnimatedSprite2D
@onready var animation_player = $AnimationPlayer
# older code
#------------------------------------ Damage & Health ---------------------------
func _process(delta):
#regenerates our enemy's health
health = min(health + health_regen * delta, max_health)
#will damage the enemy when they get hit
func hit(damage):
health -= damage
if health > 0:
#damage
animation_player.play("damage")
else:
#death
pass
现在,为了伤害敌人,我们必须将敌人添加到一个组中。这将允许我们在 Bullet 场景中使用 Area2D 节点,仅当它们属于我们的“敌人”组时才伤害我们的身体。单击敌人场景中的根节点,然后在 Groups 属性下将敌人分配给“敌人”组。
什么是群组?
组是组织和管理场景树中节点的一种方式。它们提供了一种便捷的方式,可以将操作或逻辑应用于具有共同特征或用途的一组节点。
让我们回到 Bullet 脚本中的 #todo,并将其替换为我们刚刚创建的损坏函数。
### Bullets.gd
extends Area2D
# older code
# ---------------- Bullet -------------------------
# Position
func _process(delta):
position = position + speed * delta * direction
# Collision
func _on_body_entered(body):
# Ignore collision with Player
if body.name == "Player":
return
# Ignore collision with Water
if body.name == "Map":
#water == Layer 0
if tilemap.get_layer_name(Global.WATER_LAYER):
return
# If the bullets hit an enemy, damage them
if body.is_in_group("enemy"):
body.hit(damage)
# Stop the movement and explode
direction = Vector2.ZERO
animated_sprite.play("impact")
敌人死亡
最后,对于本节,我们需要添加敌人死亡的功能。我们已经为此创建了信号,现在我们只需要更新 Enemy 脚本中的现有代码以播放死亡动画并从场景树中移除敌人,以及更新 Enemy Spawner 代码以连接到此信号并更新敌人数量。
让我们从死亡条件下的 Enemy 脚本开始。当我们的敌人死亡时,我们要做的第一件事就是停止处理其移动和方向的计时器。我们还需要停止 process ()函数重新生成敌人的健康值,因此我们将set_process值设置为 false。
### Enemy.gd
# older code
#will damage the enemy when they get hit
func hit(damage):
health -= damage
if health > 0:
#damage
animation_player.play("damage")
else:
#death
#stop movement
timer_node.stop()
direction = Vector2.ZERO
#stop health regeneration
set_process(false)
#trigger animation finished signal
is_attacking = true
#Finally, we play the death animation and emit the signal for the spawner.
animation_sprite.play("death")
death.emit()
我们还需要将 is_attacking 变量设置为 true,以便触发动画完成信号,这样我们就可以在死亡动画播放后从场景中移除节点。我们只是希望敌人停止,播放其死亡动画,然后发出信号表示它已死亡,以便可以生成新的敌人。
### Enemy.gd
#------------------------------------ Damage & Health ---------------------------
# remove
func _on_animated_sprite_2d_animation_finished():
if animation_sprite.animation == "death":
get_tree().queue_delete(self)
is_attacking = false
现在我们需要转到我们的 EnemySpawner 脚本来创建一个函数,该函数在我们的敌人脚本发出死亡信号后从我们的敌人数量中删除一个点。
###EnemySpawner.gd
# ----------------- Spawning --------------------------
# older code
# Remove enemy
func _on_enemy_death():
enemy_count = enemy_count - 1
我们希望这个函数与我们的生成函数中的信号相连接,这样我们的生成器就知道一个敌人已被移除,并且应该生成一个新的敌人。
###EnemySpawner.gd
# ----------------- Spawning --------------------
func spawn_enemy():
var attempts = 0
var max_attempts = 100 # Maximum number of attempts to find a valid spawn location
var spawned = false
while not spawned and attempts < max_attempts:
# Randomly select a position on the map
var random_position = Vector2(
rng.randi() % tilemap.get_used_rect().size.x,
rng.randi() % tilemap.get_used_rect().size.y
)
# Check if the position is a valid spawn location
if is_valid_spawn_location(Global.GRASS_LAYER, random_position) || is_valid_spawn_location(Global.SAND_LAYER, random_position):
var enemy = Global.enemy_scene.instantiate()
enemy.death.connect(_on_enemy_death) # add this
enemy.position = tilemap.map_to_local(random_position) + Vector2(16, 16) / 2
spawned_enemies.add_child(enemy)
spawned = true
else:
attempts += 1
if attempts == max_attempts:
print("Warning: Could not find a valid spawn location after", max_attempts, "attempts.")
最后,回到我们的 Enemy 脚本,我们需要在伤害动画播放后以及敌人生成时重置敌人的调制值。这将防止我们的敌人在受到伤害后生成或保持红色。将 AnimationPlayer 节点的animation_finished信号连接到 Enemy 脚本。
### Enemy.gd
# older code
func _ready():
rng.randomize()
# Reset color
animation_sprite.modulate = Color(1,1,1,1)
# Reset color
func _on_animation_player_animation_finished(anim_name):
animation_sprite.modulate = Color(1,1,1,1)
因此,我们的玩家现在可以发射子弹,如果子弹击中敌人,就会对其造成伤害。击中三次后,敌人就会被杀死,并被从场景中移除!
这就是这一部分的全部内容。这项工作相当繁重,如果你能坚持到这一步,恭喜你坚持不懈!接下来,我们将在敌人死亡时产生战利品,然后我们将添加他们攻击我们的玩家角色并对我们造成伤害的功能!记得保存你的游戏项目,下一部分见。