Voltar para o Blog
Quest Log

Sistema de Vida e Dano na Unity: Hit, Invencibilidade e Morte

Personagem 2D piscando em branco ao levar um golpe, com corações de vida acima da cabeça

Unity health system reaproveitável: componente Health com eventos, interface IDamageable, invencibilidade com piscar, knockback e morte, em C# do Unity 6.

Ferramenta:UnityC#

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), PlayerDeath ou EnemyDeath (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:

  1. A checagem de IsDead e IsInvincible fica no topo de TakeDamage. Qualquer golpe depois da morte ou dentro da janela de invencibilidade é ignorado na entrada, não no meio.
  2. 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.
  3. 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.

Curso de Unity
Quer fazer seu primeiro jogo na Unity?

No curso de Unity 6 da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Unity
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

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.