Objetivo do sistema
Neste capítulo, vamos criar um sistema reutilizável de vida (HP), dano e invencibilidade temporária (i-frames). A ideia é que tanto jogador quanto inimigos usem o mesmo componente de Health, e que o restante do jogo reaja via sinais (por exemplo, tocar som, piscar sprite, aplicar knockback, remover/respawn).
Também vamos impor regras importantes para evitar bugs comuns: prevenir múltiplos hits no mesmo frame, invencibilidade por tempo após levar dano e controle de remoção/respawn de inimigos.
Estrutura sugerida de nós
Você pode adaptar ao seu projeto, mas uma estrutura prática é:
- Player (CharacterBody2D)
- AnimatedSprite2D (ou Sprite2D)
- Hurtbox (Area2D) + CollisionShape2D
- Health (Node) com script Health.gd
- AudioStreamPlayer2D (hit/death)
- Enemy (CharacterBody2D ou Node2D)
- Sprite/AnimatedSprite2D
- Hitbox (Area2D) + CollisionShape2D (para causar dano)
- Hurtbox (Area2D) + CollisionShape2D (para receber dano, se aplicável)
- Health (Node) com Health.gd
Separar Hitbox (causa dano) de Hurtbox (recebe dano) facilita controlar quem bate e quem apanha, além de permitir ataques com espada/projéteis no futuro.
Componente reutilizável: Health.gd
Conceito
O componente Health será um Node simples que guarda HP atual/máximo, aplica dano, controla invencibilidade e emite sinais para o “dono” (player/inimigo) reagir. Assim, o script do Player não precisa “saber” regras de HP, apenas escuta eventos.
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
Script Health.gd
Crie um script em res://scripts/components/Health.gd e anexe em um Node chamado Health dentro do Player e do Enemy.
extends Node
class_name Health
signal damaged(amount: int, from: Node)
signal died(from: Node)
signal healed(amount: int)
@export var max_hp: int = 5
@export var invincibility_time: float = 0.6
var hp: int
var invincible: bool = false
# Prevenção de múltiplos hits no mesmo frame (ou no mesmo tick)
var _last_damage_frame: int = -1
func _ready() -> void:
hp = max_hp
func reset() -> void:
hp = max_hp
invincible = false
_last_damage_frame = -1
func apply_damage(amount: int, from: Node = null) -> bool:
if amount <= 0:
return false
if hp <= 0:
return false
if invincible:
return false
var current_frame := Engine.get_physics_frames()
if _last_damage_frame == current_frame:
return false
_last_damage_frame = current_frame
hp = max(hp - amount, 0)
emit_signal("damaged", amount, from)
if hp == 0:
emit_signal("died", from)
return true
_start_invincibility()
return true
func heal(amount: int) -> void:
if amount <= 0:
return
if hp <= 0:
return
var old := hp
hp = min(hp + amount, max_hp)
var delta := hp - old
if delta > 0:
emit_signal("healed", delta)
func _start_invincibility() -> void:
invincible = true
# Timer leve sem precisar criar nó na cena
await get_tree().create_timer(invincibility_time).timeout
invincible = falsePor que isso resolve problemas comuns?
- Invencibilidade temporária: impede “derreter” HP ao encostar em inimigo por vários frames seguidos.
- _last_damage_frame: evita múltiplos hits no mesmo frame caso duas áreas/colisões disparem eventos simultâneos.
- Sinais: desacopla o componente (HP) do feedback (piscar, som, knockback, morte).
Dano por contato: Hitbox que aplica dano
Conceito
Um inimigo pode ter uma Hitbox (Area2D) que detecta a Hurtbox do jogador. Ao detectar, chama apply_damage no Health do alvo.
Script para a Hitbox do inimigo
No nó Hitbox (Area2D) do inimigo, anexe um script como EnemyHitbox.gd. Ele procura um Health no corpo/área atingida.
extends Area2D
@export var damage: int = 1
func _ready() -> void:
area_entered.connect(_on_area_entered)
body_entered.connect(_on_body_entered)
func _try_damage(target: Node) -> void:
if target == null:
return
# Procura Health no próprio alvo ou em filhos
var health := target.get_node_or_null("Health")
if health == null:
health = target.find_child("Health", true, false)
if health == null:
return
health.apply_damage(damage, owner)
func _on_area_entered(area: Area2D) -> void:
# Se a Hurtbox for uma Area2D, o Health pode estar no pai
_try_damage(area)
_try_damage(area.get_parent())
func _on_body_entered(body: Node) -> void:
_try_damage(body)Observação: isso é propositalmente flexível para funcionar com Hurtbox como Area2D (com o Health no Player) ou com colisão direta no corpo.
Feedback básico no jogador: piscar, knockback e som
Conceito
O Health emite damaged e died. O Player escuta esses sinais e executa feedback: piscar sprite, empurrão (knockback) e som. Assim, o componente Health continua genérico.
Conectando sinais do Health no Player
No script do Player, pegue referência ao nó Health e conecte os sinais (pode ser via editor ou por código). Exemplo por código:
@onready var health: Health = $Health
@onready var sprite: CanvasItem = $AnimatedSprite2D
@onready var sfx_hit: AudioStreamPlayer2D = $SfxHit
@export var knockback_strength: float = 220.0
func _ready() -> void:
health.damaged.connect(_on_damaged)
health.died.connect(_on_died)
func _on_damaged(amount: int, from: Node) -> void:
_play_hit_feedback(from)
func _on_died(from: Node) -> void:
# Aqui você pode desabilitar controles, tocar animação, etc.
queue_free() # ou iniciar respawn do jogador, dependendo do seu jogo
func _play_hit_feedback(from: Node) -> void:
if sfx_hit:
sfx_hit.play()
_apply_knockback(from)
_start_blink()Knockback simples
O knockback pode ser um impulso na direção oposta ao agressor. Se o Player for CharacterBody2D, você pode somar na velocity (ou na variável que você usa para movimento).
func _apply_knockback(from: Node) -> void:
if from == null:
return
if not (from is Node2D):
return
var dir := (global_position - from.global_position).normalized()
velocity += dir * knockback_strengthSe você já controla velocity em outro lugar, o ideal é aplicar o knockback como um “extra” que decai com o tempo (por exemplo, usando uma variável knockback_velocity), mas o exemplo acima funciona como versão mínima.
Piscar sprite durante invencibilidade
Uma forma simples é alternar a visibilidade/modulate por um curto período. Como o Health já controla o tempo de invencibilidade, podemos piscar por um tempo parecido. Exemplo:
func _start_blink() -> void:
# Pisca por um tempo próximo ao da invencibilidade
var blink_time := health.invincibility_time
var t := 0.0
while t < blink_time:
sprite.visible = not sprite.visible
await get_tree().create_timer(0.08).timeout
t += 0.08
sprite.visible = trueSe preferir manter sempre visível, troque por sprite.modulate.a = 0.4 e volte para 1.0 no final.
Recebendo dano no inimigo e removendo/respawn
Conceito
Inimigos também terão Health. Ao morrer, podemos remover (queue_free) ou respawn após um tempo em um ponto definido. O importante é que a morte seja um evento (sinal) e não uma checagem espalhada pelo código.
Reação do inimigo aos sinais
No script do inimigo:
@onready var health: Health = $Health
@onready var sprite: CanvasItem = $AnimatedSprite2D
@export var respawn_enabled: bool = false
@export var respawn_time: float = 2.0
var _spawn_position: Vector2
func _ready() -> void:
_spawn_position = global_position
health.damaged.connect(_on_damaged)
health.died.connect(_on_died)
func _on_damaged(amount: int, from: Node) -> void:
# Feedback mínimo no inimigo
sprite.modulate = Color(1, 0.6, 0.6)
await get_tree().create_timer(0.08).timeout
sprite.modulate = Color(1, 1, 1)
func _on_died(from: Node) -> void:
if respawn_enabled:
await _respawn()
else:
queue_free()
func _respawn() -> void:
# Desativa temporariamente colisões/visibilidade
visible = false
set_physics_process(false)
set_process(false)
await get_tree().create_timer(respawn_time).timeout
global_position = _spawn_position
health.reset()
visible = true
set_physics_process(true)
set_process(true)Se o inimigo tiver Hitbox/Hurtbox, você pode também desativar monitoring/monitorable das Areas durante o tempo morto para evitar interações invisíveis.
Regra extra: evitando dano contínuo por contato
Mesmo com invencibilidade, às vezes você quer que o contato cause dano apenas quando “encosta” (entrada) e não enquanto permanece sobreposto. Para isso, prefira usar area_entered/body_entered (como no exemplo) em vez de checar sobreposição em _physics_process.
Se você precisar de dano por permanência (ex.: lava), implemente um tick de dano com timer e respeite invencibilidade ou um cooldown próprio do hazard.
Mini-arena de testes (prática guiada)
1) Montar a arena
- Crie uma cena Arena.tscn com um Node2D como raiz.
- Adicione um chão/parede (TileMap ou StaticBody2D) apenas para limitar movimento.
- Instancie o Player no centro.
- Instancie 2 ou 3 Enemies em posições diferentes.
2) Configurar HP e dano
- No Health do Player:
max_hp = 5,invincibility_time = 0.6. - No Health do Enemy:
max_hp = 2,invincibility_time = 0.2(opcional). - No Hitbox do Enemy:
damage = 1.
3) Teste 1: contato causa dano uma vez e ativa i-frames
- Rode a cena e encoste no inimigo.
- Verifique: HP diminui 1, toca som, sprite pisca, e não perde HP continuamente enquanto estiver encostado (por causa da invencibilidade).
4) Teste 2: prevenção de múltiplos hits no mesmo frame
- Coloque dois inimigos com Hitbox sobrepostos no mesmo ponto.
- Encoste nos dois ao mesmo tempo.
- Verifique: o Player não toma “dano duplo instantâneo” no mesmo frame; no máximo um hit por frame (e depois i-frames).
5) Teste 3: morte e remoção/respawn do inimigo
- Faça o inimigo receber dano (por exemplo, se você já tem ataque do jogador; caso não tenha, você pode temporariamente chamar
$Enemy/Health.apply_damage(1, $Player)via debug ou um botão). - Ao chegar em 0 HP, verifique: inimigo some (queue_free) ou respawna após
respawn_timese habilitado.
6) Teste 4: knockback
- Encoste no inimigo vindo de diferentes direções.
- Verifique: o Player é empurrado para longe do inimigo, sem “puxar” para dentro.
Dicas de depuração rápida
- Mostre HP no Output: no sinal
damaged, façaprint("HP:", health.hp)temporariamente. - Se o dano não dispara, confira: CollisionShape2D habilitado, camadas/máscaras corretas e se a Area2D está com
monitoringligado. - Se o Player fica invisível, garanta que o blink sempre restaura
visible = trueao final.