Voltar para o Blog
Quest Log

Número de Dano Flutuante na Unity (Damage Popup)

Números de dano amarelos e vermelhos saltando de um inimigo atingido numa arena de jogo

Unity damage popup com TextMeshPro em world space: número que sobe, cresce e some, cor de crítico, virado para a câmera e vindo de ObjectPool, em C# do Unity 6.

Ferramenta:UnityC#

Número de Dano Flutuante na Unity (Damage Popup)

Um unity damage popup é um texto com o valor do golpe que nasce em cima do alvo, sobe, cresce rapidinho e some em menos de um segundo. Na Unity, o jeito leve de fazer é um prefab com o componente TextMeshPro (o de texto 3D, sem Canvas), um script curto de animação e um ObjectPool para reaproveitar os números em vez de criar e destruir um a cada acerto.

Aqui você monta esse prefab, anima subida, escala e fade, diferencia crítico e cura por cor e tamanho, faz o número encarar a câmera em 3D, resolve o número atrás do sprite em 2D e liga tudo a um pool, com código do Unity 6.3 LTS.

O que é damage popup e por que dá game feel

O número flutuante faz duas coisas ao mesmo tempo. Informa (quanto dano saiu, se foi crítico, se o inimigo resiste a fogo) e dá peso ao golpe. Um ataque que acerta em silêncio parece fraco; o mesmo ataque com um número saltando, uma tremidinha e um som parece forte, sem mudar o dano. O post de game feel e juice explica esse efeito em detalhe, e o damage popup é um dos jeitos mais baratos de conseguir.

Os ingredientes de um bom número de dano:

  • Pulo inicial rápido, que desacelera (não uma subida em velocidade constante).
  • Escala que estoura e assenta: começa maior, volta ao tamanho normal em uns 0.1 s.
  • Fade no fim, para não sumir de uma vez.
  • Leve espalhamento lateral, para números seguidos não se empilharem.
  • Hierarquia por cor e tamanho: dano normal branco ou amarelo, crítico maior e vermelho, cura verde.

Prefab do número com TextMeshPro

Existem dois caminhos: TextMeshProUGUI dentro de um Canvas em World Space, ou o componente TextMeshPro 3D direto no mundo. Para damage popup, o 3D é melhor: cada número é um objeto simples com um MeshRenderer, sem Canvas, sem RectTransform para posicionar, e pode nascer em qualquer ponto do mundo.

  1. GameObject > 3D Object > Text - TextMeshPro. Renomeie para NumeroDeDano.
  2. No componente, Font Size em torno de 6 (para um mundo onde o personagem tem 1 a 2 unidades de altura), Alignment centralizado nos dois eixos, Font Style em negrito.
  3. No material, ligue Outline com espessura em torno de 0.2 e cor preta. Número sem contorno some em cenário claro.
  4. Em Extra Settings, ajuste Sorting Layer e Order in Layer (essencial em 2D, explicado nos erros comuns).
  5. Adicione o script NumeroDeDano (abaixo) e arraste o objeto para a pasta Project para virar prefab. Apague da cena.

Script de animação: subir, escalar e sumir

Arquivo NumeroDeDano.cs:

using TMPro;
using UnityEngine;
using UnityEngine.Pool;

public class NumeroDeDano : MonoBehaviour
{
    [SerializeField] private TMP_Text texto;
    [SerializeField] private float duracao = 0.8f;
    [SerializeField] private float velocidadeInicial = 3f;
    [SerializeField] private float desaceleracao = 6f;
    [SerializeField] private float escalaDoPulo = 1.6f;
    [SerializeField] private float tempoDoPulo = 0.12f;

    private IObjectPool<NumeroDeDano> pool;
    private Camera cameraPrincipal;
    private Vector3 velocidade;
    private Vector3 escalaBase;
    private float tempoDeVida;

    private void Awake()
    {
        escalaBase = transform.localScale;
        cameraPrincipal = Camera.main;
    }

    public void DefinirPool(IObjectPool<NumeroDeDano> poolDeOrigem)
    {
        pool = poolDeOrigem;
    }

    public void Mostrar(Vector3 posicao, int valor, Color cor, float multiplicadorDeTamanho)
    {
        transform.position = posicao;
        texto.SetText("{0}", valor);
        texto.color = cor;
        texto.alpha = 1f;

        float desvioLateral = Random.Range(-0.6f, 0.6f);
        velocidade = new Vector3(desvioLateral, velocidadeInicial, 0f);
        escalaBase = Vector3.one * multiplicadorDeTamanho;
        transform.localScale = escalaBase * escalaDoPulo;
        tempoDeVida = 0f;
    }

    private void Update()
    {
        tempoDeVida += Time.deltaTime;

        transform.position += velocidade * Time.deltaTime;
        velocidade = Vector3.MoveTowards(velocidade, Vector3.zero, desaceleracao * Time.deltaTime);

        float progressoDoPulo = Mathf.Clamp01(tempoDeVida / tempoDoPulo);
        transform.localScale = Vector3.Lerp(escalaBase * escalaDoPulo, escalaBase, progressoDoPulo);

        float inicioDoFade = duracao * 0.6f;
        if (tempoDeVida > inicioDoFade)
        {
            texto.alpha = 1f - Mathf.InverseLerp(inicioDoFade, duracao, tempoDeVida);
        }

        if (tempoDeVida >= duracao)
        {
            pool.Release(this);
        }
    }

    private void LateUpdate()
    {
        if (cameraPrincipal != null)
        {
            transform.rotation = cameraPrincipal.transform.rotation;
        }
    }
}

Arraste o próprio componente TextMeshPro do prefab para o campo texto. O que cada parte faz:

  • Velocidade que desacelera. O número nasce subindo a 3 unidades por segundo e MoveTowards puxa a velocidade para zero. Resultado: um pulo que perde força, bem mais natural que subir em linha reta.
  • Escala do pulo. Começa 1.6 vezes maior e volta ao tamanho base em 0.12 s. É o "estouro" que chama o olho.
  • Fade nos últimos 40%. Mathf.InverseLerp transforma o tempo restante numa fração de 1 a 0 aplicada em texto.alpha.
  • SetText("{0}", valor). Monta o número sem criar string nova, importante quando dezenas de números aparecem por segundo.
  • pool.Release(this) no fim, em vez de Destroy. O número volta para o pool e espera o próximo golpe.

Cores para crítico, cura e dano normal

Em vez de espalhar cores pelo código de combate, centralize num lugar só. O gerenciador abaixo tem um método por tipo de número:

  • Normal: branco ou amarelo, tamanho 1.
  • Crítico: vermelho, tamanho 1.4 e, se quiser, um "!" no fim.
  • Cura: verde, tamanho 1.

Cor e tamanho diferentes fazem o jogador ler o tipo do golpe sem ler o número. Num jogo de ação rápida, o jogador mal lê o valor; ele percebe "esse foi grande e vermelho".

Pooling dos números com ObjectPool

O Unity 6 tem pooling nativo em UnityEngine.Pool. O ObjectPool<T> recebe funções para criar, pegar, devolver e destruir, além do tamanho inicial e máximo.

Arquivo GerenciadorDeNumeros.cs:

using UnityEngine;
using UnityEngine.Pool;

public class GerenciadorDeNumeros : MonoBehaviour
{
    public static GerenciadorDeNumeros Instancia { get; private set; }

    [SerializeField] private NumeroDeDano prefabDoNumero;
    [SerializeField] private Color corNormal = Color.white;
    [SerializeField] private Color corCritico = new Color(0.93f, 0.1f, 0.23f);
    [SerializeField] private Color corCura = new Color(0.3f, 0.9f, 0.4f);
    [SerializeField] private Vector3 deslocamentoAcimaDoAlvo = new Vector3(0f, 1.2f, 0f);

    private ObjectPool<NumeroDeDano> pool;

    private void Awake()
    {
        Instancia = this;
        pool = new ObjectPool<NumeroDeDano>(
            createFunc: CriarNumero,
            actionOnGet: numero => numero.gameObject.SetActive(true),
            actionOnRelease: numero => numero.gameObject.SetActive(false),
            actionOnDestroy: numero => Destroy(numero.gameObject),
            collectionCheck: true,
            defaultCapacity: 20,
            maxSize: 100);
    }

    private NumeroDeDano CriarNumero()
    {
        NumeroDeDano numero = Instantiate(prefabDoNumero, transform);
        numero.DefinirPool(pool);
        return numero;
    }

    public void MostrarDano(Vector3 posicaoDoAlvo, int valor, bool critico)
    {
        NumeroDeDano numero = pool.Get();
        Color cor = critico ? corCritico : corNormal;
        float tamanho = critico ? 1.4f : 1f;
        numero.Mostrar(posicaoDoAlvo + deslocamentoAcimaDoAlvo, valor, cor, tamanho);
    }

    public void MostrarCura(Vector3 posicaoDoAlvo, int valor)
    {
        NumeroDeDano numero = pool.Get();
        numero.Mostrar(posicaoDoAlvo + deslocamentoAcimaDoAlvo, valor, corCura, 1f);
    }
}

Coloque o gerenciador num objeto vazio da fase. No código de combate, depois de aplicar o dano:

GerenciadorDeNumeros.Instancia.MostrarDano(transform.position, dano, foiCritico);

O collectionCheck: true faz o pool reclamar se o mesmo número for devolvido duas vezes, o que ajuda a achar bugs durante o desenvolvimento. O maxSize: 100 limita quantos números ficam guardados; se passar disso, os excedentes são destruídos ao voltar. Para entender pooling além dos números, com projéteis e inimigos, o post sobre object pooling na Unity aprofunda o ObjectPool nativo.

Com o pool, o efeito está pronto, mas ainda isolado. Quando ele entra no jogo de verdade, o número precisa nascer da colisão certa, saber se o golpe foi crítico, respeitar a pausa, não aparecer em inimigo já morto e não derrubar o desempenho numa luta com vinte inimigos. Costurar combate, UI e performance num jogo só, sem ninguém apontando o erro, é onde a maioria trava.

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

Quem chama o número: o alvo, não a arma

Uma decisão de arquitetura que evita dor de cabeça: o número de dano deve ser pedido por quem recebe o dano, não por quem ataca. Se a espada, a flecha e a bola de fogo chamam o gerenciador cada uma por conta própria, você repete a mesma linha em todo tipo de ataque e, no dia em que o inimigo ganhar armadura que reduz o dano, os números mostram o valor errado.

O caminho limpo é o componente de vida do inimigo calcular o dano final (com resistência, armadura e crítico já aplicados) e chamar MostrarDano com esse valor. Assim o número sempre mostra o que de fato saiu da barra de vida. Se o seu script de vida dispara um evento quando a vida muda, um componente pequeno no inimigo pode ouvir esse evento e pedir o número, sem o script de vida nem saber que números existem.

Outra vantagem: o alvo sabe onde ele está. A posição do número vem do transform.position do inimigo somada ao deslocamento do gerenciador, então um inimigo alto e um baixo podem ter deslocamentos diferentes se você expuser esse valor por inimigo.

Virar para a câmera em jogos 3D

O LateUpdate do NumeroDeDano já resolve: copia a rotação da câmera para o número. Ele roda depois de a câmera se mover no frame, então o texto não treme. Em 2D com câmera ortográfica fixa, a linha não atrapalha (a rotação da câmera é a de sempre), então o mesmo prefab serve nos dois tipos de jogo.

Não use transform.LookAt(camera.transform). O LookAt aponta o eixo Z do texto para a câmera, e a face visível do TextMeshPro fica do lado oposto: o número aparece espelhado.

Outro detalhe de 3D: números que nascem atrás de paredes ou do próprio inimigo. Um deslocamento na direção da câmera ajuda: some -cameraPrincipal.transform.forward * 0.5f à posição inicial para o número nascer um pouco à frente do alvo.

Erros comuns: número atrás do sprite e números sobrepostos

Número atrás do sprite (2D). O TextMeshPro 3D é desenhado pelo MeshRenderer, que respeita Sorting Layer e Order in Layer como um SpriteRenderer. Se o texto está na camada Default e os personagens numa camada acima, o número some atrás deles. Crie uma Sorting Layer Efeitos acima de tudo e coloque o número nela, em Extra Settings do componente.

Números sobrepostos num ataque rápido. Golpes em sequência nascem no mesmo ponto e viram um borrão. O desvio lateral aleatório do script ajuda. Para combos muito rápidos, outra saída é somar: se o mesmo alvo levou dano há menos de 0.2 s, atualize o número existente em vez de criar outro.

Número que nasce na posição antiga por um frame. Você ativou o objeto antes de posicionar. No gerenciador, o pool.Get() ativa e o Mostrar posiciona na mesma chamada, antes de o frame ser desenhado, então não pisca.

Erro de release duplicado. O collectionCheck acusou que um número voltou duas vezes ao pool. Normalmente é um Release também chamado em outro lugar (por exemplo, ao trocar de cena). Deixe o próprio número decidir quando volta.

Números congelados no ar na pausa. É o comportamento esperado com Time.deltaTime. Se preferir que terminem de sumir durante a pausa, troque por Time.unscaledDeltaTime.

Próximo passo: mais juice no golpe

Com os números funcionando, junte com um flash branco no sprite do inimigo e um pequeno tremor de câmera no crítico, e compare o golpe antes e depois. Se você usa Godot também, os números de dano flutuantes no Godot mostram a mesma ideia com Label3D e Tween, um bom exercício para ver como cada engine resolve animação curta.

Perguntas frequentes

Devo usar TextMeshPro ou TextMeshProUGUI para número de dano?

Para número que nasce no mundo, em cima do inimigo, use o componente TextMeshPro (3D), que não precisa de Canvas. Um Canvas em World Space para cada número é mais pesado e mais trabalhoso de posicionar.

Por que o número de dano aparece atrás do sprite em 2D?

O TextMeshPro 3D é desenhado por um MeshRenderer, que segue Sorting Layer e Order in Layer como os sprites. Coloque o texto numa Sorting Layer acima dos personagens, em Extra Settings no Inspector ou pelo MeshRenderer no script.

Preciso de object pooling para damage popup?

Em jogo com muitos golpes por segundo, sim. Instanciar e destruir um objeto a cada acerto gera lixo de memória e engasgos. O UnityEngine.Pool.ObjectPool do Unity 6 reaproveita os números com poucas linhas de código.

Como fazer o número de dano olhar para a câmera em jogo 3D?

No LateUpdate, copie a rotação da câmera para o número: transform.rotation = camera.transform.rotation. Usar LookAt aponta o verso do texto para a câmera e o número aparece espelhado.