第十二部分:血量系统与 UI:玩家血条、怪物血条、受伤无敌、死亡重生
这一部分继续补“战斗反馈”。
前面我们已经有了:
玩家攻击怪物
怪物扣血
怪物死亡
怪物发现玩家
追击玩家
攻击玩家
玩家扣血
但现在还缺几个关键东西:
玩家血量怎么显示?
怪物血量怎么显示?
玩家受伤后会不会被连续秒杀?
死亡后怎么办?
重生怎么做?
这一节我们就围绕这些来讲。
1. 血量系统到底是什么
血量系统的核心其实很简单:
最大血量 max_hp
当前血量 current_hp
受到伤害 take_damage()
恢复血量 heal()
血量变化时通知 UI
血量为 0 时死亡
最小版本:
@export var max_hp: int = 100
var current_hp: int = 100
func take_damage(amount: int):
current_hp -= amount
if current_hp <= 0:
die()
func heal(amount: int):
current_hp += amount
current_hp = min(current_hp, max_hp)
但真正项目里,我们还要考虑:
血量不能小于 0
血量不能超过 max_hp
UI 要同步变化
死亡不能重复触发
受伤后要短暂无敌
所以会稍微多一点代码。
2. 血条 UI 用什么节点
Godot 里最简单的血条可以用:
ProgressBar
ProgressBar 是 Godot 里用来显示百分比进度的 UI 控件,继承自 Range,适合做普通进度条、加载条、简单血条。官方文档也把它描述为用于可视化显示百分比的 Control。(Godot Engine documentation)
更好看的血条可以用:
TextureProgressBar
TextureProgressBar 和 ProgressBar 类似,但它使用贴图来显示进度,官方文档也明确说它适合用来做加载条、生命条或耐力条。(Godot Engine documentation)
你现在建议先用:
ProgressBar
等系统跑通后,再换成:
TextureProgressBar
不要第一步就卡在 UI 美术上。系统没跑通,血条再漂亮也只是高级摆设。
3. 玩家血条应该放在哪里
玩家血条属于 HUD UI。
所以应该放在:
CanvasLayer
前面讲过,CanvasLayer 会创建独立的 2D 渲染层,它的子节点会绘制在这个层里,并且更适合做固定在屏幕上的 HUD。官方文档也说明,CanvasLayer 的 layer 数字决定绘制顺序,默认 2D 场景在 layer 0,layer 1 会绘制在其上方。(Godot Engine documentation)
结构建议:
Main Node
├── World Node2D
│ ├── Player
│ └── Enemies
└── UI CanvasLayer
├── PlayerHUD Control
│ ├── HPBar ProgressBar
│ └── HPLabel Label
├── InteractLabel Label
└── DialogueBox Control
不要把玩家血条放在 World 里。
否则 Camera2D 移动时,血条会跟着地图跑。
4. PlayerHUD 场景结构
建议单独做一个 PlayerHUD.tscn。
结构:
PlayerHUD Control
├── HPBar ProgressBar
└── HPLabel Label
HPBar 用来显示血条。
HPLabel 用来显示数字:
80 / 100
这样你调试时会很直观。
第一阶段不要嫌数字难看。
开发阶段,清楚比好看重要。
5. ProgressBar 基础属性
选中 HPBar ProgressBar,设置:
Min Value = 0
Max Value = 100
Value = 100
Show Percentage = false
为什么关掉 Show Percentage?
因为默认可能显示:
80%
而 RPG 里我们更常见的是:
80 / 100
所以数字自己用 HPLabel 显示。
6. Player 通过 signal 通知血量变化
不要让 UI 每帧去问 Player:
你现在血量多少?
你现在血量多少?
你现在血量多少?
更好的做法是:
Player 血量变化
↓
Player 发 signal
↓
UI 收到 signal
↓
更新血条
这和之前交互提示、对话开始结束一样。
Player.gd:
signal health_changed(current_hp: int, max_hp: int)
signal died
这样 Player 血量变化时:
health_changed.emit(current_hp, max_hp)
死亡时:
died.emit()
7. Player.gd 血量基础版
@export var max_hp: int = 100
var current_hp: int = 100
func _ready():
current_hp = max_hp
health_changed.emit(current_hp, max_hp)
但注意:如果 UI 还没来得及连接 health_changed,这次 emit 可能收不到。
所以通常 UI 在 _ready() 里连接后,也主动刷新一次:
_on_player_health_changed(player.current_hp, player.max_hp)
这个很常见。
8. Player.gd 的 take_damage()
func take_damage(amount: int, attacker: Node = null):
if current_state == PlayerState.DEAD:
return
current_hp -= amount
current_hp = clamp(current_hp, 0, max_hp)
health_changed.emit(current_hp, max_hp)
if current_hp <= 0:
change_state(PlayerState.DEAD)
died.emit()
return
change_state(PlayerState.HURT)
这里的重点:
如果已经死亡,不再处理伤害
扣血后用 clamp 限制范围
血量变化后 emit health_changed
血量为 0 切到 DEAD
否则切到 HURT
clamp(current_hp, 0, max_hp) 的意思是:
如果 current_hp 小于 0,就变成 0
如果 current_hp 大于 max_hp,就变成 max_hp
否则保持原值
9. Player.gd 的 heal()
func heal(amount: int):
if current_state == PlayerState.DEAD:
return
current_hp += amount
current_hp = clamp(current_hp, 0, max_hp)
health_changed.emit(current_hp, max_hp)
以后药水、食物、治疗魔法都会调用这个。
比如药水:
player.heal(30)
10. PlayerHUD.gd
给 PlayerHUD Control 挂脚本:
extends Control
@onready var hp_bar: ProgressBar = $HPBar
@onready var hp_label: Label = $HPLabel
func setup(player: Node):
player.health_changed.connect(_on_player_health_changed)
if "current_hp" in player and "max_hp" in player:
_on_player_health_changed(player.current_hp, player.max_hp)
func _on_player_health_changed(current_hp: int, max_hp: int):
hp_bar.max_value = max_hp
hp_bar.value = current_hp
hp_label.text = "%d / %d" % [current_hp, max_hp]
然后在 UI.gd 里:
extends CanvasLayer
@export var player: Node
@onready var player_hud: Control = $PlayerHUD
func _ready():
if player != null:
player_hud.setup(player)
这样 Player 受伤时,血条会自动更新。
11. 为什么用 setup(player)
你可能会想,为什么不在 PlayerHUD 里直接写:
@export var player: Node
也可以。
但是用 setup(player) 有一个好处:
以后切地图、重生、重新实例化 Player 时
UI 可以重新绑定新的 Player
初期两种都行。
更简单版本:
@export var player: Node
也完全可以。
12. 受伤无敌是什么
如果怪物攻击玩家时,AttackArea 持续打开几帧,或者多个怪物围着玩家,玩家可能会这样:
第 1 帧:扣 10 血
第 2 帧:扣 10 血
第 3 帧:扣 10 血
第 4 帧:扣 10 血
一瞬间血条蒸发。
所以一般角色受伤后会有短暂无敌时间。
比如:
受伤后 0.8 秒内不再受伤
这叫:
Invincibility Frames
简称 i-frames
游戏里非常常见。
13. Player.gd 加无敌变量
@export var invincible_duration: float = 0.8
var is_invincible: bool = false
take_damage 开头判断:
func take_damage(amount: int, attacker: Node = null):
if current_state == PlayerState.DEAD:
return
if is_invincible:
return
current_hp -= amount
current_hp = clamp(current_hp, 0, max_hp)
health_changed.emit(current_hp, max_hp)
if current_hp <= 0:
change_state(PlayerState.DEAD)
died.emit()
return
change_state(PlayerState.HURT)
start_invincibility()
14. start_invincibility()
可以用 get_tree().create_timer()。
SceneTreeTimer 是由 SceneTree 管理的一次性计时器,官方文档也说明它常用于不创建 Timer 节点的短暂延迟,例如 await get_tree().create_timer(1.0).timeout。(Godot Engine documentation)
代码:
func start_invincibility():
is_invincible = true
start_invincible_flash()
await get_tree().create_timer(invincible_duration).timeout
is_invincible = false
stop_invincible_flash()
这表示:
进入无敌
开始闪烁
等待 invincible_duration 秒
退出无敌
停止闪烁
15. 受伤闪烁效果
最简单可以让角色透明闪烁。
func start_invincible_flash():
var flash_id := Time.get_ticks_msec()
func stop_invincible_flash():
animated_sprite.visible = true
但这个 flash_id 没有真正保存,不能阻止多个闪烁循环冲突。
更稳的写法是用一个版本号:
var flash_token: int = 0
func start_invincible_flash():
flash_token += 1
var current_token := flash_token
while is_invincible:
if current_token != flash_token:
return
animated_sprite.visible = false
await get_tree().create_timer(0.08).timeout
if current_token != flash_token:
return
animated_sprite.visible = true
await get_tree().create_timer(0.08).timeout
停止:
func stop_invincible_flash():
flash_token += 1
animated_sprite.visible = true
这个思路和前面对话逐字显示的 typing_id 很像:
用 token 防止旧异步循环回来捣乱
16. HURT 状态结束后回到 IDLE
如果你有 hurt 动画:
func _on_animation_finished():
if current_state == PlayerState.HURT:
change_state(PlayerState.IDLE)
return
如果没有 hurt 动画,可以在进入 HURT 时用 Timer:
func enter_state(state: PlayerState):
match state:
PlayerState.HURT:
velocity = Vector2.ZERO
animated_sprite.play("hurt_" + get_direction_name(last_direction))
wait_hurt_finished()
func wait_hurt_finished():
await get_tree().create_timer(0.25).timeout
if current_state == PlayerState.HURT:
change_state(PlayerState.IDLE)
如果你暂时没有 hurt_down 等动画,先播放 idle:
animated_sprite.play("idle_" + get_direction_name(last_direction))
17. HURT 状态是否允许移动
第一阶段建议:
不允许
受伤时角色停一下,会更有反馈。
func physics_hurt(delta):
velocity = Vector2.ZERO
move_and_slide()
以后可以加击退。
比如被怪物打中时,玩家往后退一点。
但这要配合 knockback_velocity,可以后面再做。
18. DEAD 状态
玩家死亡时,一般要做:
停止移动
停止交互
停止攻击
关闭 Hurtbox
播放死亡动画
显示死亡 UI
等待玩家点击重生
Player.gd:
func enter_state(state: PlayerState):
match state:
PlayerState.DEAD:
velocity = Vector2.ZERO
set_process_unhandled_input(false)
animated_sprite.play("dead")
hurtbox.monitorable = false
如果你没有 dead 动画,可以先:
animated_sprite.visible = false
或者播放 idle,然后显示死亡 UI。
19. 不要在玩家死亡时立刻 queue_free
怪物死了可以:
queue_free()
但玩家死亡不建议第一版就 queue_free()。
因为玩家身上有:
Camera2D
状态
背包
任务
引用关系
UI 绑定
你直接删 Player,UI、Camera、World 可能都要重新处理。
第一阶段更推荐:
玩家死亡后进入 DEAD 状态
停在原地
显示死亡 UI
点击重生后恢复位置和血量
这样简单很多。
20. 死亡 UI
在 UI CanvasLayer 下添加:
DeathPanel Control
├── Panel
├── TitleLabel Label
└── RespawnButton Button
默认:
visible = false
TitleLabel 写:
你倒下了
RespawnButton 写:
重新开始
结构:
UI CanvasLayer
├── PlayerHUD
├── InteractLabel
├── DialogueBox
└── DeathPanel Control
└── Panel
├── TitleLabel Label
└── RespawnButton Button
21. UI 监听 Player died
UI.gd:
extends CanvasLayer
@export var player: Node
@onready var player_hud: Control = $PlayerHUD
@onready var death_panel: Control = $DeathPanel
@onready var respawn_button: Button = $DeathPanel/Panel/RespawnButton
func _ready():
death_panel.visible = false
if player != null:
player_hud.setup(player)
player.died.connect(_on_player_died)
respawn_button.pressed.connect(_on_respawn_button_pressed)
func _on_player_died():
death_panel.visible = true
func _on_respawn_button_pressed():
death_panel.visible = false
if player != null and player.has_method("respawn"):
player.respawn()
Button 的点击一般用 pressed 信号,这和前面 UI 按钮逻辑一致。
22. Player respawn()
Player 需要知道重生点。
可以在 Player 里导出:
@export var respawn_point: Marker2D
然后:
func respawn():
if respawn_point != null:
global_position = respawn_point.global_position
current_hp = max_hp
health_changed.emit(current_hp, max_hp)
is_invincible = false
stop_invincible_flash()
hurtbox.monitorable = true
animated_sprite.visible = true
change_state(PlayerState.IDLE)
如果你前面 World 里已经有:
SpawnPoints
└── PlayerSpawn Marker2D
就把它拖给 Player 的 respawn_point。
23. 复活后给短暂无敌
重生后最好给一点无敌时间,避免复活点旁边有怪物,刚复活又被打死。
func respawn():
if respawn_point != null:
global_position = respawn_point.global_position
current_hp = max_hp
health_changed.emit(current_hp, max_hp)
hurtbox.monitorable = true
animated_sprite.visible = true
change_state(PlayerState.IDLE)
start_invincibility()
这样重生后 0.8 秒内不会立刻受伤。
24. Player.gd 血量与无敌完整片段
这里给关键片段,不重复整个 Player.gd。
signal health_changed(current_hp: int, max_hp: int)
signal died
@export var max_hp: int = 100
@export var invincible_duration: float = 0.8
@export var respawn_point: Marker2D
@onready var animated_sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var hurtbox: Area2D = $Hurtbox
var current_hp: int
var is_invincible: bool = false
var flash_token: int = 0
func _ready():
current_hp = max_hp
health_changed.emit(current_hp, max_hp)
func take_damage(amount: int, attacker: Node = null):
if current_state == PlayerState.DEAD:
return
if is_invincible:
return
current_hp -= amount
current_hp = clamp(current_hp, 0, max_hp)
health_changed.emit(current_hp, max_hp)
if current_hp <= 0:
change_state(PlayerState.DEAD)
died.emit()
return
change_state(PlayerState.HURT)
start_invincibility()
func heal(amount: int):
if current_state == PlayerState.DEAD:
return
current_hp += amount
current_hp = clamp(current_hp, 0, max_hp)
health_changed.emit(current_hp, max_hp)
func start_invincibility():
is_invincible = true
start_invincible_flash()
await get_tree().create_timer(invincible_duration).timeout
if current_state == PlayerState.DEAD:
return
is_invincible = false
stop_invincible_flash()
func start_invincible_flash():
flash_token += 1
var current_token := flash_token
while is_invincible:
if current_token != flash_token:
return
animated_sprite.visible = false
await get_tree().create_timer(0.08).timeout
if current_token != flash_token:
return
animated_sprite.visible = true
await get_tree().create_timer(0.08).timeout
func stop_invincible_flash():
flash_token += 1
animated_sprite.visible = true
func respawn():
if respawn_point != null:
global_position = respawn_point.global_position
current_hp = max_hp
health_changed.emit(current_hp, max_hp)
is_invincible = false
stop_invincible_flash()
hurtbox.monitorable = true
animated_sprite.visible = true
change_state(PlayerState.IDLE)
start_invincibility()
注意:你的 Player.gd 里如果已经有 _ready(),不要复制出两个 _ready()。
要把内容合并进去。
25. DEAD 状态进入逻辑
在状态机里:
func enter_state(state: PlayerState):
match state:
PlayerState.DEAD:
velocity = Vector2.ZERO
hurtbox.monitorable = false
is_invincible = false
stop_invincible_flash()
if animated_sprite.sprite_frames.has_animation("dead"):
animated_sprite.play("dead")
else:
animated_sprite.visible = false
这里做了几件事:
速度清零
关闭受击 Hurtbox
停止无敌闪烁
如果有死亡动画就播
如果没有就先隐藏角色
26. 怪物血条
玩家血条放在 HUD 里。
怪物血条通常有两种做法:
1. 血条显示在怪物头顶
2. 打中怪物时,在屏幕某个地方显示目标血条
第一阶段建议:
怪物头顶血条
结构:
Slime CharacterBody2D
├── AnimatedSprite2D
├── CollisionShape2D
├── HealthBar Control
│ └── HPBar ProgressBar
├── DetectionArea
├── AttackArea
└── Hurtbox
如果 HealthBar 是 Control,放在 World 里时它会跟随怪物一起移动。
这正是我们想要的。
怪物头顶血条不是固定 HUD,它应该跟着怪物走。
27. 怪物血条位置
HealthBar Control 可以放在怪物上方。
比如:
HealthBar.position = Vector2(-16, -32)
里面的 ProgressBar 宽度设置 32,高度 4 或 6。
结构:
HealthBar Control
└── HPBar ProgressBar
默认可以隐藏:
visible = false
怪物受伤后再显示。
28. Enemy 也发 health_changed
Enemy.gd:
signal health_changed(current_hp: int, max_hp: int)
signal died
初始化:
func _ready():
current_hp = max_hp
health_changed.emit(current_hp, max_hp)
受伤时:
func take_damage(amount: int):
if current_state == EnemyState.DEAD:
return
current_hp -= amount
current_hp = clamp(current_hp, 0, max_hp)
health_changed.emit(current_hp, max_hp)
if current_hp <= 0:
change_state(EnemyState.DEAD)
died.emit()
return
change_state(EnemyState.HURT)
29. EnemyHealthBar.gd
给怪物的 HealthBar 挂脚本:
extends Control
@onready var hp_bar: ProgressBar = $HPBar
func setup(enemy: Node):
enemy.health_changed.connect(_on_health_changed)
enemy.died.connect(_on_enemy_died)
visible = false
if "current_hp" in enemy and "max_hp" in enemy:
_on_health_changed(enemy.current_hp, enemy.max_hp)
func _on_health_changed(current_hp: int, max_hp: int):
hp_bar.max_value = max_hp
hp_bar.value = current_hp
visible = current_hp < max_hp and current_hp > 0
func _on_enemy_died():
visible = false
Enemy.gd 里:
@onready var health_bar: Control = $HealthBar
func _ready():
current_hp = max_hp
if health_bar != null and health_bar.has_method("setup"):
health_bar.setup(self)
health_changed.emit(current_hp, max_hp)
这样怪物满血时隐藏,受伤后显示,死亡时隐藏。
30. 怪物死亡时要不要 queue_free
怪物可以在死亡动画结束后删除。
如果有 dead 动画:
func enter_state(state: EnemyState):
match state:
EnemyState.DEAD:
velocity = Vector2.ZERO
attack_area.monitoring = false
detection_area.monitoring = false
hurtbox.monitorable = false
play_animation("dead")
动画结束:
func _on_animation_finished():
if current_state == EnemyState.DEAD:
queue_free()
如果没有 dead 动画:
func enter_state(state: EnemyState):
match state:
EnemyState.DEAD:
queue_free()
31. 敌人受伤无敌要不要做
要看游戏类型。
如果玩家一刀只能命中一次,并且你已经用 hit_targets 防止重复命中,那么怪物不一定需要受伤无敌。
但如果有持续伤害技能,比如火焰、毒雾、旋风斩,就可能需要怪物自己的受伤冷却。
第一阶段可以不做。
玩家需要无敌,是因为怪物攻击可能连续命中,多个怪围攻时也容易秒杀玩家。
怪物暂时简单点就行。
32. 玩家死亡时怪物是否停止
第一阶段可以不管。
玩家死亡后:
Player 进入 DEAD
Hurtbox 关闭
怪物 AttackArea 打不到玩家
但怪物可能还在追你。
如果你想玩家死亡后怪物停止,可以加一个全局游戏状态,或者让 Player 的 died 信号通知敌人。
先不急。
更简单:
死亡 UI 出现后,重生按钮可用
重生后玩家恢复
怪物继续原有 AI
这对第一版已经够了。
33. 玩家血量 UI 和怪物血条的区别
玩家血量:
放 CanvasLayer
固定在屏幕上
一直显示
属于 HUD
怪物血条:
放怪物节点下面
跟着怪物移动
受伤后显示
死亡后消失
属于世界对象的一部分
不要把所有怪物血条都放进 UI CanvasLayer。
那样你还要把世界坐标转换成屏幕坐标,第一阶段没必要。
34. 血量系统是否要抽成 HealthComponent
现在 Player 和 Enemy 都有:
max_hp
current_hp
take_damage
heal
health_changed
died
这说明它们可以抽成组件。
比如:
HealthComponent Node
结构:
Player
└── HealthComponent
Slime
└── HealthComponent
但现在先不要抽。
原因很简单:
你还在学基础闭环
过早组件化会增加理解成本
先在 Player 和 Enemy 里各写一份,等你真的有:
玩家
怪物
可破坏木桶
可破坏箱子
Boss
召唤物
都需要血量时,再抽 HealthComponent。
这才是自然重构。
35. 后面 HealthComponent 可能长什么样
只是提前看一眼。
extends Node
signal health_changed(current_hp: int, max_hp: int)
signal died
@export var max_hp: int = 100
var current_hp: int
func _ready():
current_hp = max_hp
health_changed.emit(current_hp, max_hp)
func take_damage(amount: int):
current_hp = clamp(current_hp - amount, 0, max_hp)
health_changed.emit(current_hp, max_hp)
if current_hp <= 0:
died.emit()
func heal(amount: int):
current_hp = clamp(current_hp + amount, 0, max_hp)
health_changed.emit(current_hp, max_hp)
以后 Player / Enemy 只监听 HealthComponent 的信号。
但这属于架构优化,先不急。
36. 受伤反馈应该有哪些
最小反馈:
扣血
血条减少
角色闪烁
进入 HURT 状态
更好的反馈:
受伤音效
红色闪烁
击退
屏幕震动
伤害数字
短暂停顿 hit stop
无敌闪烁
第一版先做:
血条减少
受伤闪烁
无敌时间
够用了。
37. Hit Stop 是什么
Hit Stop 就是打中敌人的一瞬间,画面暂停极短时间。
比如 0.05 秒。
它会让攻击更有“打中了”的手感。
现在先不用做。
以后可以用:
Engine.time_scale = 0.1
await get_tree().create_timer(0.05, true, false, true).timeout
Engine.time_scale = 1.0
不过这涉及暂停、timer 是否受 time_scale 影响、动画同步等问题。
先别碰。
战斗手感这东西很香,但也很容易把新手拖进调参泥潭。
38. 伤害数字怎么接
以后你可以在 take_damage() 里发信号:
signal damage_taken(amount: int, global_position: Vector2)
然后 World 或 DamageNumberManager 生成数字。
现在先用:
print("玩家受伤:", amount)
print("怪物受伤:", amount)
等你战斗闭环稳定,再做飘字。
39. 完整 PlayerHUD.gd
extends Control
@onready var hp_bar: ProgressBar = $HPBar
@onready var hp_label: Label = $HPLabel
func setup(player: Node):
if player == null:
return
if player.has_signal("health_changed"):
player.health_changed.connect(_on_player_health_changed)
if "current_hp" in player and "max_hp" in player:
_on_player_health_changed(player.current_hp, player.max_hp)
func _on_player_health_changed(current_hp: int, max_hp: int):
hp_bar.min_value = 0
hp_bar.max_value = max_hp
hp_bar.value = current_hp
hp_label.text = "%d / %d" % [current_hp, max_hp]
40. 完整 EnemyHealthBar.gd
extends Control
@onready var hp_bar: ProgressBar = $HPBar
func setup(enemy: Node):
if enemy == null:
return
if enemy.has_signal("health_changed"):
enemy.health_changed.connect(_on_health_changed)
if enemy.has_signal("died"):
enemy.died.connect(_on_enemy_died)
visible = false
if "current_hp" in enemy and "max_hp" in enemy:
_on_health_changed(enemy.current_hp, enemy.max_hp)
func _on_health_changed(current_hp: int, max_hp: int):
hp_bar.min_value = 0
hp_bar.max_value = max_hp
hp_bar.value = current_hp
visible = current_hp < max_hp and current_hp > 0
func _on_enemy_died():
visible = false
41. 完整 DeathPanel 逻辑
UI.gd 关键部分:
extends CanvasLayer
@export var player: Node
@onready var player_hud: Control = $PlayerHUD
@onready var death_panel: Control = $DeathPanel
@onready var respawn_button: Button = $DeathPanel/Panel/RespawnButton
func _ready():
death_panel.visible = false
if player != null:
player_hud.setup(player)
if player.has_signal("died"):
player.died.connect(_on_player_died)
respawn_button.pressed.connect(_on_respawn_button_pressed)
func _on_player_died():
death_panel.visible = true
func _on_respawn_button_pressed():
death_panel.visible = false
if player != null and player.has_method("respawn"):
player.respawn()
42. 当前推荐节点结构
现在你的结构可以是:
Main Node
├── World Node2D
│ ├── Ground TileMapLayer
│ ├── Obstacles TileMapLayer
│ ├── SpawnPoints Node2D
│ │ └── PlayerSpawn Marker2D
│ ├── Enemies Node2D
│ │ └── Slime CharacterBody2D
│ │ ├── AnimatedSprite2D
│ │ ├── CollisionShape2D
│ │ ├── NavigationAgent2D
│ │ ├── HealthBar Control
│ │ │ └── HPBar ProgressBar
│ │ ├── DetectionArea Area2D
│ │ ├── AttackArea Area2D
│ │ └── Hurtbox Area2D
│ ├── Player CharacterBody2D
│ │ ├── AnimatedSprite2D
│ │ ├── CollisionShape2D
│ │ ├── Camera2D
│ │ ├── InteractArea Area2D
│ │ ├── AttackArea Area2D
│ │ └── Hurtbox Area2D
│ └── Foreground TileMapLayer
└── UI CanvasLayer
├── PlayerHUD Control
│ ├── HPBar ProgressBar
│ └── HPLabel Label
├── InteractLabel Label
├── DialogueBox Control
└── DeathPanel Control
└── Panel
├── TitleLabel Label
└── RespawnButton Button
这已经非常像一个小型 RPG 原型了。
你现在已经有:
地图
角色移动
相机
交互
对话
攻击
怪物 AI
寻路
血量
死亡重生
别看现在还粗糙,骨架已经立起来了。
43. 常见错误 1:血条不更新
检查:
Player 是否 emit health_changed
PlayerHUD 是否连接 health_changed
player 引用有没有拖给 UI
HPBar 路径是否正确
HPBar max_value 和 value 是否设置
临时打印:
func _on_player_health_changed(current_hp: int, max_hp: int):
print("HUD 更新血量:", current_hp, "/", max_hp)
如果不打印,说明信号没连上。
44. 常见错误 2:玩家一瞬间被怪物秒杀
原因:
没有受伤无敌
怪物 AttackArea 多帧持续命中
多个怪物同时攻击
解决:
Player 加 is_invincible
受伤后 start_invincibility()
无敌期间 take_damage 直接 return
45. 常见错误 3:无敌闪烁结束后角色不可见
通常是异步闪烁循环冲突。
解决:
使用 flash_token
stop_invincible_flash() 最后 animated_sprite.visible = true
死亡和重生时也确保 visible 状态正确
46. 常见错误 4:死亡后还能受伤
检查:
take_damage 开头是否判断 DEAD
DEAD 状态是否关闭 Hurtbox
怪物是否还在重复调用 take_damage
Player 里应该有:
if current_state == PlayerState.DEAD:
return
47. 常见错误 5:重生后血条还是 0
respawn 里必须:
current_hp = max_hp
health_changed.emit(current_hp, max_hp)
如果只改血量,不 emit,UI 不知道你复活了。
48. 常见错误 6:怪物血条位置不对
怪物血条如果是子节点,它的位置是相对怪物的。
可以调整:
HealthBar.position = Vector2(-16, -32)
如果你的怪物图片比较高,就把 y 调得更负一点:
Vector2(-16, -42)
49. 常见错误 7:怪物死亡后血条还留着
Enemy 的 died 信号要发出:
died.emit()
HealthBar 要监听:
enemy.died.connect(_on_enemy_died)
死亡时:
visible = false
如果怪物直接 queue_free(),血条作为子节点也会被删除,一般没问题。
但如果你播放死亡动画,血条可能还显示,所以要隐藏。
50. 这一部分最重要的记忆点
血量系统核心是 max_hp、current_hp、take_damage、heal
血量变化应该用 signal 通知 UI
玩家血条放 CanvasLayer 的 HUD 里
怪物血条放怪物节点下面,跟着怪物移动
ProgressBar 适合第一版血条
TextureProgressBar 适合更美术化的血条
玩家受伤后要有短暂无敌
无敌期间 take_damage 直接 return
死亡后不要立刻删除 Player
玩家死亡进入 DEAD 状态,显示 DeathPanel
重生时恢复血量、位置、Hurtbox 和控制
怪物死亡可以 queue_free
51. 你现在的小练习
先完成这一套:
1. Player 添加 max_hp / current_hp
2. Player 添加 health_changed / died 信号
3. Player take_damage 后更新血量并 emit
4. UI 添加 PlayerHUD
5. PlayerHUD 显示 HPBar 和 HPLabel
6. 怪物攻击玩家后,玩家血条减少
7. Player 加 is_invincible
8. 受伤后 0.8 秒内不能再次受伤
9. UI 添加 DeathPanel
10. 玩家血量为 0 时显示 DeathPanel
11. 点击 RespawnButton 后,玩家回到 PlayerSpawn
12. 玩家血量恢复满
13. Slime 添加 HealthBar
14. 玩家攻击 Slime 后,Slime 血条显示并减少
15. Slime 死亡后消失
完成这部分后,你的 RPG 原型就有了非常完整的战斗基础闭环
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录