第十二部分:血量系统与 UI:玩家血条、怪物血条、受伤无敌、死亡重生

2026-05-19
416814 分钟
...

这一部分继续补“战斗反馈”。

前面我们已经有了:

玩家攻击怪物
怪物扣血
怪物死亡

怪物发现玩家
追击玩家
攻击玩家
玩家扣血

但现在还缺几个关键东西:

玩家血量怎么显示
怪物血量怎么显示
玩家受伤后会不会被连续秒杀
死亡后怎么办
重生怎么做

这一节我们就围绕这些来讲。

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

TextureProgressBarProgressBar 类似,但它使用贴图来显示进度,官方文档也明确说它适合用来做加载条、生命条或耐力条。(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_hpcurrent_hptake_damageheal
血量变化应该用 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. 玩家攻击 SlimeSlime 血条显示并减少
15. Slime 死亡后消失

完成这部分后,你的 RPG 原型就有了非常完整的战斗基础闭环

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
godot2026-07-27
Godot 4 常用 UI 节点详解
Godot 4 常用 UI 节点详解 在 Godot 4 中,UI 系统基于 Control(控件) 节点构建。所有 UI 节点都继承自 Control,形成一棵完整的 UI 树。与游戏引擎中常见的"Canvas + DOM"模式不同,Godot 的 UI 系统是声明式的——你在场景中搭好节点树,引擎自动完成布局计算。 一、布局系统:Container 家族 Container 是 Godot UI 的 骨架 。它决定了子节点的大小和位...
学习
godot2026-07-09
Godot 4 自动地形系统(AutoTileSet / Terrains)完全指南
前言 在 2D 游戏开发中,地形瓦片(tile)的拼接是一个绕不开的问题。想象一下:你有一片草地、一条河流、一段平台——如果每一块边缘、角落、过渡区域都要手动选择对应的瓦片图,工作量将是巨大的。 自动地形系统 就是为了解决这个问题而生的。 一、什么是自动地形(Autotiling)? 自动地形的核心思想很简单: 你只管画,引擎帮你选对瓦片 。 当你在 TileMap 上绘制地形时,引擎会自动检测每个瓦片的上下左右邻居,然后根据预设的规则...
学习
godot2026-06-25
Godot 中 zindex 和 ysort 的区别总结
在 Godot 2D 游戏开发中,角色、树木、怪物、地面、技能特效、UI 都需要正确的显示顺序。比如角色走到树前面时,角色应该挡住树;角色走到树后面时,树又应该挡住角色。 这种显示顺序主要和两个概念有关: 和 。 其中 用来手动控制图层顺序, 用来根据物体的 Y 坐标自动排序。 一句话理解 是手动分层。 是根据 Y 坐标自动排序。 简单来说: | 属性 | 作用 | 适合场景 | | | | | | | 数值越大,显示越靠前 | 地面、...
学习
godot2026-06-11
Tileset 资源图的标准和规范
一、什么是 Tileset 资源图 Tileset,中文通常叫“图块资源图”或“瓦片图”,是 2D 游戏中非常常见的一种地图资源组织方式。 简单来说,Tileset 就是把很多小图块按照固定尺寸排列在一张图片里。游戏引擎会按照固定的格子大小去切割这张图片,然后把每个小格子当成一个独立的地图块使用。 比如一个 32×32 像素的 Tileset 中,每一个 tile 都是 32×32 像素。地图编辑器或游戏引擎会按照 32×32 的网格,...
学习
godot2026-05-29
用户角色精灵图制作角色的完整流程
在 2D RPG 游戏里,角色通常不是用一张单独图片完成的,而是用一张“角色精灵图”来做。 所谓角色精灵图,通常是一张包含多个动作帧的大图。比如角色向下走有 4 帧,向左走有 4 帧,向右走有 4 帧,向上走有 4 帧。Godot 会根据这些帧不断切换图片,看起来角色就动起来了。 这篇文章主要介绍:拿到一张角色精灵图之后,如何在 Godot 中把它做成一个可以正常移动、播放动画、和地图产生遮挡关系的角色。 一、先理解角色精灵图是什么 角...
学习
godot2026-05-26
Godot 节点系统详细介绍
Godot 里最核心的东西不是“类”,也不是“组件”,而是 节点 Node 。 你可以把 Godot 的节点理解成: 在前端里,一个页面是由很多 DOM 元素组成的; 在 Godot 里,一个游戏场景是由很多 Node 节点组成的。 比如一个玩家角色,可能不是一个单独对象,而是这样的结构: 这里的 是根节点,下面挂着显示图片、播放动画、碰撞检测、摄像机、音效等子节点。 Godot 官方文档也把节点和场景放在一起讲:多个节点组成树状结构后...
学习

评论

请登录后发表评论

去登录
加载评论中...