Voltar para o Blog
Quest Log

Como Fazer um Jogo Estilo Asteroids na Godot: Inércia, Rotação e Tela Infinita

Nave triangular aplicando empuxo e atirando em asteroides que se partem em pedaços menores, no estilo do arcade clássico Asteroids feito na Godot

Aprenda como fazer Asteroids na Godot 4: nave com inércia e rotação, wrap de tela, tiros com Area2D e asteroides que se dividem, tudo em GDScript tipado.

Asteroids (1979) parece simples até você tentar cloná-lo: a nave não anda para onde você aperta, ela gira e empurra a si mesma, deslizando pelo espaço com inércia de verdade. Neste tutorial de como fazer Asteroids na Godot 4 você monta esse movimento do zero, em GDScript tipado, junto com as outras três mecânicas que definem o jogo: a tela que dá a volta (wrap-around), os asteroides que se quebram em pedaços menores e as ondas que ficam mais cheias a cada rodada.

Isso o coloca em uma categoria diferente de um shmup comum. Em um jogo de nave tradicional, que destrinchamos no tutorial de como fazer um jogo de nave estilo shmup na Godot, a nave responde na hora: apertou para a direita, andou para a direita, soltou, parou. No Asteroids, o input controla aceleração, não posição. Essa única decisão de design muda tudo: mirar exige planejamento, frear exige manobra e cada asteroide grande destruído vira dois problemas menores voando em direções novas.

É também um projeto excelente para quem está começando: escopo fechado, zero necessidade de arte elaborada e três ou quatro scripts curtos. Se ainda está decidindo o que construir primeiro, ele aparece com destaque na nossa lista de ideias de jogos simples para iniciantes.

A Cena da Nave

Crie uma cena Ship com esta estrutura:

  • Ship (CharacterBody2D), o nó raiz que carrega o script
  • Sprite2D ou Polygon2D, o visual da nave
  • CollisionShape2D com um CircleShape2D pequeno

Um detalhe que evita horas de confusão: desenhe ou rotacione o visual da nave com o nariz apontando para a direita. Na Godot, rotação 0 aponta para +X, e vamos usar Vector2.from_angle(rotation) como direção "para frente". Se o seu sprite aponta para cima, rotacione o nó filho em 90 graus (só o filho, nunca a raiz).

Escolhemos CharacterBody2D em vez de RigidBody2D de propósito. O RigidBody2D daria a inércia de graça, mas teleportar um corpo controlado pelo motor de física (que é o que o wrap de tela faz) exige _integrate_forces e cuidados que não valem a pena aqui. Com CharacterBody2D e um velocity manual, cada linha da física é sua e o comportamento é totalmente previsível.

Como Fazer Asteroids na Godot: O Movimento com Inércia

Aqui está o coração do jogo. O script inteiro do movimento cabe em vinte linhas:

extends CharacterBody2D

@export var thrust_power: float = 220.0
@export var turn_speed: float = 3.5
@export var max_speed: float = 420.0
@export var drag: float = 0.15

func _physics_process(delta: float) -> void:
    var turn: float = Input.get_axis("ui_left", "ui_right")
    rotation += turn * turn_speed * delta

    if Input.is_action_pressed("ui_up"):
        velocity += Vector2.from_angle(rotation) * thrust_power * delta

    velocity = velocity.limit_length(max_speed)
    velocity -= velocity * drag * delta
    move_and_slide()
    _wrap_around_screen()

Linha a linha, o que importa:

  • Input.get_axis("ui_left", "ui_right") devolve um float entre -1 e 1. Multiplicado por turn_speed (em radianos por segundo) e por delta, ele gira a nave. Repare que girar não muda a trajetória: a nave pode estar indo para a esquerda com o nariz apontando para a direita.
  • Vector2.from_angle(rotation) converte o ângulo atual da nave em um vetor unitário de direção. É o mesmo que transform.x para um nó sem escala. Esse é o vetor do empuxo.
  • velocity += direção * thrust_power * delta é a linha que cria a inércia. Não estamos definindo a velocidade, estamos somando aceleração à velocidade que já existia. Solte o botão e o velocity acumulado continua lá: a nave desliza.
  • limit_length(max_speed) impõe um teto. Sem ele, segurar o empuxo por dez segundos deixa a nave incontrolável.
  • velocity -= velocity * drag * delta é um atrito artificial bem leve. O arcade original praticamente não tinha; deixe drag em 0.0 se quiser a experiência raiz, ou suba para 0.5 se os playtesters reclamarem do controle.

Rode a cena e brinque trinta segundos. A sensação de "empurrar um carrinho no gelo" já está inteira nessas linhas.

Wrap de Tela: Saiu Pela Direita, Voltou Pela Esquerda

No Asteroids não existe parede. A tela é um toro: sair por uma borda significa reaparecer na oposta, mantendo velocidade e rotação. A implementação na Godot é uma função de quatro linhas, graças a fposmod:

func _wrap_around_screen() -> void:
    var screen: Vector2 = get_viewport_rect().size
    position.x = fposmod(position.x, screen.x)
    position.y = fposmod(position.y, screen.y)

fposmod(a, b) é o módulo de ponto flutuante que sempre devolve resultado positivo. É isso que dispensa qualquer if: numa tela de 1152 pixels de largura, uma nave em x = -10.0 vira x = 1142.0, e uma em x = 1160.0 vira x = 8.0. O operador % não serve aqui porque só funciona com inteiros em GDScript, e uma subtração manual exigiria quatro comparações.

Chame _wrap_around_screen() no final do _physics_process da nave, e depois reaproveite a mesma lógica nos asteroides. Um refinamento clássico para depois: dar uma margem do tamanho do sprite (fposmod(position.x + margem, screen.x + margem * 2.0) - margem) para o objeto só teleportar quando sumir por completo, em vez de cortar no meio.

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

O Tiro: Uma Area2D Que Herda a Direção da Nave

O tiro do Asteroids é um projétil reto e de vida curta. Na nave, adicione o disparo com cooldown:

@export var bullet_scene: PackedScene
@export var fire_rate: float = 0.25
var _cooldown: float = 0.0

func _process(delta: float) -> void:
    _cooldown -= delta
    if Input.is_action_pressed("ui_accept") and _cooldown <= 0.0:
        _cooldown = fire_rate
        var bullet: Area2D = bullet_scene.instantiate() as Area2D
        bullet.global_position = global_position
        bullet.rotation = rotation
        get_parent().add_child(bullet)

O detalhe importante: o tiro nasce com a rotação da nave, não com a direção do movimento dela. Você pode estar deslizando para trás e atirando para frente, e é exatamente esse desacoplamento que torna o combate interessante.

A cena Bullet é uma Area2D com Sprite2D e CollisionShape2D:

extends Area2D

@export var speed: float = 600.0
@export var lifetime: float = 0.9

func _ready() -> void:
    area_entered.connect(_on_area_entered)
    get_tree().create_timer(lifetime).timeout.connect(queue_free)

func _process(delta: float) -> void:
    position += Vector2.from_angle(rotation) * speed * delta

func _on_area_entered(area: Area2D) -> void:
    if area.has_method("break_apart"):
        area.break_apart()
    queue_free()

O lifetime de 0.9 segundos reproduz o arcade: o tiro morre sozinho antes de cruzar a tela inteira, o que impede o jogador de encher o espaço de balas. Se quiser fidelidade total, aplique o mesmo wrap de tela ao tiro, que no original também dava a volta. Para aprofundar em pooling, colisão por camadas e variações de projétil, o post sobre como atirar projéteis em 2D na Godot cobre o sistema em detalhe.

Configure as camadas de física para evitar acidentes: nave na layer 1, asteroides na layer 2, tiros na layer 3. O tiro só monitora a layer 2, o asteroide só monitora as layers 1 e 3. Assim tiro não colide com nave, nem asteroide com asteroide.

Asteroides Que Se Quebram em Pedaços

A mecânica que dá nome ao jogo: destruir um asteroide grande gera dois médios, destruir um médio gera dois pequenos, e só o pequeno morre de vez. Modelamos isso com um campo size (3, 2 ou 1) e um sinal, deixando a criação dos pedaços para o gerenciador da fase.

A cena Asteroid é uma Area2D com visual e colisão:

extends Area2D

signal exploded(size: int, at: Vector2)

var size: int = 3
var drift: Vector2 = Vector2.ZERO
var spin: float = 0.0

func _ready() -> void:
    add_to_group("asteroids")
    spin = randf_range(-1.5, 1.5)

func _process(delta: float) -> void:
    position += drift * delta
    rotation += spin * delta
    var screen: Vector2 = get_viewport_rect().size
    position.x = fposmod(position.x, screen.x)
    position.y = fposmod(position.y, screen.y)

func break_apart() -> void:
    exploded.emit(size, global_position)
    queue_free()

Repare que o asteroide não sabe se dividir. Ele só anuncia "explodi, tinha tamanho X, estava aqui" e some. Quem decide o que acontece depois é o script principal, e essa separação evita o problema clássico de uma cena precisar instanciar a si mesma.

Game Loop: Spawn, Divisão, Ondas e Pontuação

O nó raiz da fase (Main, um Node2D com a nave como filha) amarra tudo:

extends Node2D

@export var asteroid_scene: PackedScene
var score: int = 0
var wave: int = 0

func _ready() -> void:
    _start_wave()

func _start_wave() -> void:
    wave += 1
    for i: int in 2 + wave:
        _spawn(3, _edge_position())

func _spawn(size: int, at: Vector2) -> void:
    var rock: Area2D = asteroid_scene.instantiate() as Area2D
    rock.size = size
    rock.position = at
    rock.scale = Vector2.ONE * 0.4 * float(size)
    rock.drift = Vector2.from_angle(randf() * TAU) * (200.0 - 45.0 * float(size))
    rock.exploded.connect(_on_asteroid_exploded)
    add_child(rock)

func _on_asteroid_exploded(size: int, at: Vector2) -> void:
    score += (4 - size) * 100
    if size > 1:
        for i: int in 2:
            _spawn(size - 1, at)
    if get_tree().get_nodes_in_group("asteroids").size() <= 1:
        _start_wave.call_deferred()

func _edge_position() -> Vector2:
    var screen: Vector2 = get_viewport_rect().size
    if randf() < 0.5:
        return Vector2(0.0, randf() * screen.y)
    return Vector2(randf() * screen.x, 0.0)

As decisões de design escondidas nesse script:

  • Velocidade inversa ao tamanho. 200.0 - 45.0 * size faz o asteroide grande vagar a 65 pixels por segundo e o pequeno voar a 155. É isso que transforma cada divisão em um aumento real de perigo, não só de quantidade.
  • Pontuação inversa também. (4 - size) * 100 paga 100 pelo grande, 200 pelo médio, 300 pelo pequeno. O alvo mais difícil vale mais, como no arcade.
  • Spawn nas bordas. _edge_position() sorteia um ponto na borda esquerda ou superior (que, com o wrap, cobre as quatro), garantindo que nenhuma onda nasce em cima da nave.
  • Onda nova via sinal, sem polling. Quando o último asteroide anuncia a explosão, ele ainda está na árvore, por isso o teste é <= 1 e não is_empty(). O call_deferred adia o spawn para o fim do frame, quando o queue_free já resolveu.

A morte da nave segue o mesmo padrão: conecte o area_entered de uma Area2D filha da nave (ou use move_and_slide com colisão nas layers certas), tire uma vida, reposicione no centro e dê dois segundos de invencibilidade piscando o sprite.

Polish e Próximos Passos

Com o loop fechado, uma tarde de polish transforma o protótipo em jogo:

  1. Chama de empuxo. Um GPUParticles2D filho da nave, ligado só enquanto ui_up está pressionado. É feedback visual da mecânica central, o upgrade de maior retorno.
  2. Explosões e som. Partículas de estilhaço no break_apart e três sons: tiro, explosão e o bump grave do empuxo. Áudio carrega metade da sensação de arcade.
  3. Screen shake sutil quando um asteroide grande explode perto da nave.
  4. HUD e high score. Um CanvasLayer com Label para pontos e vidas; salve o recorde com FileAccess em user://.
  5. Disco voador. O inimigo que atravessa a tela atirando é o próximo desafio de código natural: reutiliza o wrap, o tiro e o sinal de explosão que você já escreveu.

O padrão que fica deste projeto vale para tudo o que vier depois: física manual quando o motor atrapalha, sinais para desacoplar quem morre de quem gerencia, e fposmod no bolso para qualquer mundo que dá a volta. Nave na tela, asteroide partido ao meio: agora é ajustar números até o controle ficar com a sua cara.

Perguntas frequentes

Por que a nave do Asteroids continua deslizando depois que eu solto o botão?

Porque o empuxo altera a velocidade, não a posição. A cada frame você soma um vetor de aceleração ao velocity, e quando solta o botão o velocity acumulado continua lá, movendo a nave. Isso é inércia simulada: para parar, o jogador precisa girar a nave e aplicar empuxo na direção contrária, exatamente como no arcade original.

Devo usar CharacterBody2D ou RigidBody2D para a nave do Asteroids?

Para fins didáticos, CharacterBody2D com velocity manual. Você controla cada linha da física, o wrap de tela funciona com uma simples troca de position e o comportamento é 100% previsível. RigidBody2D dá inércia de graça, mas teleportar um corpo físico de um lado da tela para o outro exige _integrate_forces e cuidados extras que só atrapalham o aprendizado.

Como fazer o wrap de tela na Godot, com a nave saindo de um lado e voltando do outro?

Use fposmod(position.x, largura_da_tela) e fposmod(position.y, altura_da_tela) a cada frame. A função fposmod devolve sempre um resto positivo, então uma posição de -10 numa tela de 1152 pixels vira 1142 automaticamente, sem nenhum if. Pegue o tamanho da tela com get_viewport_rect().size.

Quanto tempo leva para fazer um jogo estilo Asteroids na Godot?

Com este tutorial, um fim de semana para a versão jogável: nave, tiro, asteroides que se dividem e pontuação. Nave voando com inércia sai em menos de uma hora. O que consome mais tempo é o polish: partículas na explosão, sons, telas de início e de game over, invencibilidade ao renascer. Reserve uma segunda semana se quiser algo apresentável.

Preciso de arte própria para fazer um Asteroids?

Não. O jogo original usava vetores brancos sobre fundo preto, então um Polygon2D triangular para a nave e polígonos irregulares para os asteroides já entregam o visual clássico. Se preferir sprites, o pacote gratuito Space Shooter Redux, do Kenney, tem naves, meteoros em vários tamanhos e tiros prontos, com licença livre até para uso comercial.