Voltar para o Blog
Quest Log

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

Cena de jogo top-down com personagem no centro mirando com uma arma e vários inimigos se aproximando ao redor

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.

Ferramenta:Godot

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.

Curso de Godot
Quer fazer seu primeiro jogo na Godot?

No curso de Godot da CursoGame.Dev você vai do zero ao primeiro jogo, com um desafio prático em cada level e suporte individual no WhatsApp quando travar.

Conhecer o curso de Godot
Suporte no WhatsAppAcesso vitalícioGarantia de 7 dias

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. Esse perseguir reto é o primeiro degrau da IA para jogos no Godot; a partir dele você empilha detecção, estados e desvio de obstáculos.

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.