Como Fazer um Jogo de Tiro Top-Down no Godot 4 (Twin-Stick)

Jogo de tiro top-down no Godot 4 passo a passo: movimento em 8 direções, mira 360 graus no mouse, atirar projéteis, inimigos que perseguem, vida e spawn.
O que é um jogo de tiro top-down twin-stick
Um jogo de tiro top-down no Godot 4 é aquele visto de cima, em que a câmera olha o chão de perto e você controla um personagem que anda livremente pelo cenário enquanto atira em qualquer direção. É o esquema de Enter the Gungeon, Hotline Miami e Nuclear Throne: movimento com uma mão, mira com a outra. Por isso o gênero também é chamado de twin-stick shooter, uma referência aos dois analógicos do controle, um para andar e outro para mirar.
Vale separar bem, porque muita gente confunde. Isto não é um shmup de nave (aquele com rolagem vertical automática e tiro só para frente) nem um jogo de corrida top-down. Aqui o cenário fica parado ou acompanha o player, e o coração da jogabilidade é a mira em 360 graus: você aponta para onde o mouse (ou o analógico direito) está, não só para a frente do personagem. É essa liberdade de ângulo que faz o gênero funcionar.
Neste guia a gente monta a base completa e jogável: movimento em 8 direções, rotação do personagem para mirar no mouse, tiro instanciando projéteis, inimigos que perseguem o player, um sistema simples de vida e dano, e um spawner que joga inimigos na arena aos poucos. Todo o código é GDScript tipado do Godot 4.
Movimento top-down em 8 direções
Comece com um CharacterBody2D para o player. Ele é o node certo para personagens controlados: tem velocity, tem move_and_slide() e desliza suave ao raspar em paredes. Dentro dele coloque um Sprite2D (ou AnimatedSprite2D) e um CollisionShape2D.
O movimento top-down é mais simples que o de plataforma porque não existe gravidade nem pulo: os dois eixos são iguais. O Input.get_vector já devolve um vetor de direção normalizado a partir de quatro ações de input, então as 8 direções (as 4 retas mais as 4 diagonais) saem de graça, sem você tratar diagonal na mão.
Antes de tudo, crie as ações mover_cima, mover_baixo, mover_esquerda e mover_direita em Project Settings > Input Map, ligadas a WASD ou às setas.
extends CharacterBody2D
@export var velocidade: float = 320.0
func _physics_process(delta: float) -> void:
var direcao: Vector2 = Input.get_vector(
"mover_esquerda", "mover_direita",
"mover_cima", "mover_baixo"
)
velocity = direcao * velocidade
move_and_slide()
O get_vector recebe as ações na ordem esquerda, direita, cima, baixo e devolve um Vector2 já normalizado, ou seja, a diagonal não deixa o player mais rápido que a linha reta (um bug clássico de quem soma os eixos na mão). Multiplicando pela velocidade e jogando em velocity, o move_and_slide() faz o resto. Repare que não usamos delta aqui porque move_and_slide() já integra o tempo internamente.
Mira 360 graus: rotacionar o personagem para o mouse
Aqui mora a alma do twin-stick. O personagem anda numa direção, mas encara outra: a do cursor. No Godot 4 isso é uma linha só, o método look_at.
func _physics_process(delta: float) -> void:
var direcao: Vector2 = Input.get_vector(
"mover_esquerda", "mover_direita",
"mover_cima", "mover_baixo"
)
velocity = direcao * velocidade
move_and_slide()
look_at(get_global_mouse_position())
O look_at gira o node inteiro para que o eixo X local (a "frente", apontando para a direita) mire no ponto que você passou. O get_global_mouse_position() devolve onde o mouse está no mundo do jogo, então o player passa a encarar o cursor o tempo todo, em qualquer um dos 360 graus.
Um detalhe de arte importante: como o look_at usa o eixo X como frente, seu sprite precisa estar desenhado olhando para a direita. Se o personagem foi desenhado olhando para cima, ele vai parecer torto 90 graus. Duas saídas: redesenhe virado para a direita, ou some um deslocamento fixo à rotação depois do look_at, por exemplo rotation += deg_to_rad(90).
Na prática, o ideal é não girar o corpo inteiro (senão a hitbox e a animação de andar giram junto). O padrão do gênero é ter um node filho separado, tipo um Node2D chamado "Arma" ou "Mira", e girar só ele:
@onready var arma: Node2D = $Arma
func _physics_process(delta: float) -> void:
# ... movimento acima ...
arma.look_at(get_global_mouse_position())
Assim o corpo do player anda normal e só a arma acompanha o mouse, exatamente como num twin-stick de verdade.
Atirar projéteis instanciando cenas
Uma bala não é um objeto único que você reaproveita: cada tiro é uma cópia nova da cena de projétil, com posição e direção próprias. É isso que permite ter dezenas de balas na tela ao mesmo tempo. Se você ainda não montou a cena da bala, vale um guia dedicado a atirar projéteis no Godot 4, mas o essencial está logo abaixo.
Primeiro, a cena do projétil. Crie um Area2D (bom para balas porque detecta acerto sem empurrar fisicamente), com Sprite2D e CollisionShape2D dentro, e este script:
extends Area2D
@export var velocidade: float = 900.0
@export var dano: int = 1
var direcao: Vector2 = Vector2.RIGHT
func _physics_process(delta: float) -> void:
position += direcao * velocidade * delta
func _on_body_entered(corpo: Node2D) -> void:
if corpo.has_method("receber_dano"):
corpo.receber_dano(dano)
queue_free()
Conecte o sinal body_entered do Area2D a essa função _on_body_entered. Quando a bala toca um corpo que sabe receber dano, ela aplica o dano e se destrói com queue_free(). O has_method evita erro ao bater numa parede que não tem vida.
Agora, no player, o disparo. A bala nasce na ponta da arma e viaja na direção do mouse:
const CENA_BALA: PackedScene = preload("res://cenas/bala.tscn")
@onready var arma: Node2D = $Arma
@onready var ponta: Marker2D = $Arma/Ponta
func _physics_process(delta: float) -> void:
# ... movimento e mira ...
if Input.is_action_just_pressed("atirar"):
atirar()
func atirar() -> void:
var bala: Area2D = CENA_BALA.instantiate()
bala.global_position = ponta.global_position
bala.direcao = (get_global_mouse_position() - global_position).normalized()
bala.rotation = bala.direcao.angle()
get_tree().current_scene.add_child(bala)
O preload carrega a cena da bala uma vez só, na compilação. O instantiate() cria uma cópia a cada tiro. Colocamos a bala na ponta (um Marker2D filho da arma que marca a boca do cano), calculamos a direção subtraindo a posição do player da do mouse e normalizando, e adicionamos a bala à cena atual (não como filha do player, senão ela andaria junto dele). Crie a ação atirar no Input Map ligada ao botão esquerdo do mouse.
Inimigos que perseguem o player
O inimigo mais básico de um top-down anda reto na direção do player. Também é um CharacterBody2D, e a lógica lembra a mira: calcule a direção até o alvo e mova.
extends CharacterBody2D
@export var velocidade: float = 120.0
@export var vida: int = 3
var player: Node2D = null
func _ready() -> void:
player = get_tree().get_first_node_in_group("player")
func _physics_process(delta: float) -> void:
if player == null:
return
var direcao: Vector2 = (player.global_position - global_position).normalized()
velocity = direcao * velocidade
look_at(player.global_position)
move_and_slide()
func receber_dano(quantia: int) -> void:
vida -= quantia
if vida <= 0:
queue_free()
Coloque o player no grupo "player" (aba Node > Groups no editor) para o inimigo achá-lo com get_first_node_in_group. A cada frame ele recalcula a direção, então persegue mesmo com o player em movimento. O receber_dano é a mesma função que a bala chama, fechando o ciclo: bala acerta inimigo, chama receber_dano, vida chega a zero, inimigo some.
Perseguir em linha reta funciona em arena aberta, mas o inimigo atravessa a intenção de desviar de paredes. Quando o mapa tiver obstáculos, troque esse cálculo de direção por um NavigationAgent2D, que acha o caminho contornando as paredes. Esse é o pulo para uma IA de inimigo que persegue e patrulha de forma mais esperta, com estados de comportamento.
Vida e dano do player
O player usa o mesmo padrão de receber_dano, mas em vez de sumir da tela ele reinicia a partida ou mostra o game over. O contato com o inimigo é o que causa dano, então damos ao inimigo uma Area2D de corpo que detecta o player, ou verificamos a colisão pelo move_and_slide.
@export var vida_maxima: int = 6
var vida: int = vida_maxima
func receber_dano(quantia: int) -> void:
vida -= quantia
vida = max(vida, 0)
if vida <= 0:
morrer()
func morrer() -> void:
get_tree().reload_current_scene()
O max(vida, 0) impede vida negativa, o que ajuda a montar uma barra de vida depois sem valores estranhos. O reload_current_scene recomeça a fase de forma simples enquanto você não tem uma tela de game over. Uma dica que evita frustração é dar um curto período de invencibilidade após levar dano (um Timer que ignora novos golpes por meio segundo), senão o player perde tudo num piscar ao encostar num inimigo rápido.
Spawn de inimigos: enchendo a arena
Faltou quem enfrentar. Um spawner simples usa um Timer que, a cada intervalo, cria um inimigo numa posição de spawn. Coloque este script num node vazio da fase, com um Timer filho e alguns Marker2D marcando os pontos onde os inimigos surgem (fora da tela é o ideal).
extends Node2D
const CENA_INIMIGO: PackedScene = preload("res://cenas/inimigo.tscn")
@onready var timer: Timer = $Timer
@onready var pontos: Node2D = $PontosDeSpawn
func _ready() -> void:
timer.timeout.connect(_ao_spawnar)
timer.start()
func _ao_spawnar() -> void:
var filhos: Array[Node] = pontos.get_children()
if filhos.is_empty():
return
var ponto: Marker2D = filhos.pick_random()
var inimigo: CharacterBody2D = CENA_INIMIGO.instantiate()
inimigo.global_position = ponto.global_position
add_child(inimigo)
# aperta o ritmo: cada onda vem um pouco mais rápido
timer.wait_time = max(0.4, timer.wait_time - 0.05)
O pick_random() escolhe um ponto de spawn aleatório entre os Marker2D, então os inimigos não brotam sempre do mesmo canto. A cada spawn diminuímos o wait_time do timer, com um piso de 0.4 segundo pelo max, o que aumenta a pressão conforme a partida se estende, a curva de dificuldade clássica de um survival top-down.
Próximos passos
Com essas seis peças você tem um twin-stick jogável: anda em 8 direções, mira em 360 graus no mouse, atira balas que causam dano, enfrenta inimigos que perseguem, tem vida e um fluxo de inimigos que não para. A partir daqui os incrementos naturais são: knockback ao levar tiro, tipos diferentes de arma (espingarda com vários projéteis por disparo, metralhadora com cadência), drops de vida, e uma câmera com Camera2D seguindo o player com um leve atraso.
Se a ideia de mover algo top-down com física interessa mas você quer um projeto diferente do shooter, o mesmo raciocínio de CharacterBody2D visto de cima aparece num carro em top-down 2D, só que ali a rotação vem da direção do volante em vez do mouse. Mira livre, perseguição e spawn, porém, são a fundação de quase todo top-down de ação, e agora eles são seus.
Perguntas frequentes
Qual a diferença entre um shooter top-down e um shmup de nave?
No shmup de nave a tela rola sozinha (geralmente na vertical) e você só atira para frente, na direção da rolagem. No shooter top-down twin-stick o cenário fica parado ou segue o player, e você mira em 360 graus com o mouse ou o analógico direito. A mira livre é o que define o gênero estilo Enter the Gungeon ou Hotline Miami.
Como faço o personagem mirar na direção do mouse no Godot 4?
Use look_at(get_global_mouse_position()) dentro do _physics_process. O look_at gira o node inteiro para apontar o eixo X local na direção do ponto informado, então basta desenhar a arma ou o sprite virado para a direita que ele sempre encara o cursor.
Por que devo instanciar o projétil em vez de mover uma bala fixa?
Instanciar cria uma cópia nova da cena de bala a cada tiro, então você pode ter dezenas de projéteis na tela ao mesmo tempo, cada um com sua própria posição e direção. Uma única bala fixa só permitiria um tiro por vez e não serve para um twin-stick.
Como o inimigo persegue o player no Godot 4?
Guarde uma referência ao player, calcule a direção com (player.global_position - global_position).normalized() a cada frame e aplique essa direção na velocity antes do move_and_slide. Para desviar de paredes e obstáculos, troque esse cálculo por um NavigationAgent2D.
Onde eu coloco a lógica de vida e dano?
Deixe uma variável vida em cada corpo que pode levar dano (player e inimigos) e uma função receber_dano(quantia) que subtrai e chama queue_free ou morrer quando chega a zero. O projétil chama essa função no corpo que ele acerta ao detectar a colisão.
Como faço os inimigos aparecerem aos poucos durante a partida?
Use um Timer que dispara em intervalos e, no sinal timeout, instancie a cena do inimigo numa posição fora da tela ou num ponto de spawn. Reduza o intervalo do Timer com o tempo para aumentar a dificuldade conforme a partida avança.


