Voltar para o Blog
Quest Log

Como Fazer um Jogo Bullet Hell na Godot 4

Tela de bullet hell na Godot 4 com dezenas de projéteis em padrão circular e nave do jogador

Como fazer um jogo bullet hell na Godot 4: spawner de padrões de tiro (leque, círculo e espiral) em GDScript tipado, hitbox minúscula e object pooling.

Ferramenta:Godot

Poucos gêneros impressionam tanto quanto uma tela inteira coberta de projéteis girando em padrões geométricos, com o jogador escapando por brechas de poucos pixels. Este guia mostra como fazer um jogo bullet hell na Godot 4 com GDScript tipado do começo ao fim: o spawner que cria padrões de tiro (leque, círculo completo e espiral), o projétil que anda reto e some fora da tela, o object pooling que segura o desempenho quando a tela enche e a hitbox minúscula do jogador que torna o desvio possível.

Bullet hell, também chamado de danmaku (cortina de balas, em japonês), não é sobre mirar bem no inimigo: é sobre volume. O perigo nasce da quantidade de tiros na tela e da leitura dos padrões. Por isso o coração técnico do gênero são dois pontos: gerar muitos projéteis em formações bonitas e manter tudo rodando liso. Vamos resolver os dois com código enxuto e tipado.

O projétil: um nó simples que anda reto

Em bullet hell, o projétil individual é burro de propósito. Ele nasce com uma direção, anda reto nela e morre ao sair da tela. Toda a inteligência está no spawner, não na bala. Monte o projétil como uma cena Area2D com três filhos:

# Projetil (Area2D)  <- raiz, com o script
# ├── Sprite2D
# ├── CollisionShape2D            # um CircleShape2D pequeno
# └── VisibleOnScreenNotifier2D   # avisa quando sai da tela

O script da raiz faz o mínimo: guarda uma direção e anda nela todo frame de física.

extends Area2D

@export var velocidade: float = 260.0
var direcao: Vector2 = Vector2.RIGHT

func _physics_process(delta: float) -> void:
    position += direcao * velocidade * delta

func _on_screen_exited() -> void:
    queue_free()

A direcao é um Vector2 unitário que o spawner vai definir no momento do disparo. Em _physics_process, multiplicamos essa direção pela velocidade e pelo delta para um movimento constante e independente do framerate. Quando o VisibleOnScreenNotifier2D dispara o sinal screen_exited, o projétil se remove com queue_free(). Essa base de projétil que anda e some é a mesma de qualquer tiro 2D; se você quer o passo a passo detalhado de instanciar, mirar e aplicar dano, o guia de como atirar projétil 2D na Godot cobre esse alicerce antes de escalarmos para a cortina de balas.

O spawner de padrões: leque, círculo e espiral

Aqui mora a identidade do gênero. Um spawner é um nó que, a cada disparo, cria vários projéteis de uma vez com direções calculadas. Vamos expor a cena do projétil e uma função que cria uma bala numa direção:

extends Node2D

@export var cena_projetil: PackedScene

func criar_projetil(dir: Vector2) -> void:
    var p: Area2D = cena_projetil.instantiate()
    p.global_position = global_position
    p.direcao = dir
    get_tree().current_scene.add_child(p)

A função instancia a cena, posiciona o projétil na origem do spawner, define a direcao e adiciona o projétil à cena atual (não como filho do spawner, para ele viver livre). Com esse tijolo, os padrões viram laços for sobre direções.

O círculo completo divide 360 graus pela quantidade de tiros e dispara um em cada fatia:

func disparar_circulo(qtd: int) -> void:
    var passo: float = 360.0 / float(qtd)
    for i in qtd:
        var angulo: float = deg_to_rad(passo * i)
        var dir: Vector2 = Vector2.RIGHT.rotated(angulo)
        criar_projetil(dir)

Para cada i, calculamos o ângulo em graus, convertemos para radianos com deg_to_rad (porque rotated espera radianos) e giramos o vetor base Vector2.RIGHT. Chame disparar_circulo(24) e você tem uma coroa de 24 balas saindo em todas as direções.

O leque (spread) é um círculo parcial: você define uma abertura menor e distribui os tiros dentro dela, útil para mirar na direção do jogador:

func disparar_leque(qtd: int, abertura_graus: float, base: Vector2) -> void:
    var meio: float = abertura_graus / 2.0
    var passo: float = abertura_graus / float(qtd - 1)
    for i in qtd:
        var angulo: float = deg_to_rad(-meio + passo * i)
        var dir: Vector2 = base.rotated(angulo)
        criar_projetil(dir)

Começamos em -meio (metade da abertura para um lado), avançamos de passo em passo e giramos o vetor base, que pode apontar para o jogador. Um disparar_leque(5, 60.0, Vector2.DOWN) solta um leque de 5 tiros espalhados em 60 graus para baixo.

A espiral é o padrão mais icônico do danmaku, e o truque é um ângulo acumulado que cresce a cada disparo:

var angulo_espiral: float = 0.0
@export var incremento_graus: float = 13.0

func disparar_espiral() -> void:
    var dir: Vector2 = Vector2.RIGHT.rotated(deg_to_rad(angulo_espiral))
    criar_projetil(dir)
    angulo_espiral += incremento_graus

A variável angulo_espiral guarda a direção do último tiro. A cada chamada, disparamos naquela direção e somamos incremento_graus. Chamando disparar_espiral() a cada poucos frames (com um Timer), os projéteis traçam aquela espiral giratória que define o gênero. Mudar o incremento_graus muda o aperto da espiral; valores que não dividem 360 de forma exata geram as formas mais hipnóticas.

Para orquestrar ondas de padrões (primeiro círculo, depois espiral, depois leque mirado), vale o mesmo raciocínio de progressão de dificuldade que uso em spawner de inimigos por waves na Godot, trocando inimigos por formações de tiro.

Montar sozinho o jogo inteiro ao redor desse spawner (o chefe que alterna padrões, a pontuação, as fases, o menu, o balanceamento para ser difícil mas justo) é onde o projeto costuma travar: cada sistema funciona isolado, mas costurar tudo num bullet hell que prende exige método. Se você quer aprender Godot numa sequência pensada, do primeiro nó até o jogo fechado, o curso de Godot da CursoGame.Dev organiza essa jornada para você não ficar juntando trechos soltos sem enxergar o todo.

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

Object pooling: por que todo bullet hell precisa reaproveitar projéteis

Quando a tela tem 300 balas e você cria e destrói dezenas por frame, o coletor de memória da Godot entra em ação com frequência e o jogo engasga. A solução do gênero é o object pooling: em vez de destruir o projétil que sai da tela, você o esconde e devolve a uma piscina para reusar no próximo disparo. Assim a memória para de inchar e desinchar.

Uma piscina simples no spawner fica assim:

var piscina: Array[Area2D] = []

func pegar_projetil() -> Area2D:
    if piscina.is_empty():
        return cena_projetil.instantiate()
    var p: Area2D = piscina.pop_back()
    p.visible = true
    p.set_physics_process(true)
    return p

func devolver(p: Area2D) -> void:
    p.visible = false
    p.set_physics_process(false)
    piscina.append(p)

A pegar_projetil entrega um projétil reciclado da piscina (um Array[Area2D] tipado) ou, se estiver vazia, instancia um novo. Ao reusar, ela religa a visibilidade e o _physics_process. A devolver faz o contrário: desliga o processamento e o esconde, em vez de destruir. No projétil, troque o queue_free() do screen_exited por uma chamada que devolve a bala ao spawner. Enquanto o volume de tiros é baixo, queue_free() basta; quando a tela começa a lotar, o pooling é o que mantém os 60 frames por segundo.

A hitbox minúscula do jogador

O último pilar é o que torna o bullet hell jogável: a hitbox do jogador não cobre o sprite inteiro, ela é um ponto minúsculo no centro da nave. Isso é o que permite passar raspando por uma bala sem morrer (o grazing) e é a convenção que todo fã do gênero espera. Na Godot, separe a hitbox de dano do visual, usando uma Area2D pequena como filho do jogador:

# Jogador (CharacterBody2D)
# ├── Sprite2D               # a nave inteira, grande
# └── Hitbox (Area2D)
#     └── CollisionShape2D   # um CircleShape2D de poucos pixels

O CollisionShape2D da Hitbox é um círculo de raio pequeno, bem no centro. O sprite pode ser enorme e cheio de detalhes: só o pontinho da hitbox conta para a morte. O script do jogador move a nave e escuta a colisão da hitbox:

extends CharacterBody2D

@export var velocidade: float = 240.0
@onready var hitbox: Area2D = $Hitbox

func _ready() -> void:
    hitbox.area_entered.connect(_ao_ser_atingido)

func _physics_process(_delta: float) -> void:
    var dir: Vector2 = Input.get_vector("esquerda", "direita", "cima", "baixo")
    velocity = dir * velocidade
    move_and_slide()

func _ao_ser_atingido(area: Area2D) -> void:
    morrer()

func morrer() -> void:
    # reiniciar fase, tirar vida, tocar explosao
    queue_free()

Em _ready, conectamos o sinal area_entered da hitbox à função de dano, então ela só dispara quando uma bala encosta naquele pontinho central. O movimento usa Input.get_vector, que já devolve um Vector2 normalizado das quatro direções, e move_and_slide aplica a velocidade. Para recompensar o grazing, adicione uma segunda Area2D um pouco maior ao redor da hitbox: quando uma bala entra nela mas não na hitbox de dano, você soma pontos e dá ao jogador o incentivo de chegar perto do perigo.

Juntando as peças

Você montou os quatro pilares de um bullet hell na Godot 4: um projétil simples que anda reto e some da tela, um spawner com padrões de leque, círculo e espiral em GDScript tipado, object pooling para aguentar a tela cheia e uma hitbox minúscula que faz o desvio valer a pena. Daqui, a diversão está em combinar padrões num chefe que alterna formações, ajustar velocidades e densidades até achar o ponto entre difícil e injusto, e adicionar o sistema de pontos por grazing.

Comece com um único inimigo disparando um círculo, teste no seu corpo se dá para desviar, e só então empilhe espiral e leque mirado. Se ainda falta a cena base, o personagem e o cenário onde esse combate acontece, o guia de como fazer um jogo 2D na Godot arma essa estrutura. Bullet hell premia o ajuste fino: a mesma espiral fica entediante ou aterrorizante só mudando um incremento de ângulo, e é brincando com esses números que o seu danmaku ganha personalidade.

Perguntas frequentes

O que é um jogo bullet hell?

Bullet hell (ou danmaku, cortina de balas em japonês) é um subgênero de shoot em up em que a tela se enche de dezenas ou centenas de projéteis em padrões geométricos. O jogador sobrevive desviando por brechas minúsculas. A sensação de perigo vem do volume de tiros na tela, não da pontaria do inimigo, e dominar a leitura dos padrões é a habilidade central.

Por que a hitbox do jogador é tão pequena?

Numa tela coberta de balas, uma hitbox do tamanho do sprite seria impossível de manter viva. Por isso o bullet hell usa uma hitbox minúscula, de poucos pixels, quase sempre no centro da nave. Isso permite passar raspando por projéteis sem morrer, o chamado grazing, que é o que dá a adrenalina do gênero. Na Godot você faz isso com uma Area2D pequena separada do sprite.

O que é grazing?

Grazing é passar raspando num projétil sem ser atingido, aproveitando a hitbox minúscula do jogador. Muitos bullet hells recompensam o grazing com pontos ou carga de especial, para incentivar o jogador a chegar perto do perigo em vez de fugir. Você detecta o grazing com uma segunda Area2D, um pouco maior que a hitbox de dano.

Como criar padrões de tiro na Godot?

Um spawner percorre um laço for e cria vários projéteis de uma vez, cada um com uma direção calculada. Para um círculo completo, divida 360 graus pela quantidade de tiros. Para um leque, distribua os tiros dentro de uma abertura menor. Para uma espiral, mantenha um ângulo acumulado que cresce a cada disparo. Tudo isso cabe em funções tipadas curtas usando deg_to_rad.

Por que usar object pooling em bullet hell?

Instanciar e liberar centenas de projéteis por segundo pressiona o coletor de memória e causa engasgos. O object pooling reaproveita projéteis: em vez de destruir quem sai da tela, você o esconde e devolve a uma piscina para reusar no próximo disparo. Numa tela lotada de balas, essa técnica é o que mantém o jogo rodando liso.

Como o projétil se remove sozinho?

Adicione um nó VisibleOnScreenNotifier2D como filho do projétil e conecte o sinal screen_exited. No bullet hell, em vez de chamar queue_free direto, o ideal é devolver o projétil à piscina de object pooling, mas queue_free funciona bem enquanto o volume de tiros ainda é baixo.