Como Criar um Sistema de Tiro e Projéteis no Seu Jogo

Aprenda a criar um sistema de tiro para seu jogo no Godot 4: spawn de projéteis, direção, dano, fire rate e object pooling com código GDScript real.
Como Criar um Sistema de Tiro e Projéteis no Seu Jogo
Atirar é a mecânica mais copiada e mais malfeita que existe. Um sistema de tiro de jogo parece trivial (aperta botão, sai bala), mas é onde iniciante comete os erros que mais doem depois: bala que nasce como filha do player e se move junto com ele, bala que instancia e nunca morre até o jogo engasgar, dano resolvido com if body.name == "Inimigo" espalhado por dez scripts.
Nesse tutorial eu monto o sistema completo no Godot 4, na ordem que eu uso em projeto real: a cena do projétil, o spawn com direção certa, o dano desacoplado, o controle de cadência e, no final, object pooling básico pra quando a quantidade de balas vira problema de performance. Todo código é GDScript e roda como está.
A anatomia de um sistema de tiro
Antes do código, a decisão de arquitetura que define tudo: a bala é uma cena própria. Não é um sprite que o player desenha, não é uma lista de posições num array dentro da arma. É uma cena separada, com seu script, que sabe se mover, detectar acerto e se destruir sozinha.
A estrutura mínima:
Bullet (Area2D)
├── Sprite2D
├── CollisionShape2D
└── VisibleOnScreenNotifier2D
Por que Area2D e não RigidBody2D ou CharacterBody2D? Porque projétil comum não precisa de física: ele anda em linha reta numa velocidade fixa e só precisa saber quando encostou em algo. Area2D detecta overlap e custa bem menos pra engine. RigidBody só vale quando você quer projétil com física de verdade (granada que quica, flecha com arco de gravidade).
O script do projétil:
extends Area2D
const SPEED = 800.0
var direction := Vector2.RIGHT
var damage := 1
func _physics_process(delta):
position += direction * SPEED * delta
func _on_body_entered(body):
if body.has_method("take_damage"):
body.take_damage(damage)
queue_free()
func _on_visible_on_screen_notifier_2d_screen_exited():
queue_free()
Dois sinais precisam estar conectados no editor: o body_entered da própria Area2D e o screen_exited do VisibleOnScreenNotifier2D. O segundo é o que garante que bala que errou o alvo morre ao sair da tela, em vez de viajar pra sempre consumindo memória e física.
Repare também que o movimento está em _physics_process, não em _process. Projétil interage com colisão, então segue a regra de qualquer coisa que toca física: passo fixo, sempre.
Spawn e direção: de onde a bala nasce e pra onde vai
O erro clássico aqui é fazer add_child(bullet) dentro do player. A bala vira filha do player na árvore de cena, e filho herda transformação do pai: o player vira, a bala viaja de lado junto. A bala precisa nascer na cena principal, irmã do player, dona da própria posição no mundo.
O segundo detalhe é o ponto de saída. Adicione um node Marker2D chamado Muzzle na ponta da arma (ou do canhão da nave). É só um ponto de referência, mas evita a bala nascendo no meio do corpo do personagem e colidindo com ele no primeiro frame.
O script de tiro no player:
extends CharacterBody2D
const BULLET = preload("res://bullet.tscn")
@onready var muzzle = $Muzzle
func _process(delta):
if Input.is_action_just_pressed("shoot"):
shoot()
func shoot():
var bullet = BULLET.instantiate()
bullet.global_position = muzzle.global_position
bullet.direction = (get_global_mouse_position() - muzzle.global_position).normalized()
bullet.rotation = bullet.direction.angle()
get_tree().current_scene.add_child(bullet)
Três linhas carregam a lógica toda:
- Posição:
global_positiondo muzzle, nãoposition. Position é relativa ao pai; global é a posição real no mundo, que é o que importa pra quem vai ser adicionado na raiz da cena. - Direção: vetor do muzzle até o mouse, normalizado. Normalizar é obrigatório: sem isso, a bala fica mais rápida quanto mais longe o cursor estiver, porque o vetor cru carrega a distância no comprimento.
- Rotação:
direction.angle()gira o sprite pra apontar pra onde voa. Detalhe visual, mas tiro que voa de lado quebra qualquer jogo.
Pra um shooter de teclado (nave que só atira pra cima, plataforma que atira pra onde olha), troca a direção pelo que fizer sentido: Vector2.UP fixo, ou Vector2.RIGHT * sign(scale.x) pra seguir o lado que o personagem encara. A estrutura não muda.
Dano sem acoplamento
O _on_body_entered lá em cima já mostrou o padrão, mas vale destacar porque é a parte que mais escala mal quando feita errado:
if body.has_method("take_damage"):
body.take_damage(damage)
A bala não sabe o que é inimigo, caixa destrutível ou barril explosivo. Ela só pergunta "você sabe tomar dano?" e entrega o número. Quem decide o que fazer com o dano é o alvo:
extends CharacterBody2D
# Script do inimigo.
var health := 3
func take_damage(amount: int):
health -= amount
if health <= 0:
queue_free()
Amanhã você cria um gerador de energia destrutível, dá um take_damage pra ele e pronto: toda arma do jogo já funciona contra ele, sem mexer numa linha de código de bala. Esse desacoplamento é o que separa sistema de gambiarra.
Falta uma peça: layers e masks. Configure as camadas de colisão pra bala do player só checar a camada de inimigos e cenário, e bala de inimigo só checar player e cenário. Isso resolve dois problemas de uma vez: o player não toma tiro da própria arma, e a engine para de testar colisão de bala contra bala, que em tela cheia de projétil é um desperdício enorme de CPU.
Fire rate: segurando a cadência
Se o tiro responde a is_action_just_pressed, a cadência fica limitada pela velocidade do dedo. Pra arma automática (segurar pra atirar) você troca pra is_action_pressed e aí precisa de um controle de cadência, senão sai uma bala por frame, 60 por segundo.
O jeito mais simples e robusto é comparar com o relógio:
const FIRE_RATE = 0.15 # segundos entre tiros
var next_shot_time := 0.0
func _process(delta):
if Input.is_action_pressed("shoot"):
shoot()
func shoot():
var now = Time.get_ticks_msec() / 1000.0
if now < next_shot_time:
return
next_shot_time = now + FIRE_RATE
var bullet = BULLET.instantiate()
bullet.global_position = muzzle.global_position
bullet.direction = (get_global_mouse_position() - muzzle.global_position).normalized()
bullet.rotation = bullet.direction.angle()
get_tree().current_scene.add_child(bullet)
Sem Timer node, sem await, sem estado espalhado: uma variável guarda quando o próximo tiro é permitido, e tiro antes da hora é ignorado. FIRE_RATE de 0.15 dá pouco mais de 6 tiros por segundo; baixa pra metralhadora, sobe pra espingarda. Esse número é tuning puro, igual aceleração de movimento: roda, sente, ajusta.
Object pooling: quando o sistema de tiro vira gargalo
Pra um jogo com tiro esporádico, instantiate() e queue_free() resolvem e você não precisa de mais nada. O problema aparece em bullet hell, twin-stick shooter, qualquer jogo onde centenas de balas nascem e morrem por segundo. Instanciar cena tem custo (alocar memória, montar nodes, entrar na árvore), e liberar também. Fazendo isso centenas de vezes por segundo, o custo acumula e vira engasgo.
Object pooling ataca isso com uma troca: em vez de criar e destruir, você cria um lote de balas uma vez só, no carregamento, e fica reciclando. Bala "morta" não é destruída: é escondida e desativada. Bala nova não é instanciada: é uma morta que acorda em outra posição.
Primeiro, o projétil ganha os modos dormir e acordar:
extends Area2D
const SPEED = 800.0
const MAX_LIFETIME = 2.0
var pool: Node
var direction := Vector2.RIGHT
var damage := 1
var lifetime := 0.0
var active := false
func fire(pos: Vector2, dir: Vector2):
global_position = pos
direction = dir
rotation = dir.angle()
lifetime = 0.0
active = true
show()
set_physics_process(true)
set_deferred("monitoring", true)
func sleep():
active = false
hide()
set_physics_process(false)
set_deferred("monitoring", false)
func _physics_process(delta):
position += direction * SPEED * delta
lifetime += delta
if lifetime > MAX_LIFETIME:
pool.recycle(self)
func _on_body_entered(body):
if body.has_method("take_damage"):
body.take_damage(damage)
pool.recycle(self)
Dois pontos desse código ensinam algo:
set_deferred("monitoring", ...)em vez de atribuir direto. Obody_entereddispara no meio do passo de física, e o Godot não deixa mudar o estado de colisão de uma Area2D nesse momento. Oset_deferredagenda a mudança pro fim do frame, que é o jeito seguro.MAX_LIFETIMEsubstitui o notifier de tela. Numa pool, bala que sai da tela e nunca volta é bala perdida pro estoque, então cada uma tem prazo de validade e devolve a si mesma depois dele.
Agora o gerenciador da pool, um node na cena principal:
extends Node2D
@export var bullet_scene: PackedScene
@export var initial_size := 30
var inactive: Array = []
func _ready():
for i in initial_size:
inactive.append(_create_bullet())
func _create_bullet():
var bullet = bullet_scene.instantiate()
bullet.pool = self
add_child(bullet)
bullet.sleep()
return bullet
func spawn(pos: Vector2, dir: Vector2):
var bullet = inactive.pop_back() if inactive.size() > 0 else _create_bullet()
bullet.fire(pos, dir)
func recycle(bullet):
if not bullet.active:
return
bullet.sleep()
inactive.append(bullet)
A lógica é uma pilha: spawn tira uma bala inativa do topo (ou cria uma nova se o estoque secou, então a pool cresce sob demanda e nunca trava o tiro), e recycle devolve. O guard if not bullet.active evita reciclar duas vezes a mesma bala quando ela acerta um alvo e estoura o lifetime no mesmo frame.
No player, o shoot() encolhe pra isso:
@onready var bullet_pool = get_node("/root/Main/BulletPool")
func shoot():
var now = Time.get_ticks_msec() / 1000.0
if now < next_shot_time:
return
next_shot_time = now + FIRE_RATE
var dir = (get_global_mouse_position() - muzzle.global_position).normalized()
bullet_pool.spawn(muzzle.global_position, dir)
O caminho /root/Main/BulletPool depende de como sua cena principal se chama; ajuste ou exponha via @export var bullet_pool: Node2D e arraste no Inspector, que é mais à prova de refatoração.
Uma honestidade necessária: pooling é otimização, e otimização antes da hora é peso morto. Se seu jogo atira 3 balas por segundo, a versão com queue_free é mais simples, mais fácil de debugar e perfeitamente adequada. Implemente a pool quando o profiler (ou o frame rate) disser que precisa, não antes.
Os detalhes que mudam o feel
Com o sistema funcionando, o que separa tiro burocrático de tiro gostoso são camadas pequenas:
Spread. Arma com dispersão fica mais orgânica que laser cirúrgico. Um desvio aleatório pequeno na direção resolve:
var spread = deg_to_rad(4.0)
dir = dir.rotated(randf_range(-spread, spread))
Feedback no disparo. Som de tiro, um flash no muzzle, um leve recuo na câmera. Nenhum afeta gameplay, todos afetam a sensação. O som sozinho já muda mais que qualquer ajuste numérico.
Feedback no acerto. O alvo precisa reagir: piscar de branco, soltar partícula, emitir som de impacto. Tiro que acerta sem resposta visual parece que errou.
Balas de inimigo maiores e mais lentas. Regra prática de quem joga shoot'em up: o jogador precisa enxergar e desviar do que ameaça ele. Bala inimiga rápida e pequena não é dificuldade, é frustração.
Fechando
O sistema inteiro cabe em quatro decisões: projétil como cena própria de Area2D, spawn na raiz da cena com direção normalizada, dano via take_damage pra ninguém conhecer ninguém, e cadência controlada por relógio. Object pooling entra só quando o volume de balas justificar, e a estrutura com fire() e sleep() deixa a migração indolor.
Pra fixar, monta na ordem do artigo: primeiro a bala solitária que voa e some, depois o spawn no player, depois o dano num inimigo de teste, depois a cadência. Cada etapa roda sozinha e dá pra testar antes da próxima. Quando tudo estiver atirando, aí sim você decide se a pool vale o trabalho. Construir nessa ordem é o que faz a arquitetura entrar na cabeça de verdade.


