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

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 scriptSprite2DouPolygon2D, o visual da naveCollisionShape2Dcom umCircleShape2Dpequeno
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 umfloatentre -1 e 1. Multiplicado porturn_speed(em radianos por segundo) e pordelta, 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 quetransform.xpara 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 ovelocityacumulado 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; deixedragem 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.
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 * sizefaz 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) * 100paga 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 é
<= 1e nãois_empty(). Ocall_deferredadia o spawn para o fim do frame, quando oqueue_freejá 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:
- Chama de empuxo. Um
GPUParticles2Dfilho da nave, ligado só enquantoui_upestá pressionado. É feedback visual da mecânica central, o upgrade de maior retorno. - Explosões e som. Partículas de estilhaço no
break_aparte três sons: tiro, explosão e o bump grave do empuxo. Áudio carrega metade da sensação de arcade. - Screen shake sutil quando um asteroide grande explode perto da nave.
- HUD e high score. Um
CanvasLayercomLabelpara pontos e vidas; salve o recorde comFileAccessemuser://. - 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.


