Sistema de Vida e Dano na Unity: Hit, Invencibilidade e Morte
Um sistema de vida na Unity que você reaproveita em todo projeto (o unity health system que aparece em tanto tutorial) tem quatro peças separadas: um componente Health que só cuida de número, uma interface IDamageable para qualquer coisa que leva dano, eventos para avisar o resto do jogo e componentes pequenos que reagem ao golpe: piscar, empurrar e morrer. Aqui você monta as quatro em C# do Unity 6.3 LTS.
O objetivo é que o mesmo Health sirva para o jogador, para o slime e para a caixa de madeira, sem if (tag == "Player") dentro dele. No fim você tem invencibilidade temporária com sprite piscando, knockback com Rigidbody2D e uma morte que não acontece duas vezes.
Separar vida, dano e morte
O erro mais comum no primeiro sistema de vida é um script Player.cs com tudo junto: lê input, move, toma dano, toca som, atualiza HUD, toca animação de morte e recarrega a cena. Funciona até você precisar que o inimigo também tome dano. Aí o código é copiado, as cópias divergem e o bug corrigido no jogador continua no inimigo.
A divisão que aguenta crescer:
- Health: guarda vida atual e máxima, aplica dano, controla invencibilidade, dispara eventos. Não sabe o que é sprite, som ou cena.
- IDamageable: o contrato. Quem ataca só conhece isso.
- Reações:
DamageFlash(pisca),Knockback(empurra),PlayerDeathouEnemyDeath(decide o que morrer significa). Cada uma escuta os eventos do Health.
Morrer no jogador é recarregar o checkpoint. Morrer no inimigo é soltar moeda e voltar ao pool. Morrer na caixa é virar destroços. Mesmo Health, reações diferentes.
Sistema de vida na Unity: componente Health
Crie Health.cs:
using System;
using UnityEngine;
using UnityEngine.Events;
public class Health : MonoBehaviour, IDamageable
{
[SerializeField] private int maxHealth = 5;
[SerializeField] private float invincibilityDuration = 1f;
[SerializeField] private UnityEvent onDied = new UnityEvent();
public event Action<int, int> HealthChanged;
public event Action<Vector2> Damaged;
public event Action Died;
public int Current { get; private set; }
public int Max => maxHealth;
public bool IsDead { get; private set; }
public bool IsInvincible => Time.time < invincibleUntil;
public float InvincibilityDuration => invincibilityDuration;
private float invincibleUntil;
private void Awake()
{
Current = maxHealth;
}
public void TakeDamage(int amount, Vector2 hitSource)
{
if (IsDead || IsInvincible || amount <= 0)
{
return;
}
Current = Mathf.Max(Current - amount, 0);
invincibleUntil = Time.time + invincibilityDuration;
HealthChanged?.Invoke(Current, maxHealth);
Damaged?.Invoke(hitSource);
if (Current == 0)
{
Die();
}
}
public void Heal(int amount)
{
if (IsDead || amount <= 0)
{
return;
}
Current = Mathf.Min(Current + amount, maxHealth);
HealthChanged?.Invoke(Current, maxHealth);
}
public void ResetHealth()
{
IsDead = false;
Current = maxHealth;
invincibleUntil = 0f;
HealthChanged?.Invoke(Current, maxHealth);
}
private void Die()
{
if (IsDead)
{
return;
}
IsDead = true;
Died?.Invoke();
onDied.Invoke();
}
}
Três decisões que evitam bug:
- A checagem de
IsDeadeIsInvinciblefica no topo deTakeDamage. Qualquer golpe depois da morte ou dentro da janela de invencibilidade é ignorado na entrada, não no meio. - Invencibilidade por tempo absoluto (
Time.time), não por coroutine. Não há coroutine para esquecer de parar, e se o objeto for desativado e reativado o cálculo continua certo. ResetHealth()existe desde o começo. Respawn de jogador e inimigo vindo de object pooling precisam dele, e é aqui que o bug de "inimigo nasce morto" costuma morar.
Interface IDamageable para qualquer coisa que leva dano
Crie IDamageable.cs:
using UnityEngine;
public interface IDamageable
{
void TakeDamage(int amount, Vector2 hitSource);
}
O parâmetro hitSource é a posição de quem causou o dano. Ele serve para o knockback saber para onde empurrar e para um efeito de partícula saber de que lado espirrar.
Agora quem ataca não precisa saber o que acertou. Crie DamageOnContact.cs para espinhos, inimigos que machucam ao encostar ou a hitbox da espada:
using UnityEngine;
public class DamageOnContact : MonoBehaviour
{
[SerializeField] private int damage = 1;
[SerializeField] private LayerMask targetLayers;
private void OnTriggerEnter2D(Collider2D other)
{
TryDamage(other);
}
private void OnTriggerStay2D(Collider2D other)
{
TryDamage(other);
}
private void TryDamage(Collider2D other)
{
if ((targetLayers.value & (1 << other.gameObject.layer)) == 0)
{
return;
}
IDamageable target = other.GetComponentInParent<IDamageable>();
if (target != null)
{
target.TakeDamage(damage, transform.position);
}
}
}
GetComponentInParent aceita interface e procura no próprio objeto e nos pais, o que resolve o caso comum do collider estar num filho do personagem. O OnTriggerStay2D faz o espinho continuar machucando quem fica parado em cima dele: o Health já filtra pela invencibilidade, então o dano sai no máximo uma vez por janela.
Eventos com UnityEvent ou Action
O Health tem os dois tipos de evento, e isso é proposital.
UnityEvent (namespace UnityEngine.Events) aparece no Inspector como uma lista onde você arrasta um objeto e escolhe um método. É ótimo para resposta simples sem código: tocar um AudioSource, ativar um GameObject de "Game Over", disparar um ParticleSystem.
event Action é C# puro. Não aparece no Inspector, é mais leve e deixa explícito no código quem escuta quem. É o que os componentes de reação abaixo usam. A regra que eu sigo: sistema que se conecta por código usa Action, efeito que o designer liga e desliga usa UnityEvent.
Sempre faça o += no evento em OnEnable e cancele em OnDisable. Objeto destruído que continua inscrito num evento gera MissingReferenceException no próximo golpe.
Invencibilidade e piscar o sprite
Crie DamageFlash.cs:
using System.Collections;
using UnityEngine;
[RequireComponent(typeof(Health))]
public class DamageFlash : MonoBehaviour
{
[SerializeField] private SpriteRenderer spriteRenderer;
[SerializeField] private float blinkInterval = 0.1f;
private Health health;
private Coroutine blinkRoutine;
private void Awake()
{
health = GetComponent<Health>();
}
private void OnEnable()
{
health.Damaged += HandleDamaged;
}
private void OnDisable()
{
health.Damaged -= HandleDamaged;
spriteRenderer.enabled = true;
}
private void HandleDamaged(Vector2 hitSource)
{
if (blinkRoutine != null)
{
StopCoroutine(blinkRoutine);
}
blinkRoutine = StartCoroutine(Blink(health.InvincibilityDuration));
}
private IEnumerator Blink(float duration)
{
float endTime = Time.time + duration;
while (Time.time < endTime)
{
spriteRenderer.enabled = !spriteRenderer.enabled;
yield return new WaitForSeconds(blinkInterval);
}
spriteRenderer.enabled = true;
blinkRoutine = null;
}
}
O piscar dura exatamente a janela de invencibilidade, lida do próprio Health. Assim o jogador vê o que o código está fazendo: enquanto pisca, não leva dano. Isso é comunicação de regra pelo visual, não só enfeite. O OnDisable religa o sprite para o objeto nunca voltar do pool invisível.
Knockback com força na direção oposta
Crie Knockback.cs:
using System.Collections;
using UnityEngine;
[RequireComponent(typeof(Health), typeof(Rigidbody2D))]
public class Knockback : MonoBehaviour
{
[SerializeField] private float force = 8f;
[SerializeField] private float upwardBias = 0.4f;
[SerializeField] private float controlLockTime = 0.2f;
private Health health;
private Rigidbody2D rb;
public bool IsKnockedBack { get; private set; }
private void Awake()
{
health = GetComponent<Health>();
rb = GetComponent<Rigidbody2D>();
}
private void OnEnable()
{
health.Damaged += HandleDamaged;
}
private void OnDisable()
{
health.Damaged -= HandleDamaged;
IsKnockedBack = false;
}
private void HandleDamaged(Vector2 hitSource)
{
Vector2 away = (Vector2)transform.position - hitSource;
float sideSign = away.x >= 0f ? 1f : -1f;
Vector2 direction = new Vector2(sideSign, upwardBias).normalized;
rb.linearVelocity = Vector2.zero;
rb.AddForce(direction * force, ForceMode2D.Impulse);
StartCoroutine(LockControl());
}
private IEnumerator LockControl()
{
IsKnockedBack = true;
yield return new WaitForSeconds(controlLockTime);
IsKnockedBack = false;
}
}
O ponto central: no Unity 6 a velocidade do Rigidbody2D é linearVelocity (o nome antigo velocity está obsoleto). Zerar antes do AddForce com ForceMode2D.Impulse faz o empurrão ter sempre o mesmo tamanho, esteja o personagem caindo ou correndo contra o golpe.
O IsKnockedBack é o que faz o knockback funcionar de verdade. Seu script de movimento provavelmente escreve rb.linearVelocity todo FixedUpdate. Sem a trava, ele sobrescreve o empurrão no frame seguinte e o golpe parece não ter efeito nenhum. No controller, antes de aplicar o input, coloque if (knockback.IsKnockedBack) return;.
O empurrão usa só o lado (esquerda ou direita) mais um viés para cima. Em plataforma, direção pura do golpe empurra o personagem contra o chão quando o inimigo está um pouco acima, e ele não sai do lugar.
Até aqui cada peça é pequena e testável sozinha. O trabalho de verdade começa quando elas se cruzam no jogo: o knockback briga com o controller, a morte precisa parar o spawner, a HUD precisa ouvir o jogador certo, o respawn tem que resetar tudo. É nessa costura entre sistemas que o projeto solo costuma empacar, e é o tipo de coisa que um curso de Unity com jogo completo deixa visível, porque as peças são montadas juntas e não em vídeos soltos.
Morte separada da lógica de vida
Crie EnemyDeath.cs como exemplo de reação à morte:
using UnityEngine;
[RequireComponent(typeof(Health))]
public class EnemyDeath : MonoBehaviour
{
[SerializeField] private GameObject dropPrefab;
[SerializeField] private float destroyDelay = 0.5f;
[SerializeField] private Animator animator;
private Health health;
private void Awake()
{
health = GetComponent<Health>();
}
private void OnEnable()
{
health.Died += HandleDied;
}
private void OnDisable()
{
health.Died -= HandleDied;
}
private void HandleDied()
{
if (animator != null)
{
animator.SetTrigger("Die");
}
foreach (Collider2D col in GetComponentsInChildren<Collider2D>())
{
col.enabled = false;
}
if (dropPrefab != null)
{
Instantiate(dropPrefab, transform.position, Quaternion.identity);
}
Destroy(gameObject, destroyDelay);
}
}
Desligar os colliders na morte é o que impede o cadáver de continuar machucando o jogador durante a animação. Para o jogador, um PlayerDeath faria outra coisa: desligar o input, mostrar a tela de derrota e chamar o respawn no checkpoint.
Montando no Inspector: jogador, inimigo e caixa
Com os scripts prontos, a montagem é só composição de componentes. Vale fazer uma vez com calma para ver o ganho da separação.
Jogador. No GameObject do jogador, adicione Health (Max Health 5, Invincibility Duration 1), DamageFlash (arraste o SpriteRenderer do filho com o sprite), Knockback (Force 8, Upward Bias 0,4, Control Lock Time 0,2) e um PlayerDeath seu. Coloque o jogador numa layer Player.
Inimigo. Adicione Health (Max Health 3, Invincibility Duration 0,15, só para evitar dano duplo), DamageFlash, Knockback com força menor e EnemyDeath. No filho que tem o collider de contato, adicione DamageOnContact com Target Layers marcando só Player. Assim o inimigo machuca o jogador, mas não machuca outro inimigo que encosta nele.
Caixa destrutível. Só Health (Max Health 1) e um UnityEvent em On Died apontando para um ParticleSystem de lascas e para o SetActive(false) do próprio GameObject. Nenhum script novo. É aqui que o UnityEvent paga o seu lugar.
Espinhos. Um Tilemap ou sprite com BoxCollider2D marcado como Is Trigger e DamageOnContact mirando Player. Como o Health do jogador filtra pela invencibilidade, ficar parado em cima do espinho tira 1 de vida por segundo, e não 60.
Repare que nenhum desses objetos tem código próprio de vida. Se amanhã você decidir que dano crítico existe, muda um lugar só.
Erros comuns: dano duplicado, morrer duas vezes
Dano duplicado no mesmo golpe. O personagem tem dois colliders (corpo e pés), e os dois entram no trigger do espinho no mesmo frame. A invencibilidade do Health já bloqueia o segundo. Se o objeto não deve ter invencibilidade (uma caixa), coloque invincibilityDuration em 0,05 s em vez de 0.
Morrer duas vezes. Sem a flag IsDead, dois golpes no frame em que a vida zera disparam Died duas vezes: dois drops, duas pontuações, duas chamadas de game over. O Health verifica IsDead em TakeDamage e em Die().
OnTriggerEnter2D não dispara. Pelo menos um dos dois objetos precisa de Rigidbody2D, e o collider do espinho precisa de Is Trigger marcado.
O knockback não empurra. É o controller sobrescrevendo linearVelocity, como explicado acima. Ou o Rigidbody2D está como Kinematic, e corpo Kinematic ignora força.
MissingReferenceException depois da morte. Algum script se inscreveu em Died ou Damaged e não cancelou no OnDisable.
Próximo passo: barra de vida na UI
O evento HealthChanged(atual, máximo) já entrega tudo o que uma barra de vida precisa. Um componente de UI escuta o Health do jogador e ajusta o preenchimento de uma Image com fillAmount = atual / (float)maximo. Esse é o tema de barra de vida na Unity.
Se você quer ver a mesma arquitetura em outra engine, o post de sistema de vida e dano no Godot usa sinais no lugar de eventos C#, e o de knockback e invencibilidade no Godot aprofunda a sensação do golpe. A lógica é a mesma, e entender isso vale mais que decorar a sintaxe de uma engine só.
Perguntas frequentes
Como fazer um sistema de vida na Unity?
Crie um componente Health que guarda a vida atual e máxima, recebe dano por um método TakeDamage e dispara eventos quando a vida muda e quando chega a zero. Quem reage (HUD, animação, morte) escuta os eventos, assim o Health não precisa conhecer nenhum desses sistemas.
Para que serve a interface IDamageable na Unity?
Ela deixa o código de ataque falar com qualquer coisa que leva dano (jogador, inimigo, caixa, barril) sem saber o tipo concreto. A bala só pergunta se o alvo tem IDamageable e chama TakeDamage, e cada objeto decide o que fazer com o golpe.
Como fazer knockback 2D na Unity?
Calcule a direção do golpe (posição do alvo menos posição de quem bateu), zere a linearVelocity do Rigidbody2D e aplique AddForce com ForceMode2D.Impulse nessa direção. Bloqueie o controle do jogador por uma fração de segundo, senão o script de movimento sobrescreve o empurrão no frame seguinte.
UnityEvent ou Action em C#: qual usar para eventos de vida?
UnityEvent aparece no Inspector e permite ligar respostas sem código, bom para designer e para efeitos simples. Action em C# é mais leve e fica explícito no código, bom para sistemas que se conectam por script. Dá para usar os dois no mesmo componente.



