Objetos destrutiveis: quebrar caixas no Godot 4

Aprenda a criar um objeto destrutivel godot com vida, dano por estagios, particulas, som e loot. Tutorial pratico de GDScript para quebrar caixas no Godot 4.
Quebrar uma caixa de madeira e jogar os cacos para os lados continua sendo uma das coisas mais satisfatorias de qualquer jogo 2D. Montar um objeto destrutivel godot que funciona de verdade nao depende de magica: voce precisa de uma variavel de vida, uma funcao de dano, um pouco de troca de sprite e um momento bem cuidado para liberar o no da memoria sem cortar a animacao ou o som no meio. Neste tutorial a gente parte de uma caixa simples e vai adicionando camadas: estagios de rachadura, particulas de poeira, som de impacto, drop de loot e cacos fisicos com RigidBody2D. Tudo em Godot 4 e com codigo que voce pode colar direto no editor.
Objetos destrutiveis: quebrar caixas no Godot 4
A base de qualquer objeto destrutivel godot e a mesma: um corpo com colisao, um Sprite2D para a aparencia e um script que controla a vida. Comece com uma cena bem enxuta. Use um StaticBody2D como raiz se a caixa fica parada no cenario, ou um RigidBody2D se ela ja precisa cair e ser empurrada. Para o exemplo vou usar StaticBody2D, que e o caso mais comum para caixas de obstaculo.
Estrutura da cena:
Caixa(StaticBody2D) com o scriptSprite2D(a textura visual)CollisionShape2D(a colisao)GpuParticles2D(a poeira da quebra)AudioStreamPlayer2D(o som de impacto)
Vida e dano com estagios de rachadura
O coracao do sistema e a funcao receber_dano. Ela desconta a vida, atualiza a aparencia e, quando a vida chega a zero, dispara a sequencia de destruicao. Para os estagios de rachadura, a ideia mais simples e ter uma lista de texturas e escolher a certa de acordo com a porcentagem de dano que a caixa ja sofreu.
extends StaticBody2D
@export var vida_max: int = 3
@export var sprites_dano: Array[Texture2D] = []
@export var loot_cena: PackedScene
@export var caco_cena: PackedScene
@export var quantidade_cacos: int = 5
var vida: int = 0
@onready var sprite: Sprite2D = $Sprite2D
@onready var colisao: CollisionShape2D = $CollisionShape2D
@onready var particulas: GpuParticles2D = $GpuParticles2D
@onready var som: AudioStreamPlayer2D = $AudioStreamPlayer2D
func _ready() -> void:
vida = vida_max
_atualizar_visual()
func receber_dano(qtd: int) -> void:
if vida <= 0:
return
vida = max(vida - qtd, 0)
_atualizar_visual()
if vida == 0:
_destruir()
func _atualizar_visual() -> void:
if sprites_dano.is_empty():
return
# Quanto mais dano, maior o indice da rachadura.
var dano_sofrido := vida_max - vida
var indice := int(float(dano_sofrido) / vida_max * sprites_dano.size())
indice = clamp(indice, 0, sprites_dano.size() - 1)
sprite.texture = sprites_dano[indice]
Repare em dois cuidados. O primeiro if vida <= 0 evita que um segundo golpe no mesmo frame chame a destruicao duas vezes. O segundo e o calculo do indice: ele converte o dano sofrido em uma fatia da lista de sprites, entao a mesma logica serve para 3 ou 8 estagios de rachadura sem mexer no codigo.
Para chamar o dano, qualquer coisa que colida com a caixa pode usar receber_dano. Um exemplo simples vindo de uma area de ataque do jogador:
func _on_area_ataque_body_entered(corpo: Node2D) -> void:
if corpo.has_method("receber_dano"):
corpo.receber_dano(1)
Usar has_method em vez de checar o tipo da classe deixa o sistema generico. Qualquer barril, vaso ou parede fragil que tenha receber_dano reage ao mesmo ataque, e voce nao precisa de heranca complicada.
A sequencia de destruicao sem cortar nada
Aqui mora o erro mais comum. As pessoas chamam queue_free() na mesma linha em que emitem as particulas e tocam o som. O resultado: o no some no mesmo frame, as particulas nunca aparecem e o audio e cortado. A solucao e esconder a parte visivel da caixa, deixar particulas e som rodarem, e so depois liberar o no.
func _destruir() -> void:
# Esconde a caixa, mas mantem o no vivo para particulas e som.
sprite.visible = false
colisao.set_deferred("disabled", true)
# Dispara a poeira.
particulas.one_shot = true
particulas.emitting = true
# Som de impacto.
if som.stream:
som.play()
# Solta o loot e os cacos enquanto a poeira sobe.
_soltar_loot()
_soltar_cacos()
# Espera as particulas terminarem antes de liberar.
await get_tree().create_timer(particulas.lifetime).timeout
queue_free()
Tres detalhes importam. Use set_deferred("disabled", true) na colisao porque desabilitar uma forma de colisao durante o processamento de fisica causa erro no Godot 4; o deferred adia a mudanca para um momento seguro. O one_shot = true faz as particulas emitirem um unico jato em vez de um loop continuo. E o await em cima de particulas.lifetime segura a destruicao pelo tempo exato que uma particula vive, garantindo que nenhuma poeira corte no ar.
Se voce prefere nao depender de await, da para fazer a mesma coisa com um Timer. Conecte o sinal timeout a uma funcao que chama queue_free:
func _destruir_com_timer() -> void:
sprite.visible = false
colisao.set_deferred("disabled", true)
particulas.one_shot = true
particulas.emitting = true
if som.stream:
som.play()
var timer := Timer.new()
timer.one_shot = true
timer.wait_time = particulas.lifetime
add_child(timer)
timer.timeout.connect(queue_free)
timer.start()
Os dois caminhos chegam no mesmo lugar. O await deixa o codigo mais curto e linear; o Timer e util quando voce ja tem um Timer na cena ou quer ajustar o tempo pelo inspetor. Se preferir CpuParticles2D, a API e quase identica: troque o tipo do no e o lifetime continua valendo.
Soltando loot e cacos fisicos
Com a destruicao no lugar, falta a parte que da peso ao momento: o que sobra quando a caixa quebra. O loot e o caso mais simples. Voce instancia uma cena (uma moeda, uma pocao) na posicao da caixa e a adiciona ao mundo, nao a caixa, porque a caixa esta prestes a sumir.
func _soltar_loot() -> void:
if loot_cena == null:
return
var item := loot_cena.instantiate()
get_parent().add_child(item)
item.global_position = global_position
Adicionar o item em get_parent() em vez de add_child(item) direto na caixa e fundamental. Se voce filiar o loot a caixa, o queue_free() da caixa leva o item junto e ele desaparece antes de o jogador pegar.
Os cacos sao o toque que transforma uma quebra simples em algo memoravel. Cada caco e um RigidBody2D pequeno com um pedaco da textura. Voce instancia varios, espalha em angulos diferentes e aplica um impulso para eles voarem.
func _soltar_cacos() -> void:
if caco_cena == null:
return
for i in quantidade_cacos:
var caco: RigidBody2D = caco_cena.instantiate()
get_parent().add_child(caco)
caco.global_position = global_position
# Direcao aleatoria com forca variavel.
var angulo := randf_range(0.0, TAU)
var forca := randf_range(120.0, 260.0)
var direcao := Vector2(cos(angulo), sin(angulo))
caco.apply_central_impulse(direcao * forca)
# Um giro para parecer natural.
caco.angular_velocity = randf_range(-8.0, 8.0)
A cena do caco e bem leve: um RigidBody2D com um Sprite2D pequeno e um CollisionShape2D. Vale dar aos cacos um tempo de vida proprio para nao acumular centenas deles na cena. Um script curto no caco resolve:
extends RigidBody2D
@export var vida_segundos: float = 1.5
func _ready() -> void:
var timer := get_tree().create_timer(vida_segundos)
timer.timeout.connect(queue_free)
Com isso cada caco vive um segundo e meio, cai com a fisica e some sozinho. Se quiser um efeito mais limpo, ative uma transicao de transparencia no caco antes do queue_free, mas mesmo sem isso o resultado ja fica bom.
Ajustando o sentimento da quebra
Sistema funcionando e so metade do trabalho. O que diferencia uma caixa qualquer de uma caixa gostosa de quebrar e o ajuste fino. Comece pelas particulas: um lifetime curto, entre 0.4 e 0.7 segundos, com bastante variacao na velocidade inicial, da aquela explosao rapida de poeira. Evite lifetime longo, porque ele segura a destruicao por tempo demais e o jogo parece travado.
No som, varie levemente o pitch_scale a cada quebra para que dez caixas seguidas nao soem identicas:
func _tocar_som_variado() -> void:
if som.stream == null:
return
som.pitch_scale = randf_range(0.9, 1.1)
som.play()
Para o impacto visual, considere um pequeno tremor de tela quando a caixa quebra perto do jogador, e nunca subestime o valor de um leve atraso entre o golpe e a quebra. Esse atraso de poucos frames da peso ao acerto. Se a caixa some no mesmo instante do toque, o cerebro nao registra o impacto.
Vale lembrar que a mesma estrutura serve para muito alem de caixas. Vasos, barris explosivos, paredes frageis e cristais usam exatamente esse esqueleto: vida, dano, estagios visuais e uma sequencia de destruicao. O que muda sao as texturas, o numero de cacos e o tipo de loot. Se a sua caixa tambem precisa ser movida pelo jogador antes de quebrar, da uma olhada em como empurrar caixas e blocos no Godot, porque a logica de fisica combina muito bem com a destruicao.
E se voce quer mergulhar fundo no lado visual, as particulas merecem um estudo dedicado. O sistema de poeira que usamos aqui e bem basico, mas da para ir muito alem com fluxos de emissao, gradientes de cor e curvas de escala. Veja o guia de particulas com GPUParticles para transformar aquela poeira simples em algo com fumaca, faiscas e fragmentos coloridos.
Juntando tudo
O fluxo completo fica assim. A caixa nasce com vida cheia e o sprite limpo. Cada receber_dano desconta a vida e troca a textura para um estagio mais rachado. Quando a vida zera, a caixa esconde o sprite, desativa a colisao com seguranca, dispara particulas em one_shot, toca o som, solta loot no pai correto, espalha cacos com impulso fisico e so entao, depois de esperar o tempo das particulas, chama queue_free. Cada peca e opcional e independente: voce pode comecar so com vida e dano, e ir somando particulas, som e cacos conforme o jogo pede.
A maior licao deste sistema nao e o codigo em si, e o timing. Quase todo bug de objeto destrutivel vem de liberar o no cedo demais. Mantenha a regra simples: esconda primeiro, espere o efeito, libere por ultimo. Com isso suas caixas vao quebrar do jeito certo, e o jogador vai querer quebrar todas que encontrar pela frente.


