Como Criar Sistema de Vida e Dano no Godot: Health Component Completo

Aprenda a criar um sistema de vida e dano no Godot 4 com Health component reutilizável, hitbox, hurtbox, barra de vida e morte. Tutorial com GDScript real.
Como Criar Sistema de Vida e Dano no Godot: Health Component Completo
Quase todo jogo precisa de um sistema de vida no Godot em algum momento: o player toma dano, o inimigo morre, a barra de HP desce. E quase todo iniciante implementa isso do jeito que dá mais trabalho depois, com uma variável health solta dentro do script do player, outra dentro do script do inimigo, cada uma com sua própria lógica de morte copiada e colada.
Existe um jeito melhor, e ele cabe num único script pequeno: o Health component. Você escreve uma vez, pluga como node filho em qualquer coisa que possa morrer (player, inimigo, caixote destrutível, porta com HP) e conecta o resto por sinal. É composição em vez de herança, e é o padrão que eu uso em projeto comercial.
Nesse tutorial a gente monta o sistema completo em GDScript do Godot 4: o componente de vida, o take_damage, hitbox e hurtbox com Area2D, barra de vida na UI e a morte. Todo código roda como está.
Por que um componente, e não uma variável no player
A tentação é óbvia: abrir o script do player e escrever var health = 100. Funciona hoje. O problema aparece na semana seguinte, quando o inimigo também precisa de vida, e o chefe, e o barril explosivo. Aí você copia a lógica, e agora um bug de dano precisa ser corrigido em quatro lugares.
A alternativa é tratar vida como uma peça plugável. Um node chamado Health que só faz três coisas:
- Guarda quanto de vida tem e qual é o máximo
- Expõe
take_damage()eheal() - Avisa o mundo por sinais quando a vida muda e quando chega a zero
Quem é o dono dessa vida (player, inimigo, objeto) não importa pro componente. E o componente não sabe nada sobre barra de UI, animação de morte ou som de hit. Ele só emite sinais, e cada sistema interessado escuta. Esse desacoplamento é o que faz o sistema escalar sem virar espaguete.
Montando o sistema de vida no Godot: o Health component
Crie um script novo chamado health.gd. Ele estende Node puro, porque vida não tem posição nem aparência:
class_name Health
extends Node
signal health_changed(current: int, max_health: int)
signal damaged(amount: int)
signal healed(amount: int)
signal died
@export var max_health: int = 100
@export var invulnerability_time: float = 0.0
var current: int
var invulnerable: bool = false
func _ready():
current = max_health
func take_damage(amount: int) -> void:
if invulnerable or current <= 0:
return
current = max(current - amount, 0)
damaged.emit(amount)
health_changed.emit(current, max_health)
if current == 0:
died.emit()
elif invulnerability_time > 0.0:
invulnerable = true
await get_tree().create_timer(invulnerability_time).timeout
invulnerable = false
func heal(amount: int) -> void:
if current <= 0:
return
current = min(current + amount, max_health)
healed.emit(amount)
health_changed.emit(current, max_health)
Alguns pontos de design que valem explicar, porque são decisões e não acidente:
class_name Health registra o tipo no projeto. Isso permite checar if node is Health em qualquer lugar e exportar referências tipadas no Inspector, que você vai ver daqui a pouco.
Os guards no topo dos métodos fazem o trabalho sujo. take_damage ignora dano se o dono já morreu ou está invencível. heal se recusa a ressuscitar: curar um cadáver é decisão de game design, e se o seu jogo tiver revive, você cria um método revive() explícito em vez de deixar o heal fazer isso por acaso.
Invencibilidade temporária (i-frames) já vem de fábrica. Deixe invulnerability_time em zero pra inimigos e suba pra algo como 0.5 no player. Sem isso, encostar num inimigo drena a vida inteira em meio segundo, porque o contato gera dano a cada frame de física.
died é um sinal, não um queue_free(). O componente avisa que a vida acabou e pronto. Quem decide o que fazer com a morte é o dono: o inimigo toca animação e some, o player mostra tela de game over, o caixote dropa item. Se o componente deletasse o dono direto, você perderia esse controle.
Aplicando dano: hitbox e hurtbox
Com o Health pronto, falta a pergunta seguinte: quem chama o take_damage? A resposta clássica é o par hitbox e hurtbox, dois Area2D com papéis opostos:
- Hitbox: a área que causa dano. Fica na espada, no projétil, no corpo do inimigo que machuca por contato.
- Hurtbox: a área que recebe dano. Fica em quem pode ser ferido.
Separar os dois resolve um monte de caso real: o sprite da espada pode ser grande mas a hitbox pequena, a hurtbox do chefe pode ignorar o corpo e cobrir só o ponto fraco, e por aí vai.
A hitbox é quase só dados:
class_name Hitbox
extends Area2D
@export var damage: int = 10
A hurtbox detecta hitboxes que entram nela e repassa o dano pro Health do dono:
class_name Hurtbox
extends Area2D
@export var health: Health
func _ready():
area_entered.connect(_on_area_entered)
func _on_area_entered(area: Area2D) -> void:
if area is Hitbox:
health.take_damage(area.damage)
O @export var health: Health é o detalhe elegante aqui: no Inspector aparece um campo onde você arrasta o node Health do mesmo personagem. Nada de get_node com caminho chumbado que quebra quando você reorganiza a cena.
A estrutura de um inimigo fica assim:
Inimigo (CharacterBody2D)
├── Sprite2D
├── CollisionShape2D
├── Health
└── Hurtbox (Area2D)
└── CollisionShape2D
Layers: a parte que todo mundo erra
Hitbox e hurtbox são Area2D, então obedecem collision layer e mask como qualquer corpo físico. E é aqui que nasce o bug clássico: a espada do player acertando a hurtbox do próprio player.
A configuração que evita isso usa camadas dedicadas, separadas das camadas de colisão física:
Camada 5 = hitbox do player Camada 6 = hurtbox do player
Camada 7 = hitbox dos inimigos Camada 8 = hurtbox dos inimigos
Hitbox do player → layer 5, mask 8 (só acha hurtbox de inimigo)
Hurtbox do player → layer 6, mask 7 (só é achada por hitbox de inimigo)
Hitbox de player nunca tem a camada de hurtbox de player na mask. Pronto: dano amigo impossível por construção, sem nenhum if checando quem é quem no código. Configure isso pelos checkboxes do Inspector, em Collision > Layer e Collision > Mask de cada Area2D.
Barra de vida conectada por sinal
A UI nunca deve perguntar a vida a cada frame. O Health já emite health_changed toda vez que algo muda, então a barra só precisa escutar. Use um ProgressBar (ou TextureProgressBar, se quiser arte customizada):
extends ProgressBar
@export var health: Health
func _ready():
max_value = health.max_health
value = health.max_health
health.health_changed.connect(_on_health_changed)
func _on_health_changed(current: int, _max_health: int) -> void:
value = current
Arraste o node Health do player pro campo exportado e acabou. Sem polling, sem acoplamento: se amanhã você trocar o ProgressBar por corações estilo Zelda, o Health nem fica sabendo.
Uma melhoria barata que muda a percepção do jogador: animar a descida da barra com um tween em vez de teleportar o valor.
func _on_health_changed(current: int, _max_health: int) -> void:
var tween := create_tween()
tween.tween_property(self, "value", float(current), 0.25)
Um quarto de segundo de animação e o dano "pesa" mais. É desproporcional o quanto isso melhora o feel pra tão pouco código.
Feedback de dano
Dano sem feedback visual parece bug. O jogador precisa ver que acertou e sentir que apanhou. O sinal damaged existe exatamente pra isso. No script do personagem:
extends CharacterBody2D
@onready var sprite: Sprite2D = $Sprite2D
@onready var health: Health = $Health
func _ready():
health.damaged.connect(_on_damaged)
health.died.connect(_on_died)
func _on_damaged(_amount: int) -> void:
sprite.modulate = Color.RED
await get_tree().create_timer(0.1).timeout
sprite.modulate = Color.WHITE
Um flash vermelho de um décimo de segundo já comunica o hit. Daqui você empilha o que o jogo pedir: um som no mesmo handler, um screen shake, um número de dano flutuando. Tudo escutando o mesmo sinal, sem tocar no componente.
Morte
A morte é só mais um handler, e é onde o desacoplamento paga a conta. Cada dono trata o died do seu jeito.
O inimigo comum desliga a colisão (pra não continuar machucando enquanto a animação roda) e se remove:
func _on_died() -> void:
set_physics_process(false)
$CollisionShape2D.set_deferred("disabled", true)
$Hurtbox/CollisionShape2D.set_deferred("disabled", true)
# Aqui entraria a animação de morte; sem ela, remove direto.
queue_free()
O set_deferred é obrigatório ao desligar colisão: o sinal pode disparar no meio do passo de física, e mexer em colisão nesse momento sem deferir gera erro no console. Se você tiver um AnimationPlayer com animação de morte, toque ela antes e chame queue_free() quando terminar, usando await $AnimationPlayer.animation_finished.
O player, por outro lado, normalmente não some da cena: trava o controle, toca a animação e avisa um game manager pra mostrar a tela de game over ou voltar ao checkpoint. Mesma estrutura, handler diferente. O Health continua idêntico nos dois casos.
Onde esse sistema cresce
O esqueleto que você montou aguenta bastante coisa antes de precisar de refatoração. Alguns caminhos naturais, em ordem de esforço:
Cura e pickups. Um item de vida é um Area2D que chama health.heal(25) no body_entered e se deleta. Cinco linhas.
Tipos de dano e resistência. Troque o int do take_damage por um Resource DamageInfo com quantidade, tipo e knockback. O Health aplica resistências antes de descontar. A assinatura muda uma vez, o resto do sistema sobrevive intacto.
Knockback. A hitbox já conhece sua direção; passe ela junto no dano e deixe o dono do Health aplicar o empurrão no damaged. O componente de vida não precisa saber de física.
Escudo ou armadura. Uma segunda barra que absorve antes do HP. Dá pra fazer dentro do Health ou como um segundo componente na frente dele. Pra escudo regenerável estilo FPS, o segundo componente fica mais limpo.
A regra que mantém tudo em ordem: dado e regra de vida ficam no componente, reação fica em quem escuta os sinais.
Fechando
Sistema de vida e dano no Godot se resume a três peças que não se conhecem: um Health component que guarda número e emite sinal, um par hitbox e hurtbox que decide quem machuca quem via layers, e uma UI que escuta em vez de perguntar. Montou uma vez, todo objeto destrutível do jogo vira questão de arrastar um node e marcar checkboxes.
Meu conselho prático: monte isso num projeto de teste agora, com dois quadrados coloridos se batendo, antes de levar pro seu jogo. Em meia hora você vê o ciclo completo (dano, flash, barra descendo, morte) funcionando, e qualquer dúvida de layer e mask aparece num ambiente onde errar não custa nada. Depois é só copiar os três scripts pro projeto de verdade, porque componente bom é isso: você escreve uma vez e carrega pra todo jogo seguinte.


