Numeros de dano flutuantes no Godot 4

Aprenda a criar numeros de dano godot flutuantes no Godot 4 com Tween, cores por tipo de dano e instancia limpa via PackedScene neste tutorial pratico.
Os numeros de dano godot que sobem da cabeca do inimigo e somem em meio segundo sao um daqueles detalhes que mudam a leitura de um combate inteiro. Sem eles voce ataca no escuro e nao sabe quanto tirou. Com eles, o jogador entende o ritmo da luta, percebe quando um golpe critico aconteceu e tem aquele retorno visual imediato que torna o combate gostoso. A boa noticia e que o sistema e simples de montar e nao depende de nenhum plugin. Vamos construir do zero, com uma cena reutilizavel, animacao via Tween e variacao de cor por tipo de dano.
Numeros de dano flutuantes no Godot 4
A ideia geral e a seguinte: cada vez que algo recebe dano, voce instancia uma pequena cena que mostra um texto, anima esse texto subindo e desaparecendo, e depois libera a instancia da memoria. Nada de manter um pool gigante ou de controlar dezenas de labels na mao. Voce cria, anima e descarta. O Godot 4 cuida do resto quando voce chama queue_free.
Antes de escrever codigo, vale separar as responsabilidades. A cena do numero de dano nao precisa saber nada sobre quem causou o dano nem sobre regras de combate. Ela so recebe um valor, um tipo e se anima. Quem decide quando mostrar e o objeto que tomou o golpe. Essa separacao deixa o sistema facil de reaproveitar entre inimigos, jogador, objetos destrutiveis e ate torres.
Montando a cena do numero de dano
Crie uma cena nova com um Node2D na raiz e um Label como filho. O Node2D serve de ancora para mover o conjunto, e o Label mostra o texto. Salve como damage_number.tscn. No Label, ajuste a fonte para algo legivel e centralize o texto pelas propriedades de alinhamento horizontal e vertical. Deixe o Node2D na origem, porque vamos posicionar a instancia inteira no momento de spawn.
O script abaixo vai na raiz Node2D. Ele expoe uma funcao configurar que recebe o valor e o tipo, ajusta a cor e dispara a animacao.
extends Node2D
@onready var label: Label = $Label
const ALTURA_SUBIDA: float = 48.0
const DURACAO: float = 0.6
func configurar(valor: int, tipo: String = "normal") -> void:
label.text = str(valor)
label.modulate = _cor_por_tipo(tipo)
_animar()
func _cor_por_tipo(tipo: String) -> Color:
match tipo:
"critico":
return Color(1.0, 0.85, 0.2)
"cura":
return Color(0.35, 0.9, 0.4)
_:
return Color(0.95, 0.3, 0.3)
func _animar() -> void:
var destino_y: float = position.y - ALTURA_SUBIDA
var tween: Tween = create_tween()
tween.set_parallel(true)
tween.tween_property(self, "position:y", destino_y, DURACAO) \
.set_trans(Tween.TRANS_QUAD) \
.set_ease(Tween.EASE_OUT)
tween.tween_property(label, "modulate:a", 0.0, DURACAO) \
.set_delay(DURACAO * 0.4)
tween.chain().tween_callback(queue_free)
Repare em alguns pontos. O create_tween cria um tween vinculado a essa instancia, entao ele e descartado junto quando a cena sai. O set_parallel(true) faz a subida e o fade acontecerem ao mesmo tempo. O fade tem um pequeno atraso pelo set_delay, assim o numero fica visivel por um instante antes de comecar a sumir. No final, chain garante que o queue_free so seja chamado depois que as animacoes terminam, evitando liberar a instancia no meio do movimento.
Cor e leitura por tipo de dano
O match no _cor_por_tipo resolve a diferenciacao visual. Dano normal sai em vermelho suave, critico em amarelo dourado e cura em verde. Voce pode adicionar quantos tipos quiser, como dano de veneno em roxo ou dano verdadeiro em branco. O importante e manter consistencia entre os tipos durante todo o jogo, porque o jogador aprende essas cores rapido e passa a confiar nelas.
Uma variacao comum e deixar o critico maior. Da para escalar o Label no momento da configuracao, somando um efeito de escala ao tween. Para um critico, por exemplo, voce poderia comecar o label em escala 1.4 e animar de volta para 1.0, o que da aquele impacto de pancada. Comece simples e adicione esses temperos depois que a base estiver funcionando.
Evitando que os numeros se empilhem
Se o alvo leva varios golpes rapidos, os numeros nascem todos no mesmo ponto e viram uma mancha ilegivel. A solucao mais barata e variar levemente a posicao X de cada spawn. Um deslocamento aleatorio pequeno espalha os numeros o suficiente para o olho separar um do outro sem que pareca bagunca.
Da para fazer isso no proprio metodo de spawn, antes de configurar. Veja como fica uma funcao auxiliar que cuida da instancia e do posicionamento:
const CENA_DANO: PackedScene = preload("res://ui/damage_number.tscn")
func _mostrar_dano(valor: int, tipo: String, origem: Vector2) -> void:
var numero: Node2D = CENA_DANO.instantiate()
var desvio_x: float = randf_range(-10.0, 10.0)
numero.position = origem + Vector2(desvio_x, -16.0)
get_tree().current_scene.add_child(numero)
numero.configurar(valor, tipo)
O preload carrega a PackedScene uma unica vez, no carregamento do script, e nao a cada golpe. Isso e melhor do que usar load dentro da funcao, que faria o disco trabalhar repetidamente. O randf_range gera o desvio horizontal, e o -16.0 no Y sobe um pouco o ponto de partida para o numero nascer acima do alvo, e nao no centro dele.
Adicionar o numero em get_tree().current_scene em vez de filho do inimigo tem um motivo pratico. Se o inimigo morre e e liberado no mesmo frame, um numero filho dele sumiria junto, no meio da animacao. Colocando na cena principal, o numero termina sua vida tranquilo mesmo que o alvo ja tenha ido embora.
Disparando a partir de receber_dano
Agora a parte que conecta tudo ao combate. O objeto que toma dano costuma ter uma funcao do tipo receber_dano. E dela que chamamos o spawn do numero. Esse codigo fica no script do inimigo ou de qualquer entidade que tenha vida.
extends CharacterBody2D
const CENA_DANO: PackedScene = preload("res://ui/damage_number.tscn")
@export var vida_maxima: int = 100
var vida_atual: int = 100
func _ready() -> void:
vida_atual = vida_maxima
func receber_dano(quantidade: int, critico: bool = false) -> void:
vida_atual -= quantidade
var tipo: String = "critico" if critico else "normal"
_mostrar_dano(quantidade, tipo, global_position)
if vida_atual <= 0:
_morrer()
func receber_cura(quantidade: int) -> void:
vida_atual = min(vida_atual + quantidade, vida_maxima)
_mostrar_dano(quantidade, "cura", global_position)
func _mostrar_dano(valor: int, tipo: String, origem: Vector2) -> void:
var numero: Node2D = CENA_DANO.instantiate()
var desvio_x: float = randf_range(-10.0, 10.0)
numero.position = origem + Vector2(desvio_x, -16.0)
get_tree().current_scene.add_child(numero)
numero.configurar(valor, tipo)
func _morrer() -> void:
queue_free()
A funcao receber_dano recebe a quantidade e um booleano dizendo se foi critico. Ela atualiza a vida, escolhe o tipo com um operador ternario e chama o spawn passando global_position, que e a posicao do inimigo no mundo. A receber_cura segue a mesma logica, mas usa o tipo cura para sair verde. Quem chama receber_dano e o codigo de ataque do jogador ou de uma arma, normalmente apos detectar a colisao do golpe.
Se voce ainda nao tem a parte de vida montada, vale ver o passo a passo de um sistema de vida e dano antes de seguir, porque o numero flutuante e a camada visual que senta em cima dessa logica. Os dois andam juntos.
Refinando o resultado
Com a base pronta, alguns ajustes pequenos elevam bastante a sensacao. O primeiro e o tempo. Numeros que ficam tempo demais na tela poluem o combate, e numeros que somem rapido demais nao sao lidos. O valor de 0.6 segundo do exemplo e um ponto de partida razoavel, mas teste com o seu ritmo de jogo e ajuste a constante DURACAO.
O segundo refino e a curva de movimento. O TRANS_QUAD com EASE_OUT faz o numero subir rapido no comeco e desacelerar no fim, o que parece natural. Se quiser algo mais saltitante, experimente TRANS_BACK, que da uma leve ultrapassada antes de assentar. Vale brincar com as opcoes ate achar o que combina com a sua arte.
O terceiro ponto e combinar o numero com outro feedback no momento do impacto. Um numero subindo fica ainda melhor acompanhado de um piscar branco no sprite atingido. Esse efeito e barato e roda no GPU, e da para ver como montar um shader de hit flash que casa perfeitamente com os numeros. Quando os dois disparam juntos, o golpe ganha peso de verdade.
Cuidados de desempenho
Instanciar e liberar cenas a cada golpe e perfeitamente viavel para a grande maioria dos jogos. O Godot 4 lida bem com isso, e o queue_free agendado pelo tween garante que nada vaza. So comece a pensar em pool de objetos se voce tiver centenas de numeros por segundo na tela, algo tipico de um jogo de horda com muitos inimigos. Nesse caso, em vez de liberar, voce esconderia a instancia e a reaproveitaria.
Outro cuidado e nao criar tweens orfaos. Como o tween e vinculado a instancia via create_tween, ele morre junto com ela, entao esse risco esta coberto no codigo acima. Evite usar tweens criados em nos que sobrevivem por muito tempo sem controle, porque ai sim eles podem se acumular.
Esse sistema cobre o que a maioria dos jogos 2D precisa. Voce tem uma cena reutilizavel, cores por tipo, espalhamento que evita empilhamento e um disparo limpo a partir de receber_dano. A partir daqui, e questao de temperar com escala, som e os efeitos de impacto que combinam com o seu projeto. O esqueleto ja esta de pe e pronto para crescer junto com o seu combate.


