Voltar para o Blog
Quest Log

Como Fazer um Jogo Estilo Vampire Survivors no Godot 4

Player cercado por hordas de inimigos com projeteis automaticos e orbes de XP em um jogo survivor-like feito no Godot 4

Como fazer um jogo estilo Vampire Survivors no Godot 4: player top-down, ataque automatico, ondas de inimigos, perseguicao e level up com GDScript tipado.

Como Fazer um Jogo Estilo Vampire Survivors no Godot 4

Aprender a fazer um jogo estilo Vampire Survivors no Godot 4 e um dos melhores caminhos pra quem quer um projeto que roda rapido e ja parece um jogo de verdade. O segredo do genero, conhecido como survivor-like ou bullet heaven, e que o jogador faz uma coisa so: andar. O ataque acontece sozinho, os inimigos vem em hordas que crescem com o tempo e a graca esta em sobreviver e ficar mais forte a cada level up. Pouca arte, mecanicas focadas, muita repeticao. Perfeito pra estudar Godot sem se afogar em escopo.

Neste tutorial voce vai montar a base jogavel com codigo GDScript tipado e API real do Godot 4: o player com movimento top-down, o ataque automatico que mira no inimigo mais proximo, o spawn continuo de inimigos em ondas crescentes, o inimigo que persegue voce e o sistema de XP com level up que oferece uma melhoria. Vamos ser diretos sobre o escopo: isso e a fundacao, nao um jogo completo. Mas e uma fundacao que roda.

Arquitetura de um jogo estilo Vampire Survivors no Godot

Antes de digitar codigo, vale entender como as pecas se encaixam, porque survivor-like e um jogo de muitos objetos vivos ao mesmo tempo. A regra de ouro e separar tudo em cenas autossuficientes.

Pense em cinco cenas independentes:

  • Player (CharacterBody2D): anda, tem vida e carrega o sistema de armas.
  • Inimigo (CharacterBody2D): persegue o player e causa dano no contato. Salvo como enemy.tscn.
  • Projetil (Area2D): voa em linha reta e some ao acertar. Salvo como projectile.tscn.
  • Orbe de XP (Area2D): fica no chao esperando ser coletado. Salvo como xp_orb.tscn.
  • Main (Node2D): junta tudo, controla o spawn das ondas e a UI.

A comunicacao entre elas acontece de duas formas, e escolher a certa evita acoplamento. Para "achar todo mundo de um tipo" usamos grupos: o player entra no grupo player e cada inimigo entra no grupo enemies. Assim a arma encontra o inimigo mais proximo sem guardar referencia direta a ninguem. Para "avisar que algo aconteceu" usamos signals: o inimigo emite um sinal ao morrer, o orbe emite ao ser coletado, e quem se importa escuta. O inimigo nao precisa saber que o player existe; ele so grita "morri" e larga um orbe.

Essa separacao e o que permite ter cem inimigos na tela sem o codigo virar um no de spaghetti. Cada objeto cuida de si.

O player com movimento top-down

O player de um survivor-like e o mais simples do jogo: ele so se move em oito direcoes, sem gravidade, porque a camera e de cima. Use um CharacterBody2D com um Sprite2D e um CollisionShape2D. O movimento usa Input.get_vector, que ja devolve um Vector2 normalizado lendo quatro acoes de input ao mesmo tempo.

class_name Player
extends CharacterBody2D

@export var velocidade: float = 140.0
@export var vida_maxima: int = 100

var vida: int = 100

func _ready() -> void:
    add_to_group("player")
    vida = vida_maxima

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()

func receber_dano(quantidade: int) -> void:
    vida -= quantidade
    if vida <= 0:
        morrer()

func morrer() -> void:
    # Aqui voce dispara o game over. Por enquanto, so paramos o player.
    set_physics_process(false)

Repare em dois detalhes. O Input.get_vector ja normaliza o vetor, entao andar na diagonal nao fica mais rapido que andar reto, um bug classico de quem soma as direcoes na mao. E o add_to_group("player") no _ready e o que permite os inimigos e os orbes encontrarem o player depois. Lembre de criar as quatro acoes de input (mover_esquerda, mover_direita, mover_cima, mover_baixo) no menu Project Settings, aba Input Map.

Ataque automatico mirando no inimigo mais proximo

Aqui mora a alma do genero: o jogador nao atira, a arma atira sozinha. A mecanica e um Timer que dispara em intervalo fixo e, a cada disparo, procura o inimigo mais proximo e lanca um projetil na direcao dele.

Adicione um no Timer filho do player (ou de um no Arma) e conecte o timeout. Para achar o alvo, percorremos o grupo enemies e guardamos o de menor distancia.

extends Node2D

@export var projetil_cena: PackedScene
@export var intervalo: float = 0.8
@export var alcance: float = 400.0

@onready var timer_ataque: Timer = $TimerAtaque

func _ready() -> void:
    timer_ataque.wait_time = intervalo
    timer_ataque.timeout.connect(_disparar)
    timer_ataque.start()

func _disparar() -> void:
    var alvo: Node2D = _inimigo_mais_proximo()
    if alvo == null:
        return
    var direcao: Vector2 = (alvo.global_position - global_position).normalized()
    var projetil := projetil_cena.instantiate()
    get_tree().current_scene.add_child(projetil)
    projetil.global_position = global_position
    projetil.iniciar(direcao)

func _inimigo_mais_proximo() -> Node2D:
    var inimigos: Array[Node] = get_tree().get_nodes_in_group("enemies")
    var melhor: Node2D = null
    var menor_dist: float = alcance
    for inimigo in inimigos:
        var corpo := inimigo as Node2D
        var dist: float = global_position.distance_to(corpo.global_position)
        if dist < menor_dist:
            menor_dist = dist
            melhor = corpo
    return melhor

O projetil em si e um Area2D que voa em linha reta e some ao acertar um inimigo ou ao sair do alcance. Usar Area2D em vez de CharacterBody2D deixa o projetil leve, porque ele so precisa detectar sobreposicao, nao colidir fisicamente.

class_name Projetil
extends Area2D

@export var velocidade: float = 320.0
@export var dano: int = 10
@export var vida_util: float = 2.0

var _direcao: Vector2 = Vector2.ZERO

func iniciar(direcao: Vector2) -> void:
    _direcao = direcao

func _ready() -> void:
    body_entered.connect(_ao_acertar)
    var t := get_tree().create_timer(vida_util)
    t.timeout.connect(queue_free)

func _physics_process(delta: float) -> void:
    global_position += _direcao * velocidade * delta

func _ao_acertar(corpo: Node2D) -> void:
    if corpo.is_in_group("enemies") and corpo.has_method("receber_dano"):
        corpo.receber_dano(dano)
        queue_free()

O vida_util com create_timer garante que projetis que erram tudo nao fiquem voando pra sempre e entupindo a memoria. E o has_method("receber_dano") evita acoplar o projetil a um tipo especifico de inimigo: qualquer coisa no grupo enemies que saiba receber dano serve.

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

Spawn continuo de inimigos em ondas crescentes

Survivor-like vive de horda. Os inimigos precisam aparecer sem parar, sempre fora da tela, e em quantidade que sobe com o tempo. A estrategia: um Timer periodico que spawna inimigos numa posicao fora da camera, com um intervalo que vai diminuindo conforme a partida avanca.

Para nascer fora da tela, sorteamos um angulo ao redor do player e empurramos o ponto de spawn pra um raio maior que a metade da diagonal da tela. Assim o inimigo sempre surge na borda, nunca em cima do jogador.

extends Node2D

@export var inimigo_cena: PackedScene
@export var raio_spawn: float = 700.0
@export var intervalo_inicial: float = 1.5
@export var intervalo_minimo: float = 0.3

@onready var timer_spawn: Timer = $TimerSpawn

var _tempo_decorrido: float = 0.0

func _ready() -> void:
    timer_spawn.wait_time = intervalo_inicial
    timer_spawn.timeout.connect(_spawnar)
    timer_spawn.start()

func _process(delta: float) -> void:
    _tempo_decorrido += delta
    # A cada minuto o spawn fica mais rapido, ate o teto.
    var fator: float = _tempo_decorrido / 60.0
    timer_spawn.wait_time = max(intervalo_minimo, intervalo_inicial - fator * 0.3)

func _spawnar() -> void:
    var player := get_tree().get_first_node_in_group("player") as Node2D
    if player == null:
        return
    var angulo: float = randf_range(0.0, TAU)
    var deslocamento: Vector2 = Vector2.RIGHT.rotated(angulo) * raio_spawn
    var inimigo := inimigo_cena.instantiate()
    add_child(inimigo)
    inimigo.global_position = player.global_position + deslocamento

O randf_range(0.0, TAU) sorteia um angulo completo em radianos (TAU e duas voltas, ou seja, o circulo inteiro), e Vector2.RIGHT.rotated(angulo) transforma esse angulo num vetor de direcao. Multiplicando pelo raio_spawn e somando a posicao do player, o inimigo nasce sempre na borda, em qualquer direcao. A dificuldade crescente vem do _process que aperta o wait_time do timer com o passar do tempo.

Se voce quer um controle mais fino, com ondas definidas em um array (cinco inimigos rapidos, depois oito mais fortes, depois um chefe), vale ver o tutorial dedicado de spawner de inimigos e ondas no Godot, que modela cada onda com quantidade, intervalo e pausa.

O inimigo que persegue o player

O inimigo do survivor-like e burro de proposito: ele so anda em linha reta na direcao do player. Sem pathfinding, sem desvio de obstaculos. Num jogo de horda, a quantidade e o desafio, nao a inteligencia individual de cada um.

class_name Inimigo
extends CharacterBody2D

signal morreu(posicao: Vector2)

@export var velocidade: float = 70.0
@export var vida: int = 30
@export var dano_contato: int = 8

func _ready() -> void:
    add_to_group("enemies")

func _physics_process(_delta: float) -> void:
    var player := get_tree().get_first_node_in_group("player") as Node2D
    if player == null:
        return
    var direcao: Vector2 = (player.global_position - global_position).normalized()
    velocity = direcao * velocidade
    move_and_slide()
    _checar_contato()

func _checar_contato() -> void:
    for i in get_slide_collision_count():
        var colisao := get_slide_collision(i)
        var outro := colisao.get_collider()
        if outro != null and outro.is_in_group("player") and outro.has_method("receber_dano"):
            outro.receber_dano(dano_contato)

func receber_dano(quantidade: int) -> void:
    vida -= quantidade
    if vida <= 0:
        morrer()

func morrer() -> void:
    morreu.emit(global_position)
    queue_free()

A perseguicao e so calcular a direcao ate o player e seguir reto. O get_slide_collision depois do move_and_slide detecta quando o inimigo encostou no player pra causar dano de contato. E o sinal morreu carrega a posicao da morte, que e exatamente onde vamos largar o orbe de XP. Para um comportamento mais rico, com inimigos que patrulham antes de te ver ou mantem distancia, da uma olhada em como fazer IA de inimigo perseguir e patrulhar.

Sistema de XP, orbe e level up com melhoria

O ultimo pilar e o loop de progressao. Quando um inimigo morre, ele larga um orbe de XP. O player coleta o orbe, acumula experiencia e, ao encher a barra, sobe de nivel e escolhe uma melhoria. E esse loop de "ficar mais forte" que prende o jogador por horas.

O orbe e um Area2D que detecta o player e some ao ser coletado, emitindo quanto XP vale.

class_name OrbeXP
extends Area2D

@export var valor: int = 5

func _ready() -> void:
    body_entered.connect(_ao_coletar)

func _ao_coletar(corpo: Node2D) -> void:
    if corpo.is_in_group("player") and corpo.has_method("ganhar_xp"):
        corpo.ganhar_xp(valor)
        queue_free()

Para conectar tudo, a cena Main escuta o sinal morreu de cada inimigo e instancia um orbe na posicao da morte. No spawn do inimigo, conectamos o sinal:

func _spawnar() -> void:
    # ... codigo de posicao anterior ...
    var inimigo := inimigo_cena.instantiate()
    inimigo.morreu.connect(_largar_orbe)
    add_child(inimigo)
    inimigo.global_position = player.global_position + deslocamento

func _largar_orbe(posicao: Vector2) -> void:
    var orbe := orbe_cena.instantiate()
    add_child(orbe)
    orbe.global_position = posicao

E no player adicionamos o controle de nivel. A cada level up, o XP necessario sobe um pouco e o jogo oferece uma melhoria.

signal subiu_de_nivel(novo_nivel: int)

@export var orbe_cena: PackedScene

var nivel: int = 1
var xp_atual: int = 0
var xp_para_subir: int = 20

func ganhar_xp(quantidade: int) -> void:
    xp_atual += quantidade
    if xp_atual >= xp_para_subir:
        _subir_de_nivel()

func _subir_de_nivel() -> void:
    xp_atual -= xp_para_subir
    nivel += 1
    xp_para_subir = int(xp_para_subir * 1.25)
    subiu_de_nivel.emit(nivel)
    _aplicar_melhoria()

func _aplicar_melhoria() -> void:
    # Versao simples: cada nivel acelera o ataque.
    # O ideal e pausar o jogo e deixar o player escolher entre 3 opcoes.
    velocidade += 5.0

A multiplicacao xp_para_subir * 1.25 faz cada nivel custar mais que o anterior, mantendo a sensacao de progressao. No Vampire Survivors de verdade, o _aplicar_melhoria abriria uma tela com tres cartas pra escolher (mais dano, segundo projetil, mais velocidade). Por enquanto, aplicamos uma melhoria fixa pra fechar o loop. Se voce quer aprofundar essa camada, com niveis, curvas de XP e desbloqueios persistentes, o tutorial de sistema de progressao em jogos cobre o assunto inteiro.

Proximos passos

O que voce montou aqui ja roda e ja e um survivor-like: voce anda, a arma atira sozinha no inimigo mais proximo, as hordas vem sem parar e crescem com o tempo, os inimigos te perseguem, e voce sobe de nivel coletando XP. Esse e o loop completo, em pequena escala.

Para virar um jogo de verdade, os proximos passos naturais sao: uma tela de level up com tres opcoes de melhoria de verdade (mais armas, mais projetis, area de dano), tipos de inimigo variados com vida e velocidade diferentes, um chefe a cada poucos minutos, audio e feedback visual no acerto, e uma UI com barra de vida, contador de tempo e barra de XP. Cada um desses e um modulo que voce pluga no esqueleto sem reescrever o que ja existe, justamente porque separamos tudo em cenas com responsabilidades claras.

E se voce sentiu que copiou o codigo mas ainda nao entende por que cada linha funciona, esse e o sinal de que vale aprender Godot a fundo, com base solida em GDScript e na arquitetura de cenas e signals. O caminho mais rapido pra sair do "copiei e funcionou" pro "eu sei construir isso sozinho" e estudar com estrutura: veja o melhor curso de Godot pra montar essa fundacao e parar de depender de tutorial pra cada mecanica nova.

Perguntas frequentes

O que e um jogo estilo Vampire Survivors?

E um survivor-like, tambem chamado de bullet heaven: o jogador so se movimenta, o ataque acontece sozinho e o desafio vem de sobreviver a hordas que crescem com o tempo. A progressao acontece dentro da partida, escolhendo melhorias a cada level up. Vampire Survivors popularizou o genero, mas a base e simples e otima pra aprender Godot.

Da pra fazer um jogo estilo Vampire Survivors no Godot sozinho?

Da sim. O genero foi feito famoso por um desenvolvedor solo justamente porque o escopo de arte e mecanicas e enxuto: poucos sprites, um loop de combate automatico e muita repeticao de inimigos iguais. O desafio real e balancear as ondas e as melhorias, nao a complexidade tecnica. E um dos melhores generos pra primeiro projeto serio.

Quais nos do Godot 4 eu uso pra fazer um survivor-like?

O player e os inimigos costumam ser CharacterBody2D com move_and_slide. Os ataques automaticos usam um Timer pra disparar e Area2D ou projeteis pra causar dano. Os orbes de XP usam Area2D pra deteccao de coleta. O spawn de inimigos usa instantiate de uma cena salva como PackedScene, posicionada fora da camera.

Como funciona o ataque automatico no Vampire Survivors?

O jogador nao aperta botao de tiro. Um Timer dispara em intervalo fixo e, a cada disparo, o jogo procura o inimigo mais proximo e lanca um projetil na direcao dele. Conforme voce sobe de nivel, da pra reduzir o intervalo do Timer, aumentar o dano ou disparar mais projeteis de uma vez.

Esse tutorial faz o jogo completo?

Nao, e honesto dizer que isso aqui e a base jogavel: movimento, ataque automatico, ondas crescentes, perseguicao e level up. Um jogo completo precisa de muitas armas, inimigos variados, chefoes, audio, UI de menu e balanceamento fino. O que voce monta aqui ja roda e ja e divertido, e e a fundacao pra crescer.