Como atirar projétil no Godot 4: balas e flechas em 2D

Atirar projétil no Godot 4 em 2D com GDScript tipado: crie a cena Area2D, faça a bala andar, mire no mouse, aplique dano e coloque cooldown de tiro.
Como atirar projétil no Godot 4: balas e flechas em 2D
Quase todo jogo de ação precisa de alguma forma de projétil: a bala de uma nave, a flecha de um arqueiro, a bola de fogo de um mago. E é comum travar bem nesse ponto, porque atirar um projétil no Godot envolve várias peças conversando ao mesmo tempo, do spawn ao dano. Neste tutorial você vai aprender a disparar projétil no Godot 4 em 2D usando GDScript tipado do começo ao fim: montar a cena da bala como uma Area2D, fazer ela andar numa direção, instanciar a partir do jogador mirando no mouse, destruir o projétil ao sair da tela ou ao colidir, aplicar dano no alvo e ainda colocar um cooldown para o tiro não sair infinito.
Cada trecho de código vem explicado linha a linha, e tudo usa tipagem estática, do jeito que deixa o projeto mais robusto e o autocomplete do editor mais esperto.
Por que Area2D é a base do projétil
Antes de escrever qualquer linha, vale entender a escolha do nó. No Godot 4 você tem três candidatos para um projétil 2D: Area2D, CharacterBody2D e RigidBody2D. Para bala, flecha e magia, a Area2D ganha na maioria dos casos.
A Area2D não resolve física de contato: ela não empurra ninguém nem é empurrada. O que ela faz de melhor é detectar sobreposição e disparar sinais quando algo entra nela. Isso encaixa perfeito com um projétil, porque você quer controlar a trajetória você mesmo, de forma previsível, e só precisa saber o instante em que a bala encostou em alguém. O RigidBody2D seria útil se você quisesse uma granada que quica e cai com gravidade, mas para tiro reto ele é complexidade desnecessária.
A cena do projétil tem uma estrutura enxuta, com a Area2D como raiz e três filhos:
# Projetil (Area2D) <- raiz, com o script
# ├── Sprite2D # a aparência da bala
# ├── CollisionShape2D # a forma que detecta o acerto
# └── VisibleOnScreenNotifier2D # avisa quando some da tela
O Sprite2D é a imagem. O CollisionShape2D define a área de colisão, normalmente um CircleShape2D pequeno ou um CapsuleShape2D no caso de uma flecha. E o VisibleOnScreenNotifier2D vai cuidar de limpar o projétil quando ele sai do campo de visão. Salve essa cena como projetil.tscn, porque é ela que o jogador vai instanciar a cada disparo.
Movendo o projétil numa direção
Com a cena montada, o próximo passo é fazer a bala andar. A regra é simples: o projétil recebe uma direção no momento em que nasce e segue reto por ela até morrer. Anexe o script abaixo na raiz Area2D:
extends Area2D
## Velocidade em pixels por segundo.
@export var velocidade: float = 600.0
## Dano aplicado no alvo atingido.
@export var dano: int = 10
## Direção normalizada em que o projétil viaja.
var direcao: Vector2 = Vector2.RIGHT
func _physics_process(delta: float) -> void:
position += direcao * velocidade * delta
Repare na tipagem em tudo. velocidade e dano são @export, então aparecem no Inspetor e você ajusta cada tipo de projétil sem tocar no código: uma flecha lenta, uma bala rápida, uma magia que dói mais.
A variável direcao é um Vector2 normalizado, ou seja, um vetor de comprimento 1 que guarda apenas para onde a bala aponta, sem carregar velocidade. Ela começa como Vector2.RIGHT só como valor padrão, mas quem realmente define isso é o jogador no disparo.
O coração do movimento está no _physics_process(delta: float) -> void. Ele roda a cada frame de física, e delta é o tempo desde o último frame. A conta direcao * velocidade * delta transforma a direção pura em deslocamento real daquele frame, e multiplicar por delta garante que a bala ande na mesma velocidade em qualquer taxa de quadros. Somar em position empurra o projétil um pouquinho por frame, e o resultado é um movimento retilíneo e suave.
Destruindo o projétil ao sair da tela
Se a bala andar para sempre, cada disparo deixa um nó vivo viajando pelo infinito. Depois de um minuto de gameplay, são centenas deles consumindo memória à toa. O VisibleOnScreenNotifier2D resolve isso de graça.
Esse nó dispara o sinal screen_exited no exato momento em que o retângulo dele deixa de ser visível pela câmera. Conecte esse sinal a um método do projétil:
func _ready() -> void:
var notifier: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D
notifier.screen_exited.connect(_ao_sair_da_tela)
func _ao_sair_da_tela() -> void:
queue_free()
No _ready, que roda uma vez quando o projétil entra na cena, pegamos a referência tipada ao VisibleOnScreenNotifier2D com $. A linha notifier.screen_exited.connect(_ao_sair_da_tela) liga o sinal ao nosso método por código, o que é mais explícito e mais fácil de versionar do que conectar pelo editor.
Quando a bala some da tela, _ao_sair_da_tela chama queue_free(). Esse método marca o nó para ser removido com segurança no fim do frame, liberando a memória. É a forma correta de destruir um nó no Godot, muito melhor do que apagar na força.
Aplicando dano ao colidir
Andar e sumir já resolvem meio projétil. Falta a parte importante: acertar alguém e causar dano. Como a raiz é uma Area2D, ela oferece dois sinais de colisão. O body_entered dispara quando um corpo físico, como um CharacterBody2D inimigo, entra na área. O area_entered dispara quando outra Area2D entra, útil se seus inimigos usam hurtbox.
Vamos conectar o body_entered, o caso mais comum de inimigo que anda pelo mapa:
func _ready() -> void:
var notifier: VisibleOnScreenNotifier2D = $VisibleOnScreenNotifier2D
notifier.screen_exited.connect(_ao_sair_da_tela)
body_entered.connect(_ao_acertar_corpo)
func _ao_acertar_corpo(corpo: Node2D) -> void:
if corpo.has_method("receber_dano"):
corpo.receber_dano(dano)
queue_free()
Adicionamos body_entered.connect(_ao_acertar_corpo) ao _ready. Agora, quando a bala toca um corpo, o Godot chama _ao_acertar_corpo e entrega o nó atingido no parâmetro corpo: Node2D.
Dentro do método, corpo.has_method("receber_dano") verifica se o alvo sabe levar dano. Esse cheque é o que impede erros: se a bala encostar numa parede ou num objeto sem esse método, nada quebra, ela só some. Se o alvo tiver o método receber_dano, chamamos corpo.receber_dano(dano) passando o valor de dano do projétil. No fim, queue_free() destrói a bala, porque projétil sumido no impacto é o comportamento esperado da maioria dos jogos.
Do lado do inimigo, o método fica assim, num CharacterBody2D qualquer:
extends CharacterBody2D
@export var vida: int = 30
func receber_dano(quantidade: int) -> void:
vida -= quantidade
if vida <= 0:
queue_free()
Simples e tipado: subtrai a quantidade da vida e, se zerar, o inimigo se remove. Se você quer um sistema de colisão mais elaborado, com áreas dedicadas a dar e receber golpe, vale ver o padrão de hitbox e hurtbox com Area2D, que separa quem causa dano de quem recebe.
Instanciando o projétil a partir do jogador
Com a cena da bala completa, chega a hora do jogador atirar. Atirar significa instanciar: criar uma cópia nova da cena projetil.tscn, posicioná-la na frente do jogador, dar a direção certa e soltá-la na cena. Este script vai no jogador:
extends CharacterBody2D
## A cena do projétil, arrastada no Inspetor.
@export var cena_projetil: PackedScene
## De onde a bala nasce (um Marker2D filho na ponta da arma).
@onready var ponto_de_tiro: Marker2D = $PontoDeTiro
func atirar() -> void:
if cena_projetil == null:
return
var projetil: Area2D = cena_projetil.instantiate()
projetil.global_position = ponto_de_tiro.global_position
projetil.direcao = _direcao_do_tiro()
get_tree().current_scene.add_child(projetil)
func _direcao_do_tiro() -> Vector2:
return (get_global_mouse_position() - global_position).normalized()
A variável cena_projetil é um PackedScene exportado. No Inspetor, você arrasta o arquivo projetil.tscn para esse campo, o que desacopla o código da cena e deixa fácil trocar o tipo de bala.
O @onready var ponto_de_tiro: Marker2D aponta para um Marker2D filho do jogador, posicionado na ponta da arma. Usar um marcador dá controle preciso de onde a bala nasce, evitando que ela apareça no centro do personagem.
No método atirar, a primeira linha protege contra esquecimento: se cena_projetil estiver vazio, o método sai sem quebrar. Depois, cena_projetil.instantiate() cria uma cópia viva da cena e a guardamos numa variável tipada como Area2D. Definimos a posição global da bala igual à do ponto de tiro e injetamos a direção com projetil.direcao = _direcao_do_tiro().
O detalhe importante é o get_tree().current_scene.add_child(projetil). Adicionamos a bala à cena principal, e não como filha do jogador. Se ela fosse filha do jogador, andaria junto quando o personagem se movesse, o que estraga a trajetória. Como filha da cena, ela vive sozinha e segue reto.
Mirando no mouse ou na direção do personagem
A mira mora no método _direcao_do_tiro. A conta (get_global_mouse_position() - global_position).normalized() faz o trabalho todo. O get_global_mouse_position() devolve onde o cursor está no mundo, e global_position é onde o jogador está. Subtrair um do outro dá o vetor que vai do jogador até o mouse. O .normalized() reduz esse vetor para comprimento 1, mantendo só a direção, que é exatamente o que o projétil espera receber. Assim, a bala sempre voa reto na direção do cursor, ideal para jogos de mira livre vistos de cima.
Se o seu jogo é uma plataforma ou um top-down sem mira de mouse, você quer atirar na direção para onde o personagem olha. Basta trocar o método por uma direção guardada conforme o input de movimento:
## Última direção horizontal para onde o jogador virou.
var direcao_olhar: Vector2 = Vector2.RIGHT
func _direcao_do_tiro() -> Vector2:
return direcao_olhar
Aqui você atualiza direcao_olhar no _physics_process do jogador toda vez que ele anda, por exemplo Vector2.RIGHT ao ir para a direita e Vector2.LEFT ao ir para a esquerda. Na hora do tiro, a bala herda esse rumo. As duas abordagens usam o mesmo projétil, muda só de onde vem o Vector2.
Cooldown de tiro com Timer
Falta um problema clássico: sem controle, cada frame com o botão pressionado dispara uma bala, e o jogador vira uma metralhadora sem sentido. O cooldown resolve isso limitando a cadência com um nó Timer configurado como one_shot. Adicione um Timer filho do jogador e conecte assim:
@onready var cooldown: Timer = $Cooldown
func _ready() -> void:
cooldown.one_shot = true
cooldown.wait_time = 0.25
func _process(_delta: float) -> void:
if Input.is_action_pressed("atirar") and cooldown.is_stopped():
atirar()
cooldown.start()
O @onready var cooldown: Timer pega a referência ao Timer. No _ready, marcamos one_shot = true para o Timer rodar uma vez e parar, em vez de repetir, e definimos wait_time = 0.25, ou seja, no máximo um tiro a cada 0,25 segundo, quatro por segundo.
O controle real está no _process. A condição Input.is_action_pressed("atirar") verifica se o botão de tiro está pressionado, usando uma ação que você mapeia no Input Map. O segundo teste, cooldown.is_stopped(), só deixa passar se o Timer não estiver rodando. Quando as duas condições batem, chamamos atirar() e logo cooldown.start(), que liga o Timer. Enquanto ele conta, is_stopped() devolve falso e todo disparo é ignorado. Quando o tempo acaba, o Timer para, is_stopped() volta a ser verdadeiro e o próximo tiro é liberado. Se quiser explorar variações desse padrão, o guia de Timer e cooldown no Godot mostra outras formas de controlar cadência e recargas.
Juntando tudo
Vale recapitular as peças que você montou, porque juntas elas formam um sistema de tiro completo e reaproveitável:
- A cena do projétil é uma
Area2DcomSprite2D,CollisionShape2DeVisibleOnScreenNotifier2D. - O movimento acontece no
_physics_process, somandodirecao * velocidade * deltana posição. - A limpeza vem do
screen_exitedchamandoqueue_free, mais oqueue_freeno impacto. - O dano sai do
body_entered, que chamareceber_danono alvo. - O disparo instancia a cena, define posição e direção e adiciona à cena principal.
- A mira normaliza o vetor até o mouse ou usa a direção do personagem.
- O cooldown usa um
Timerone_shot comis_stoppedpara limitar a cadência.
Esse esqueleto serve para bala, flecha e magia. A partir dele dá para adicionar rastro de partículas, projéteis que perseguem o alvo ou tiros que atravessam vários inimigos. Se quiser aprofundar as variações e ver esse conjunto evoluir para armas diferentes, o post sobre sistema de tiro com projétil segue por esse caminho.
E se você chegou até aqui montando cada peça mas ainda sente falta da base, aquela visão de como as cenas, os nós e o GDScript se encaixam num jogo inteiro, vale começar do princípio com um curso de criação de jogos para iniciantes, que te dá o alicerce para transformar tutoriais soltos em projetos completos. Com o projétil funcionando e tipado, você já tem uma das mecânicas mais reaproveitáveis do desenvolvimento de jogos rodando no seu Godot.
Perguntas frequentes
Como instanciar um projétil no Godot?
Guarde a cena do projétil em uma variável exportada com preload ou load, chame o método instantiate() para criar uma cópia, defina a posição e a direção dessa cópia e adicione ela à árvore com add_child ou, melhor ainda, à cena principal via get_tree().current_scene.add_child(). Assim o projétil vive de forma independente do jogador.
Como fazer o projétil mirar no mouse?
No momento do disparo, calcule a direção com (get_global_mouse_position() - global_position).normalized(). Esse vetor unitário aponta do jogador para o cursor. Passe ele para o projétil e multiplique pela velocidade dentro do _physics_process para mover a bala reto naquela direção.
Area2D ou RigidBody2D para projétil?
Para a maioria dos jogos 2D, Area2D é a melhor escolha: ela detecta sobreposição, dispara sinais de colisão e você controla o movimento à mão, o que deixa a trajetória previsível. RigidBody2D só vale quando você quer física real, como uma granada que quica e sofre gravidade.
Como destruir o projétil ao sair da tela?
Adicione um nó VisibleOnScreenNotifier2D como filho do projétil e conecte o sinal screen_exited ao método queue_free(). Quando a bala sai do campo visível, ela se remove sozinha da memória, evitando que centenas de projéteis fiquem viajando para sempre.
Como dar cooldown no tiro?
Use um nó Timer configurado como one_shot. No disparo, cheque se o Timer está parado com is_stopped(); se estiver, atire e chame timer.start(). Enquanto o Timer roda, novos disparos são ignorados. O intervalo do wait_time controla a cadência de tiro.
Por que meu projétil atinge o próprio jogador?
Isso acontece quando o projétil e o jogador compartilham a mesma collision layer/mask. Coloque o projétil em uma layer própria e configure a mask dele para enxergar só a layer dos inimigos. Você também pode ignorar colisões cujo corpo seja o próprio dono do tiro.


