Voltar para o Blog
Quest Log

Números de Dano Flutuantes no Godot 4 (Damage Popup com Tween)

Inimigo em cena de RPG 2D com número vermelho subindo acima da cabeça indicando dano recebido

Aprenda a criar números de dano flutuantes no Godot 4 com Tween: label que sobe e some, cor de crítico e spawn a partir da hitbox de combate.

Números de dano flutuantes no Godot são aquele efeito clássico de RPG e jogos de ação: o inimigo toma um golpe e um número salta acima da cabeça dele, sobe alguns pixels e desaparece com fade. É feedback puro. O jogador sabe na hora quanto machucou, se foi crítico e se vale continuar batendo. Neste tutorial você vai montar um damage popup completo no Godot 4 usando Tween, com cena reutilizável, cor especial para crítico e spawn direto do seu sistema de combate.

O efeito parece complexo, mas se resume a três peças: uma cena pequena com um Label, um Tween que anima posição e transparência, e um ponto no código de combate que instancia essa cena na posição do acerto. Vamos por partes.

Criando a cena do número de dano

A base é uma cena isolada, só com o texto. Crie uma nova cena com um nó raiz Node2D chamado DamageNumber e adicione um Label como filho. No Label, escolha uma fonte legível, ative o contorno (outline) nas configurações de tema para o número ler bem sobre qualquer fundo, e centralize o texto com horizontal_alignment e vertical_alignment.

Salve como damage_number.tscn. Anexe um script tipado na raiz. É ele que recebe o valor, escreve no Label e dispara a animação assim que entra na cena.

extends Node2D

@onready var label: Label = $Label

var altura_subida: float = 40.0
var duracao: float = 0.6

func configurar(valor: int, critico: bool = false) -> void:
    label.text = str(valor)
    if critico:
        label.add_theme_font_size_override("font_size", 32)
        label.modulate = Color(1.0, 0.75, 0.1)
    else:
        label.add_theme_font_size_override("font_size", 22)
        label.modulate = Color.WHITE

func _ready() -> void:
    _animar()

func _animar() -> void:
    var alvo: Vector2 = position + Vector2(0.0, -altura_subida)
    var tween: Tween = create_tween()
    tween.set_parallel(true)
    tween.tween_property(self, "position", alvo, duracao)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
    tween.tween_property(self, "modulate:a", 0.0, duracao)\
        .set_ease(Tween.EASE_IN)
    tween.chain().tween_callback(queue_free)

Repare em três detalhes que fazem esse código funcionar de verdade. O set_parallel(true) roda a subida e o fade ao mesmo tempo, e não um depois do outro. O chain() depois do bloco paralelo agenda o queue_free para o fim, garantindo que o nó se destrua sozinho quando a animação acaba. E o modulate:a anima só o canal alpha, mantendo a cor que você definiu no configurar. Se você quer entender a fundo como create_tween, set_parallel e chain se combinam, vale ler o guia de animação por código com Tween no Godot.

Um pouco de variação lateral

Números que sobem em linha reta e perfeita ficam robóticos, ainda mais quando vários aparecem juntos. Um empurrãozinho horizontal aleatório resolve isso e faz o combate parecer mais orgânico.

func _animar() -> void:
    var desvio_x: float = randf_range(-18.0, 18.0)
    var alvo: Vector2 = position + Vector2(desvio_x, -altura_subida)
    var tween: Tween = create_tween()
    tween.set_parallel(true)
    tween.tween_property(self, "position", alvo, duracao)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
    tween.tween_property(self, "modulate:a", 0.0, duracao)\
        .set_ease(Tween.EASE_IN)
    tween.chain().tween_callback(queue_free)

Dando peso ao acerto crítico

Cor e tamanho maiores já separam o crítico do golpe normal, mas um "pop" de escala vende o impacto. A ideia é o número nascer grande, encolher rápido para o tamanho de leitura e então subir. Ajuste o configurar para guardar o estado de crítico e trate a escala no início da animação.

var eh_critico: bool = false

func configurar(valor: int, critico: bool = false) -> void:
    eh_critico = critico
    label.text = str(valor)
    if critico:
        label.add_theme_font_size_override("font_size", 32)
        label.modulate = Color(1.0, 0.75, 0.1)
    else:
        label.add_theme_font_size_override("font_size", 22)
        label.modulate = Color.WHITE

func _animar() -> void:
    var alvo: Vector2 = position + Vector2(randf_range(-18.0, 18.0), -altura_subida)
    var tween: Tween = create_tween()

    if eh_critico:
        scale = Vector2(1.6, 1.6)
        tween.tween_property(self, "scale", Vector2.ONE, 0.15)\
            .set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)

    tween.set_parallel(true)
    tween.tween_property(self, "position", alvo, duracao)\
        .set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
    tween.tween_property(self, "modulate:a", 0.0, duracao)\
        .set_ease(Tween.EASE_IN)
    tween.chain().tween_callback(queue_free)

Quando eh_critico é verdadeiro, o primeiro tween_property roda sozinho (antes do set_parallel), reduzindo a escala com TRANS_BACK para aquele leve estouro elástico. Depois entra o bloco paralelo de subida e fade normal. Golpe comum não paga esse custo e sobe direto.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 7 dias

Spawnando a partir do sistema de combate

A cena existe e se anima sozinha. Falta instanciá-la no momento certo: quando o alvo recebe dano. Se você usa Area2D para acertos, o lugar natural é a hurtbox do alvo, que detecta a hitbox do atacante e resolve quanto de vida perder. Se essa parte ainda não está montada, veja como estruturar as áreas em hitbox e hurtbox com Area2D no Godot.

O ponto crítico aqui é onde você adiciona o popup na árvore. Não coloque como filho do inimigo. Se o golpe mata o alvo e ele chama queue_free, o número morre junto e some no meio do fade. Adicione o popup a um nó da fase que sobreviva, como a raiz do level.

extends Area2D
class_name Hurtbox

const DAMAGE_NUMBER: PackedScene = preload("res://efeitos/damage_number.tscn")

@export var vida_maxima: int = 100
var vida: int = vida_maxima

func receber_dano(valor: int, critico: bool = false) -> void:
    vida = max(vida - valor, 0)
    _mostrar_dano(valor, critico)
    if vida <= 0:
        morrer()

func _mostrar_dano(valor: int, critico: bool) -> void:
    var popup: Node2D = DAMAGE_NUMBER.instantiate()
    get_tree().current_scene.add_child(popup)
    popup.global_position = global_position + Vector2(0.0, -24.0)
    popup.configurar(valor, critico)

func morrer() -> void:
    get_parent().queue_free()

A ordem importa: chame add_child antes de setar global_position, porque a posição global só resolve depois que o nó está na árvore. Chamar configurar por último dispara o _ready e a animação com os valores certos já aplicados.

Determinando o crítico

Quem decide se o golpe é crítico costuma ser o atacante, não o alvo. Uma abordagem simples: role a chance no momento do ataque e passe o resultado para o receber_dano.

@export var dano_base: int = 15
@export var chance_critico: float = 0.2
@export var multiplicador_critico: float = 2.0

func calcular_dano() -> Dictionary:
    var critico: bool = randf() < chance_critico
    var dano: int = dano_base
    if critico:
        dano = int(round(float(dano_base) * multiplicador_critico))
    return {"dano": dano, "critico": critico}

func _on_hitbox_area_entered(area: Area2D) -> void:
    if area is Hurtbox:
        var resultado: Dictionary = calcular_dano()
        area.receber_dano(resultado["dano"], resultado["critico"])

Centralizando num spawner autoload

Chamar get_tree().current_scene.add_child(...) espalhado por várias hurtboxes funciona, mas conforme o projeto cresce você vai querer um único lugar responsável por criar os popups. Um autoload resolve isso e ainda facilita trocar a cena, aplicar um teto de quantos números aparecem por vez ou adicionar variações (cura em verde, dano por veneno em roxo).

Crie damage_spawner.gd, registre em Project Settings > Autoload com o nome DamageSpawner e concentre a lógica de spawn ali.

extends Node

const DAMAGE_NUMBER: PackedScene = preload("res://efeitos/damage_number.tscn")

func mostrar_dano(valor: int, pos: Vector2, critico: bool = false) -> void:
    var popup: Node2D = DAMAGE_NUMBER.instantiate()
    var raiz: Node = get_tree().current_scene
    if raiz == null:
        return
    raiz.add_child(popup)
    popup.global_position = pos
    popup.configurar(valor, critico)

func mostrar_cura(valor: int, pos: Vector2) -> void:
    var popup: Node2D = DAMAGE_NUMBER.instantiate()
    var raiz: Node = get_tree().current_scene
    if raiz == null:
        return
    raiz.add_child(popup)
    popup.global_position = pos
    popup.configurar(valor, false)
    popup.label.modulate = Color(0.3, 1.0, 0.4)

Com o autoload no lugar, a hurtbox fica enxuta. Ela só resolve a vida e delega o visual ao spawner, sem saber nada sobre Tween ou cena de Label.

func receber_dano(valor: int, critico: bool = false) -> void:
    vida = max(vida - valor, 0)
    DamageSpawner.mostrar_dano(valor, global_position + Vector2(0.0, -24.0), critico)
    if vida <= 0:
        morrer()

Essa separação segue a mesma lógica de manter a interface desacoplada da regra do jogo, como quando você conecta a barra de vida sem enfiar UI dentro do personagem. Se estiver montando o resto do combate na tela, o post de HUD de vida e munição no Godot mostra como ligar esses valores à interface principal sem acoplar tudo.

Adaptação para jogos 3D

Em 3D a receita é a mesma, com duas trocas. Use Label3D em vez de Label, com a propriedade billboard em BILLBOARD_ENABLED para o texto sempre encarar a câmera, e anime a posição no eixo Y do Vector3. O fade continua no modulate:a.

extends Node3D

@onready var label: Label3D = $Label3D
var altura_subida: float = 1.2
var duracao: float = 0.8

func configurar(valor: int, critico: bool = false) -> void:
    label.text = str(valor)
    label.modulate = Color(1.0, 0.75, 0.1) if critico else Color.WHITE
    label.font_size = 64 if critico else 40

func _ready() -> void:
    var alvo: Vector3 = position + Vector3(0.0, altura_subida, 0.0)
    var tween: Tween = create_tween()
    tween.set_parallel(true)
    tween.tween_property(self, "position", alvo, duracao)\
        .set_ease(Tween.EASE_OUT)
    tween.tween_property(self, "modulate:a", 0.0, duracao)\
        .set_ease(Tween.EASE_IN)
    tween.chain().tween_callback(queue_free)

Ajustes finos que fazem diferença

Alguns detalhes separam um damage popup amador de um que parece de jogo comercial. Segure a leitura por um instante antes de sumir: adicione um pequeno tween_interval no comeco para o número existir por completo antes de começar a apagar. Limite a fonte a um tamanho que caiba na tela mesmo com quatro dígitos. E, se vários acertos acontecem no mesmo frame, um leve atraso escalonado entre os spawns evita que os números se empilhem ilegíveis no mesmo pixel.

Para dano contínuo (veneno, queimadura, área), reduza a fonte e a opacidade inicial para diferenciar do golpe direto sem poluir a tela. E resista à tentação de exibir cada tick de um dano por segundo: acumule e mostre um número maior a cada intervalo, senão o combate vira uma chuva de dígitos.

Fechando o loop de feedback

Você agora tem uma cena de número de dano reutilizável, animada com Tween, com crítico destacado por cor e escala, e um autoload que centraliza o spawn a partir do seu sistema de combate. É um daqueles efeitos de baixo custo e alto retorno: poucas linhas de GDScript tipado, mas uma diferença enorme na sensação de impacto de cada golpe.

O próximo passo natural é conectar isso ao resto do seu combate: variar a cor por tipo de dano, adicionar som no momento do crítico e sincronizar o popup com a animação de dano do inimigo. Cada uma dessas camadas usa exatamente o mesmo padrão de Tween que você montou aqui. Se quiser aprender a construir sistemas de jogo completos com essa base, dá uma olhada nos cursos da CursoGame.Dev.

Perguntas frequentes

O que são números de dano flutuantes no Godot?

São labels que aparecem sobre o alvo quando ele toma dano, sobem alguns pixels e somem com fade. Servem de feedback visual imediato de combate em RPGs e jogos de ação.

Uso Label ou Label3D para damage numbers?

Em jogos 2D use um Label (ou RichTextLabel) dentro de uma cena separada. Em jogos 3D use Label3D com billboard ativado para o texto sempre encarar a câmera.

Como faço o número subir e sumir ao mesmo tempo?

Crie um Tween com create_tween(), ative set_parallel(true) e rode dois tween_property em paralelo: um animando position para cima e outro animando modulate:a de 1 para 0.

Como destaco um acerto crítico?

Passe um parâmetro critico: bool para o popup. Se for true, aumente a fonte, troque a cor para amarelo ou laranja e adicione um leve scale extra no Tween para dar peso ao golpe.

Onde devo instanciar os números de dano?

No ponto do jogo que resolve o dano, geralmente na hurtbox (Area2D) do alvo ao detectar a hitbox do atacante. Um autoload spawner centraliza a criação e evita repetir código.

Por que meu Label some junto com o inimigo que morreu?

Porque ele é filho do inimigo. Instancie o popup na árvore da fase (ou num autoload), não como filho do alvo, para ele sobreviver e terminar a animação mesmo após a morte.