Míssil Teleguiado na Godot 4: Projétil que Persegue o Alvo em 2D

Míssil teleguiado na Godot 4: crie um projétil 2D que persegue o alvo com lerp_angle, taxa de giro limitada, aquisição de alvo e explosão ao colidir.
Míssil teleguiado é daqueles efeitos que parecem exigir matemática pesada, mas na Godot 4 o núcleo cabe em umas dez linhas de GDScript. Neste tutorial você vai montar um míssil teleguiado 2D completo: a cena do projétil, a aquisição do inimigo mais próximo, a perseguição com taxa de giro limitada (o detalhe que separa um míssil justo de um míssil injusto), a perda de alvo, o tempo de vida com autodestruição e a explosão ao colidir. Todo o código é GDScript 4 tipado, sem API inventada.
Se você ainda não tem um projétil básico funcionando, vale ler antes como atirar projéteis na Godot. O míssil deste post é a evolução natural daquele projétil reto.
A matemática do míssil teleguiado sem mistério
O comportamento inteiro se resume a duas perguntas por frame de física:
- Para onde está o alvo? Subtraia posições:
alvo.global_position - global_positiondá o vetor do míssil até o alvo. O métodoangle()desse vetor devolve o ângulo dessa direção em radianos. - Quanto eu posso girar neste frame? Aqui mora o design. Se você simplesmente fizer
rotation = direcao.angle(), o míssil vira instantaneamente e nunca erra. Isso não é um míssil, é um raio laser com sprite de míssil.
A solução é limitar a taxa de giro: o míssil pode girar no máximo N radianos por segundo em direção ao alvo. Com giro limitado, o míssil descreve curvas com raio mínimo. Um jogador atento espera o míssil se aproximar e corta na perpendicular no último instante: a curva do míssil não fecha a tempo e ele passa direto. Esse é o clássico "esperar e cortar" de jogos de nave, e é o que torna o míssil esquivável e divertido em vez de uma sentença de morte.
A Godot te dá duas ferramentas prontas para isso:
rotate_toward(de, para, quantidade): gira do ângulo atual em direção ao ângulo desejado, no máximoquantidaderadianos, sempre pelo caminho mais curto. Taxa constante, comportamento previsível. Disponível a partir da Godot 4.3.lerp_angle(de, para, peso): interpola entre ângulos respeitando a volta em 2 PI. O giro é proporcional ao erro: rápido quando o míssil aponta longe do alvo, lento quando quase alinhado. Fica com cara de "suavização" e o raio de curva varia, o que dificulta balancear.
Para gameplay, prefira taxa constante. Mais adiante mostro um fallback com wrapf e clampf que funciona em qualquer Godot 4, caso você esteja em uma versão sem rotate_toward.
Montando a cena do míssil
Crie uma cena nova com esta estrutura:
- Missil (Area2D): raiz.
Area2Dé a escolha certa aqui porque o míssil não precisa de física de corpo, só detectar sobreposição e explodir. Se o seu jogo exige que o míssil colida com paredes do cenário usandomove_and_slide, troque porCharacterBody2D, o resto da lógica é igual. - Sprite2D: a arte do míssil. Desenhe (ou rotacione a textura) com o nariz apontando para a direita, porque
rotation = 0na Godot aponta para +X. Isso evita ficar somando 90 graus de correção no código. - CollisionShape2D: uma
CapsuleShape2Ddeitada ou umRectangleShape2Dfino cobre bem o corpo do míssil. - Timer: vai cuidar do tempo de vida. Marque
one_shotcomo ligado.
Na aba de colisão do Area2D, configure as camadas: o míssil fica em uma camada própria de projéteis e a máscara aponta para a camada dos inimigos. Assim ele ignora o jogador e outros projéteis sem nenhum if extra.
Aquisição de alvo: o inimigo mais próximo
Coloque todos os inimigos no grupo inimigos (no editor, aba Node > Groups, ou via add_to_group("inimigos") no _ready do inimigo). Isso já é o padrão se você seguiu o tutorial de spawner de inimigos em waves, que usa o mesmo grupo para controlar a horda.
Para achar o mais próximo, compare distâncias com distance_squared_to em vez de distance_to. A distância ao quadrado preserva a ordem (se A está mais perto que B, o quadrado de A também é menor) e evita calcular raiz quadrada para cada inimigo, todo disparo:
func adquirir_alvo() -> void:
var inimigos: Array[Node] = get_tree().get_nodes_in_group("inimigos")
var mais_proximo: Node2D = null
var menor_dist_sq: float = INF
for inimigo: Node in inimigos:
var inimigo_2d := inimigo as Node2D
if inimigo_2d == null:
continue
var dist_sq: float = global_position.distance_squared_to(inimigo_2d.global_position)
if dist_sq < menor_dist_sq:
menor_dist_sq = dist_sq
mais_proximo = inimigo_2d
alvo = mais_proximo
Variações úteis: filtrar por um raio máximo de aquisição (if dist_sq > raio * raio: continue) ou por cone à frente do míssil (compare o dot da direção do míssil com a direção até o inimigo). Para um primeiro míssil, o mais próximo global resolve.
Perseguição no _physics_process
Aqui está o script completo do míssil. Cole em missil.gd na raiz Area2D:
extends Area2D
@export var velocidade: float = 320.0
@export var taxa_de_giro: float = 3.0
@export var tempo_de_vida: float = 4.0
@export var readquirir_ao_perder: bool = false
var alvo: Node2D = null
@onready var timer_vida: Timer = $Timer
func _ready() -> void:
body_entered.connect(_ao_colidir_corpo)
area_entered.connect(_ao_colidir_area)
timer_vida.timeout.connect(explodir)
timer_vida.start(tempo_de_vida)
adquirir_alvo()
func _physics_process(delta: float) -> void:
if alvo != null and not is_instance_valid(alvo):
alvo = null
if readquirir_ao_perder:
adquirir_alvo()
if alvo != null:
var angulo_alvo: float = (alvo.global_position - global_position).angle()
rotation = rotate_toward(rotation, angulo_alvo, taxa_de_giro * delta)
global_position += Vector2.RIGHT.rotated(rotation) * velocidade * delta
Pontos que importam nesse código:
- Velocidade constante, curva limitada. O míssil sempre anda
velocidadepixels por segundo na direção em que o nariz aponta (Vector2.RIGHT.rotated(rotation)). A perseguição acontece só na rotação, nunca teleportando o vetor de movimento. É isso que produz aquelas curvas de perseguição bonitas em vez de um projétil grudento. taxa_de_giroem radianos por segundo. Com3.0, o míssil leva pouco mais de um segundo para dar meia-volta (PI radianos). Multiplicar pordeltamantém o giro independente do framerate.rotate_towardescolhe o caminho mais curto entre o ângulo atual e o desejado, tratando a passagem de 180 para -180 graus sem você se preocupar com wrap.
Se a sua versão da Godot 4 não tem rotate_toward (ela chegou na 4.3), o mesmo comportamento sai com funções que existem desde a 4.0:
var angulo_alvo: float = (alvo.global_position - global_position).angle()
var erro: float = wrapf(angulo_alvo - rotation, -PI, PI)
rotation += clampf(erro, -taxa_de_giro * delta, taxa_de_giro * delta)
wrapf normaliza o erro de ângulo para o intervalo -PI a PI (caminho mais curto) e clampf limita o giro do frame. E se você quiser o giro proporcional estilo suavização, a linha vira rotation = lerp_angle(rotation, angulo_alvo, 5.0 * delta). Teste os dois e sinta a diferença: o lerp_angle fecha curva agressivamente de longe e amolece de perto, o que costuma deixar o míssil ou fácil demais ou injusto demais dependendo da distância.
Perda de alvo: seguir reto ou readquirir
Quando o inimigo perseguido morre, a referência alvo passa a apontar para um objeto liberado da memória. Comparar com null não basta: um objeto liberado não é null, e acessar global_position nele gera erro. Por isso o script checa is_instance_valid(alvo) todo frame antes de usar a referência.
Ao perder o alvo, existem duas escolhas de design, e o @export var readquirir_ao_perder deixa você alternar por míssil:
- Seguir reto (
false): o míssil mantém a última direção e voa até o tempo de vida acabar. É o comportamento mais justo quando o míssil é inimigo, porque destruir a origem do perigo remove o perigo. - Readquirir (
true): o míssil chamaadquirir_alvo()de novo e passa a perseguir o próximo inimigo mais próximo. Ótimo para mísseis do jogador em um shmup, onde o jogador quer que cada disparo renda.
Tempo de vida e explosão ao colidir
Sem tempo de vida, um míssil que perdeu o alvo voa para sempre e vaza memória de cena. O nó Timer resolve: start(tempo_de_vida) no _ready e, no timeout, o míssil explode sozinho. Quatro segundos é um bom ponto de partida.
A colisão usa os dois sinais do Area2D: body_entered para inimigos que são CharacterBody2D ou RigidBody2D, e area_entered para inimigos ou hurtboxes que são Area2D. Complete o script com:
func _ao_colidir_corpo(corpo: Node2D) -> void:
if corpo.is_in_group("inimigos"):
if corpo.has_method("receber_dano"):
corpo.receber_dano(3)
explodir()
func _ao_colidir_area(area: Area2D) -> void:
if area.is_in_group("inimigos"):
explodir()
func explodir() -> void:
set_physics_process(false)
# Aqui entra o efeito: instancie uma cena de explosao,
# toque um som, aplique dano em area se quiser.
queue_free()
O set_physics_process(false) antes do queue_free evita que o míssil continue andando e colidindo no mesmo frame da morte. Se você quiser dano em área, a explosão vira uma segunda cena Area2D com raio maior, instanciada na posição do míssil antes do queue_free: o míssil morre, a explosão vive por alguns décimos de segundo e aplica dano em quem encostar.
Um rastro de fumaça fica ótimo com um GPUParticles2D filho emitindo para trás, ou um Line2D que vai acumulando pontos da trajetória. É polimento, não afeta a lógica.
Balanceamento: taxa de giro contra velocidade
A dupla velocidade e taxa_de_giro define sozinha a dificuldade de esquiva, e vale entender a relação:
- Rápido com giro baixo (por exemplo 450 e 1.5): míssil letal em linha reta, mas qualquer curva do jogador o despista. Raio de curva enorme.
- Lento com giro alto (por exemplo 200 e 5.0): persegue como cachorro, quase impossível despistar na curva, mas o jogador simplesmente corre mais que ele ou o leva para uma parede.
- O meio termo (320 e 3.0): exige a manobra clássica de esperar chegar perto e cortar na perpendicular. É onde o míssil fica interessante.
Uma regra prática: o raio mínimo de curva do míssil é velocidade / taxa_de_giro em pixels. Com 320 e 3.0, cerca de 107 pixels. Se esse raio for menor que a hitbox do jogador somada à margem de reação, o míssil vira acerto garantido, então suba a velocidade ou desça o giro.
Daí saem as contramedidas como ideia de design: um item de flare pode spawnar um Node2D no grupo inimigos temporariamente (se o míssil for inimigo, o grupo do alvo do míssil) para roubar a aquisição; um dash do jogador pode zerar a aquisição dos mísseis próximos, forçando o seguir reto; ou o míssil pode ter giro que decai com o tempo, começando obediente e terminando teimoso. Todas essas mecânicas são só variações de mexer em alvo e taxa_de_giro em tempo real.
Próximos passos
Com o míssil funcionando, os encaixes naturais são:
- Integrar o míssil como arma especial em como fazer um jogo de nave shmup, com munição limitada para não trivializar o jogo.
- Fazer os inimigos das waves do seu spawner de inimigos em waves dispararem mísseis contra o jogador nas rodadas avançadas.
- Voltar ao básico de como atirar projéteis na Godot e unificar míssil e tiro reto numa hierarquia: o tiro reto é só um míssil com
taxa_de_girozero.
O padrão que você montou aqui, apontar gradualmente para um alvo com giro limitado, serve para muito além de mísseis: inimigos voadores que perseguem, peixes que nadam até a isca, câmera que segue suave. A matemática é a mesma, muda o sprite.
Perguntas frequentes
Qual a diferença entre lerp_angle e rotate_toward na Godot 4?
lerp_angle interpola por proporção: o míssil gira rápido quando o erro de ângulo é grande e devagar quando é pequeno. rotate_toward gira uma quantidade fixa de radianos por chamada, o que dá taxa de giro constante e previsível. Para míssil teleguiado esquivável, prefira taxa constante (rotate_toward ou o fallback com wrapf e clampf).
Devo usar Area2D ou CharacterBody2D para o míssil teleguiado?
Para a maioria dos casos, Area2D basta: o míssil não precisa deslizar em paredes nem reagir a física, só detectar o toque e explodir. Use CharacterBody2D se quiser que o míssil colida com o cenário usando move_and_slide antes de chegar ao alvo.
Como fazer o míssil escolher o inimigo mais próximo?
Coloque os inimigos em um grupo chamado inimigos, percorra get_tree().get_nodes_in_group("inimigos") e compare distâncias com distance_squared_to, que evita a raiz quadrada e é mais barato. Guarde o nó com a menor distância como alvo.
O que acontece quando o alvo do míssil morre?
A referência aponta para um objeto liberado, então cheque is_instance_valid(alvo) todo frame. Ao perder o alvo, você escolhe o comportamento: seguir reto na direção atual (mais justo para o jogador) ou chamar a aquisição de novo e perseguir o próximo inimigo (mais agressivo).
Como deixar o míssil teleguiado possível de esquivar?
Limite a taxa de giro. Se o míssil é rápido mas faz curva fechada demais, ele nunca erra e vira frustração. Com giro limitado, o jogador espera o míssil chegar perto e corta na perpendicular: o raio de curva do míssil não acompanha e ele passa reto. Ajuste velocidade e taxa de giro em conjunto.


