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.

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.

Próximo nível
Quer aprender isso na prática?

No CursoGame.Dev você sai dos tutoriais soltos e constrói jogos publicáveis, com trilha progressiva, quests práticas e feedback real.

Conhecer a plataforma
+500 alunos4.9/5Garantia 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.

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.