Como Fazer um Jogo Estilo Vampire Survivors 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 comoenemy.tscn. - Projetil (
Area2D): voa em linha reta e some ao acertar. Salvo comoprojectile.tscn. - Orbe de XP (
Area2D): fica no chao esperando ser coletado. Salvo comoxp_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.
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.


