Nesta aula você aprende a detectar colisões com círculos, retângulos e polígonos, a separar a fase ampla da fase estreita, a evitar o tunelamento com testes de varredura, e constrói um Asteroids completo, com regras testadas por JUnit sem abrir janela e uma casca gráfica em linhas vetoriais, como no fliperama de 1979.
Na cena da aula anterior, o barco navegava sozinho num mar vazio. Jogos acontecem quando as coisas se encontram: a bala acerta, a nave bate, o personagem pisa na plataforma. Para o computador, "se encontrar" é uma pergunta de geometria, feita milhares de vezes por segundo: estas duas formas se sobrepõem? Responder rápido e certo é a arte da detecção de colisões, e o jogo desta aula foi escolhido porque cabe inteiro nessa pergunta.
O Asteroids, de 1979
Em novembro de 1979, a Atari lançou o Asteroids, criado por Lyle Rains e Ed Logg. O jogador controla uma nave triangular no centro da tela, cercado de rochas que flutuam em todas as direções:
- a nave gira, acelera e atira;
- cada rocha atingida se parte em duas menores;
- as pequenas somem;
- quem sai por uma borda da tela volta pela oposta.
O jogo usava um monitor vetorial: em vez de uma grade de pixels, um feixe de elétrons desenhava linhas diretamente na tela, e daí o visual de traços brancos sobre fundo preto. Foi o jogo de fliperama mais vendido da Atari, com dezenas de milhares de gabinetes fabricados.
O Asteroids é perfeito para estudar colisões porque tem três tipos diferentes de encontro:
- tiro contra rocha: objeto minúsculo e rápido contra objeto grande;
- nave contra rocha: triângulo contra forma irregular;
- as bordas: quem sai por um lado entra pelo outro.
A geometria das colisões
Toda detecção de colisão começa por escolher com que forma representar cada objeto. A forma de colisão raramente é a forma desenhada: é uma aproximação, escolhida pelo equilíbrio entre precisão e custo.
| Forma | Teste de sobreposição | Custo | Bom para |
|---|---|---|---|
| Círculo | distância entre centros ≤ soma dos raios | 1 subtração por eixo, sem raiz (compare quadrados) | rochas, bolas, projéteis, personagens "redondos" |
| Retângulo alinhado (AABB) | os intervalos se sobrepõem em x e em y | 4 comparações | tiles, caixas, plataformas, telas de UI |
| Polígono convexo | teorema do eixo separador (SAT): procura uma reta que os separe | proporcional ao número de lados | naves, rampas, formas angulosas |
O teorema do eixo separador diz que dois polígonos convexos não se tocam se, e somente se, existe uma reta entre eles. Para encontrá-la basta testar as direções perpendiculares aos lados de cada polígono: se em alguma delas as "sombras" dos dois não se sobrepõem, há separação. O libGDX traz esse teste pronto (Intersector.overlapConvexPolygons), assim como dezenas de outros: círculo com retângulo, segmento com círculo, ponto em triângulo.
No nosso Asteroids, as rochas são desenhadas como polígonos irregulares, mas colidem como círculos. O jogador não percebe a diferença, e o teste é barato. É a mesma troca que o fliperama original fazia com um processador de 1,5 MHz.
Fase ampla, fase estreita
Com n objetos, testar todos contra todos custa n²/2 pares. Com 20 rochas e 5 tiros são poucas centenas de testes, e qualquer computador faz isso sem esforço. Com 2.000 objetos, são 2 milhões por passo. A solução universal é dividir o trabalho em duas fases.
- Fase ampla (broad phase): um teste grosseiro e baratíssimo descarta os pares que com certeza não se tocam. Pode ser um círculo que envolve o objeto inteiro, ou uma grade espacial em que só objetos na mesma célula ou em células vizinhas são comparados.
- Fase estreita (narrow phase): o teste exato, caro, só para os pares que sobraram.
A colisão da nave usa exatamente isso: um círculo que envolve o triângulo inteiro descarta quase todas as rochas com uma comparação de distâncias ao quadrado, e só as que passam fazem o teste preciso do triângulo contra o círculo.
O tiro que atravessa a pedra
Existe uma armadilha que todo jogo de ação encontra cedo ou tarde. A simulação avança em saltos: a cada passo, o tiro está num lugar e, no passo seguinte, noutro. Se o salto for maior que o objeto, o tiro pode estar antes da pedra num passo e depois dela no seguinte, sem nunca ter estado dentro dela. Um teste que só olha a posição atual não vê nada. É o tunelamento (tunneling).
A defesa é testar o caminho, e não o ponto: o segmento de reta entre a posição anterior e a atual contra o círculo da pedra. É o chamado teste de varredura (swept test). O primeiro teste de unidade do jogo, mais adiante, demonstra o problema com números: um tiro a 60 unidades por segundo passa de x = 4,5 para x = 5,5 num passo, sobre uma pedra de raio 0,3 centrada em x = 5. Nenhuma das duas posições está dentro da pedra, mas o segmento entre elas a atravessa.
As regras: um mundo sem desenho
A arquitetura segue o que funcionou no painel MVVM: toda a regra do jogo numa classe que não sabe que existe tela. Os comandos do jogador chegam como um record:
package br.com.exemplo.asteroides;
/** O que o jogador quer neste passo. Vem do teclado no jogo, e de qualquer lugar nos testes. */
public record Comandos(float girar, boolean acelerar, boolean atirar) {
public static final Comandos NENHUM = new Comandos(0, false, false);
}
As rochas:
package br.com.exemplo.asteroides;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import java.util.Random;
/** Uma rocha: posição, velocidade, tamanho e um contorno irregular só para o desenho. */
public final class Asteroide {
public enum Tamanho {
GRANDE(1.1f, 20), MEDIO(0.6f, 50), PEQUENO(0.3f, 100); // pontos do Asteroids original, de 1979
final float raio; final int pontos;
Tamanho(float raio, int pontos) { this.raio = raio; this.pontos = pontos; }
Tamanho menor() { return this == GRANDE ? MEDIO : this == MEDIO ? PEQUENO : null; }
}
final Vector2 posicao = new Vector2();
final Vector2 velocidade = new Vector2();
final Tamanho tamanho;
final float[] contorno; // vértices em volta da origem, para o ShapeRenderer
float angulo, giro;
Asteroide(Tamanho tamanho, float x, float y, float vx, float vy, Random sorte) {
this.tamanho = tamanho;
posicao.set(x, y);
velocidade.set(vx, vy);
giro = (sorte.nextFloat() - 0.5f) * 90f;
int n = 10;
contorno = new float[n * 2];
for (int i = 0; i < n; i++) {
float a = i * MathUtils.PI2 / n;
float r = tamanho.raio * (0.75f + sorte.nextFloat() * 0.35f); // irregular, mas dentro do raio
contorno[i * 2] = MathUtils.cos(a) * r;
contorno[i * 2 + 1] = MathUtils.sin(a) * r;
}
}
float raio() { return tamanho.raio; }
}
O enum carrega os dados de cada tamanho, como em "Dados sem Cerimônia — Records e Enums de Verdade": raio, pontos e qual é o tamanho seguinte na divisão. Os pontos são os do jogo original: 20 pela rocha grande, 50 pela média, 100 pela pequena. Quanto menor o alvo, mais difícil acertar, e maior o prêmio.
E o mundo, com todas as regras:
package br.com.exemplo.asteroides;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
/**
* Toda a regra do jogo, sem uma linha de desenho: por isso roda (e é testada) sem janela.
* Unidades: o mundo mede 16 x 9; as bordas "dão a volta" (o espaço é um toro).
*/
public final class Mundo {
public static final float LARGURA = 16f, ALTURA = 9f;
static final float RAIO_NAVE = 0.35f;
static final float GIRO = 270f; // graus/s
static final float EMPUXO = 9f; // u/s²
static final float ATRITO = 0.6f; // o "atrito espacial" que deixa o jogo jogável
static final float VEL_MAX_NAVE = 8f;
static final float VEL_TIRO = 14f;
static final float VIDA_TIRO = 0.9f; // s
static final float CADENCIA = 0.18f; // s entre tiros
static final float INVULNERAVEL = 2.5f; // s depois de renascer
public enum Estado { JOGANDO, FIM }
public static final class Nave {
final Vector2 posicao = new Vector2(LARGURA / 2, ALTURA / 2);
final Vector2 velocidade = new Vector2();
float angulo = 90f; // apontando para cima
float invulneravel = INVULNERAVEL;
boolean motorLigado;
/** Os três vértices do triângulo, no mundo (usados na colisão e no desenho). */
void vertices(Vector2 bico, Vector2 esquerda, Vector2 direita) {
bico.set(0.55f, 0).rotateDeg(angulo).add(posicao);
esquerda.set(-0.35f, 0.3f).rotateDeg(angulo).add(posicao);
direita.set(-0.35f, -0.3f).rotateDeg(angulo).add(posicao);
}
}
public static final class Tiro {
final Vector2 posicao = new Vector2(), anterior = new Vector2(), velocidade = new Vector2();
float vida = VIDA_TIRO;
}
final Nave nave = new Nave();
final List<Asteroide> asteroides = new ArrayList<>();
final List<Tiro> tiros = new ArrayList<>();
final Random sorte;
int pontos, vidas = 3, onda;
Estado estado = Estado.JOGANDO;
private float recarga;
// vetores de trabalho reaproveitados: nada de "new" dentro do passo
private final Vector2 a = new Vector2(), b = new Vector2(), c = new Vector2();
public Mundo(long semente) {
sorte = new Random(semente);
novaOnda();
}
void novaOnda() {
onda++;
for (int i = 0; i < 2 + onda * 2; i++) {
// nasce longe da nave: numa das bordas
float x = sorte.nextBoolean() ? sorte.nextFloat() * LARGURA : 0;
float y = x == 0 ? sorte.nextFloat() * ALTURA : 0;
float ang = sorte.nextFloat() * 360f, vel = 0.8f + sorte.nextFloat() * (0.6f + onda * 0.2f);
asteroides.add(new Asteroide(Asteroide.Tamanho.GRANDE, x, y,
MathUtils.cosDeg(ang) * vel, MathUtils.sinDeg(ang) * vel, sorte));
}
}
public void atualizar(float dt, Comandos cmd) {
if (estado == Estado.FIM) return;
moverNave(dt, cmd);
moverTiros(dt);
for (Asteroide ast : asteroides) {
ast.posicao.mulAdd(ast.velocidade, dt);
ast.angulo += ast.giro * dt;
darAVolta(ast.posicao);
}
colisoesTiroAsteroide();
colisaoNaveAsteroide();
if (asteroides.isEmpty()) novaOnda();
}
private void moverNave(float dt, Comandos cmd) {
nave.angulo += cmd.girar() * GIRO * dt;
nave.motorLigado = cmd.acelerar();
if (cmd.acelerar()) {
nave.velocidade.add(MathUtils.cosDeg(nave.angulo) * EMPUXO * dt, MathUtils.sinDeg(nave.angulo) * EMPUXO * dt);
}
nave.velocidade.scl(1f / (1f + ATRITO * dt)).limit(VEL_MAX_NAVE);
nave.posicao.mulAdd(nave.velocidade, dt);
darAVolta(nave.posicao);
nave.invulneravel = Math.max(0, nave.invulneravel - dt);
recarga -= dt;
if (cmd.atirar() && recarga <= 0) {
var t = new Tiro();
nave.vertices(a, b, c);
t.posicao.set(a);
t.anterior.set(a);
t.velocidade.set(VEL_TIRO, 0).rotateDeg(nave.angulo).add(nave.velocidade);
tiros.add(t);
recarga = CADENCIA;
}
}
private void moverTiros(float dt) {
for (Iterator<Tiro> it = tiros.iterator(); it.hasNext(); ) {
Tiro t = it.next();
t.anterior.set(t.posicao);
t.posicao.mulAdd(t.velocidade, dt);
t.vida -= dt;
if (t.vida <= 0) { it.remove(); continue; }
if (darAVolta(t.posicao)) t.anterior.set(t.posicao); // atravessou a borda: não vale o "rastro"
}
}
/**
* Tiro × asteroide com teste de VARREDURA: o segmento do passo anterior ao atual contra o círculo.
* Um teste só de ponto deixaria tiros rápidos "atravessarem" pedras pequenas entre dois passos.
*/
private void colisoesTiroAsteroide() {
List<Asteroide> novos = new ArrayList<>();
for (Iterator<Tiro> it = tiros.iterator(); it.hasNext(); ) {
Tiro t = it.next();
for (Iterator<Asteroide> ia = asteroides.iterator(); ia.hasNext(); ) {
Asteroide ast = ia.next();
float r = ast.raio();
if (Intersector.intersectSegmentCircle(t.anterior, t.posicao, ast.posicao, r * r)) {
it.remove();
ia.remove();
pontos += ast.tamanho.pontos;
partir(ast, novos);
break;
}
}
}
asteroides.addAll(novos);
}
/** Uma rocha atingida vira duas menores, saindo em direções opostas e um pouco mais rápidas. */
void partir(Asteroide ast, List<Asteroide> destino) {
Asteroide.Tamanho menor = ast.tamanho.menor();
if (menor == null) return;
float vel = ast.velocidade.len() * 1.3f + 0.5f;
float direcao = ast.velocidade.angleDeg() + 90f;
for (int lado = -1; lado <= 1; lado += 2) {
float ang = direcao + (lado < 0 ? 0 : 180f) + (sorte.nextFloat() - 0.5f) * 40f;
destino.add(new Asteroide(menor, ast.posicao.x, ast.posicao.y,
MathUtils.cosDeg(ang) * vel, MathUtils.sinDeg(ang) * vel, sorte));
}
}
/**
* Nave × asteroide em duas fases: um teste barato de círculos descarta quase tudo (fase ampla);
* só os que passam fazem o teste exato do triângulo contra o círculo (fase estreita).
*/
private void colisaoNaveAsteroide() {
if (nave.invulneravel > 0) return;
nave.vertices(a, b, c);
for (Asteroide ast : asteroides) {
float r = ast.raio();
float soma = r + 0.6f; // raio que envolve a nave toda
if (nave.posicao.dst2(ast.posicao) > soma * soma) continue; // fase ampla: longe demais
float r2 = r * r;
boolean toca = Intersector.intersectSegmentCircle(a, b, ast.posicao, r2)
|| Intersector.intersectSegmentCircle(b, c, ast.posicao, r2)
|| Intersector.intersectSegmentCircle(c, a, ast.posicao, r2)
|| Intersector.isPointInTriangle(ast.posicao, a, b, c);
if (toca) { perderVida(); return; }
}
}
private void perderVida() {
vidas--;
if (vidas <= 0) { estado = Estado.FIM; return; }
nave.posicao.set(LARGURA / 2, ALTURA / 2);
nave.velocidade.setZero();
nave.angulo = 90f;
nave.invulneravel = INVULNERAVEL;
}
/** O espaço é um toro: quem sai por um lado entra pelo oposto. Devolve true se deu a volta. */
static boolean darAVolta(Vector2 p) {
float x = p.x, y = p.y;
if (p.x < 0) p.x += LARGURA; else if (p.x >= LARGURA) p.x -= LARGURA;
if (p.y < 0) p.y += ALTURA; else if (p.y >= ALTURA) p.y -= ALTURA;
return x != p.x || y != p.y;
}
}
Os pontos a estudar:
- O teste do triângulo contra o círculo tem duas partes. Há contato se algum lado do triângulo cruza o círculo (três testes de segmento), ou se o centro do círculo está dentro do triângulo. Esse segundo caso cobre uma pedra pequena inteira dentro da nave, sem tocar em nenhum lado.
- Comparar quadrados:
dst2é a distância ao quadrado, esoma * somatambém. Evitar a raiz quadrada num teste feito milhares de vezes por segundo é um hábito antigo, e ainda válido. - A borda e o rastro do tiro. Quando um tiro atravessa a borda e reaparece do outro lado, o segmento entre a posição anterior e a atual cruzaria a tela inteira, e acertaria qualquer pedra no caminho. Por isso, ao dar a volta, o "anterior" é reposicionado.
- Invulnerabilidade ao renascer. Sem ela, a nave poderia renascer em cima de uma pedra e morrer em sequência. Dois segundos e meio de proteção, com a nave piscando, é a solução clássica.
- Remover durante a iteração. Tiros e pedras saem das listas pelo próprio
Iterator, comit.remove(). Umlista.remove(x)dentro de umfor-eachlançariaConcurrentModificationException, como vimos em "Listas, Conjuntos e Mapas — o Collections Framework". As pedras novas, das divisões, esperam numa lista à parte e só entram no fim. - O
Randomcom semente. O mundo recebe a semente de fora. No jogo ela vem do relógio, e nos testes é fixa, o que torna tudo reproduzível.
Regras testadas sem janela
Como o Mundo não desenha, ele é testado com JUnit, como qualquer classe:
package br.com.exemplo.asteroides;
import com.badlogic.gdx.math.Intersector;
import com.badlogic.gdx.math.Vector2;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** As regras do Asteroids, testadas sem janela, sem OpenGL e sem esperar um único quadro. */
class MundoTest {
static final float PASSO = 1f / 60f;
Mundo mundo;
@BeforeEach
void preparar() {
mundo = new Mundo(42);
mundo.asteroides.clear(); // cada teste monta a sua cena
mundo.nave.invulneravel = 0;
}
Asteroide pedra(Asteroide.Tamanho t, float x, float y) {
return new Asteroide(t, x, y, 0, 0, mundo.sorte);
}
@Test
void quemSaiPorUmLadoEntraPeloOutro() {
mundo.nave.posicao.set(15.95f, 4f);
mundo.nave.velocidade.set(6f, 0);
mundo.asteroides.add(pedra(Asteroide.Tamanho.GRANDE, 3, 8)); // longe, só para não virar nova onda
mundo.atualizar(PASSO, Comandos.NENHUM);
assertTrue(mundo.nave.posicao.x < 1f, "deveria reaparecer na esquerda: " + mundo.nave.posicao);
}
@Test
void tiroNaPedraGrandeDaVinteEDivideEmDuasMedias() {
mundo.nave.posicao.set(8, 2); // apontando para cima (90°)
mundo.asteroides.add(pedra(Asteroide.Tamanho.GRANDE, 8, 5));
mundo.atualizar(PASSO, new Comandos(0, false, true));
for (int i = 0; i < 30 && mundo.pontos == 0; i++) mundo.atualizar(PASSO, Comandos.NENHUM);
assertEquals(20, mundo.pontos);
assertEquals(2, mundo.asteroides.size());
assertTrue(mundo.asteroides.stream().allMatch(a -> a.tamanho == Asteroide.Tamanho.MEDIO));
}
@Test
void pedraPequenaSomeSemSeDividir() {
var novos = new java.util.ArrayList<Asteroide>();
mundo.partir(pedra(Asteroide.Tamanho.PEQUENO, 5, 5), novos);
assertTrue(novos.isEmpty());
}
@Test
void tiroSomeDepoisDoTempoDeVida() {
mundo.asteroides.add(pedra(Asteroide.Tamanho.GRANDE, 1, 1));
mundo.nave.angulo = 0;
mundo.nave.posicao.set(8, 8);
mundo.atualizar(PASSO, new Comandos(0, false, true));
assertEquals(1, mundo.tiros.size());
for (int i = 0; i < 60; i++) mundo.atualizar(PASSO, Comandos.NENHUM); // 1 s > 0,9 s de vida
assertEquals(0, mundo.tiros.size());
}
@Test
void naveInvulneravelNaoMorreDepoisMorreQuandoAProtecaoAcaba() {
mundo.asteroides.add(pedra(Asteroide.Tamanho.GRANDE, 8, 4.5f)); // em cima da nave
mundo.nave.invulneravel = 1f;
mundo.atualizar(PASSO, Comandos.NENHUM);
assertEquals(3, mundo.vidas);
int passos = 0;
while (mundo.vidas == 3 && passos < 120) { mundo.atualizar(PASSO, Comandos.NENHUM); passos++; }
assertEquals(2, mundo.vidas);
assertEquals(60, passos, 1, "morreu quando o 1 s de proteção acabou");
assertEquals(Mundo.INVULNERAVEL, mundo.nave.invulneravel, 0.001f, "renasce protegida");
}
@Test
void semVidasOJogoAcaba() {
mundo.vidas = 1;
mundo.asteroides.add(pedra(Asteroide.Tamanho.GRANDE, 8, 4.5f));
mundo.atualizar(PASSO, Comandos.NENHUM);
assertEquals(Mundo.Estado.FIM, mundo.estado);
}
@Test
void limparTodasAsPedrasComecaUmaOndaMaior() {
int ondaAntes = mundo.onda;
mundo.atualizar(PASSO, Comandos.NENHUM); // lista vazia → nova onda
assertEquals(ondaAntes + 1, mundo.onda);
assertEquals(2 + mundo.onda * 2, mundo.asteroides.size());
}
/** Por que o teste de varredura existe: um tiro rápido pula por cima de uma pedra pequena. */
@Test
void tiroRapidoAtravessaAPedraNoTesteDePontoMasNaoNaVarredura() {
var centro = new Vector2(5, 5);
float raio = 0.3f;
var antes = new Vector2(4.5f, 5); // 1 unidade por passo = 60 u/s
var depois = new Vector2(5.5f, 5);
boolean pontoAntes = antes.dst(centro) <= raio, pontoDepois = depois.dst(centro) <= raio;
assertFalse(pontoAntes || pontoDepois, "o teste de ponto não vê a pedra");
assertTrue(Intersector.intersectSegmentCircle(antes, depois, centro, raio * raio), "a varredura vê");
}
}
MundoTest ✔
├─ tiroSomeDepoisDoTempoDeVida() ✔
├─ tiroNaPedraGrandeDaVinteEDivideEmDuasMedias() ✔
├─ tiroRapidoAtravessaAPedraNoTesteDePontoMasNaoNaVarredura() ✔
├─ naveInvulneravelNaoMorreDepoisMorreQuandoAProtecaoAcaba() ✔
├─ limparTodasAsPedrasComecaUmaOndaMaior() ✔
├─ semVidasOJogoAcaba() ✔
├─ pedraPequenaSomeSemSeDividir() ✔
└─ quemSaiPorUmLadoEntraPeloOutro() ✔
Test run finished after 125 ms
[ 8 tests successful ]
[ 0 tests failed ]
Oito regras, em 125 ms (JUnit 6.1.3, libGDX 1.14.2, JDK 27), usando as classes de matemática do próprio libGDX (Vector2, Intersector) e nenhum contexto gráfico. Uma versão desse teste que deixa o laço rodar dez passos a mais depois da morte falha com expected: <2.5> but was: <2.3333335>, e o erro está no teste, e não no jogo: esses dez passos (1/6 de segundo) consomem parte da nova proteção. A versão acima para exatamente no passo da morte e ainda confere quando ela aconteceu (60 passos, o segundo de proteção que o teste deu). É um lembrete de que um teste que falha diz "algo está errado", e não necessariamente "o código está errado".
A casca gráfica
O jogo em si só lê o teclado, avança o mundo em passos fixos e desenha. O desenho usa o ShapeRenderer, que desenha linhas, círculos e triângulos sem texturas: é perfeito para o visual vetorial do original.
package br.com.exemplo.asteroides;
import com.badlogic.gdx.ApplicationAdapter;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.InputAdapter;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.PixmapIO;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.glutils.ShapeRenderer;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;
import com.badlogic.gdx.utils.ScreenUtils;
import com.badlogic.gdx.utils.viewport.FitViewport;
import com.badlogic.gdx.utils.viewport.ScreenViewport;
/** A casca gráfica: lê o teclado, avança o Mundo em passos fixos e desenha linhas, como o fliperama de 1979. */
public class AsteroidesJogo extends ApplicationAdapter {
static final float PASSO = 1f / 60f;
private Mundo mundo;
private ShapeRenderer linhas;
private SpriteBatch lote;
private BitmapFont fonte;
private FitViewport vista;
private ScreenViewport vistaHud;
private float acumulado;
private int fotos;
private final Vector2 a = new Vector2(), b = new Vector2(), c = new Vector2();
@Override
public void create() {
mundo = new Mundo(System.nanoTime());
linhas = new ShapeRenderer();
lote = new SpriteBatch();
fonte = new BitmapFont();
vista = new FitViewport(Mundo.LARGURA, Mundo.ALTURA, new OrthographicCamera());
vistaHud = new ScreenViewport();
Gdx.input.setInputProcessor(new InputAdapter() {
@Override
public boolean keyDown(int tecla) {
if (tecla == Keys.F12) fotografar();
if (tecla == Keys.ENTER && mundo.estado == Mundo.Estado.FIM) mundo = new Mundo(System.nanoTime());
if (tecla == Keys.ESCAPE) Gdx.app.exit();
return true;
}
});
}
@Override
public void resize(int largura, int altura) {
vista.update(largura, altura, true);
vistaHud.update(largura, altura, true);
}
@Override
public void render() {
acumulado += Math.min(Gdx.graphics.getDeltaTime(), 0.25f);
var cmd = new Comandos(
(Gdx.input.isKeyPressed(Keys.LEFT) ? 1 : 0) - (Gdx.input.isKeyPressed(Keys.RIGHT) ? 1 : 0),
Gdx.input.isKeyPressed(Keys.UP),
Gdx.input.isKeyPressed(Keys.SPACE));
while (acumulado >= PASSO) {
mundo.atualizar(PASSO, cmd);
acumulado -= PASSO;
}
ScreenUtils.clear(Color.BLACK);
vista.apply();
linhas.setProjectionMatrix(vista.getCamera().combined);
linhas.begin(ShapeRenderer.ShapeType.Line);
linhas.setColor(Color.WHITE);
for (Asteroide ast : mundo.asteroides) desenharPedra(ast);
desenharNave();
linhas.end();
linhas.begin(ShapeRenderer.ShapeType.Filled);
for (Mundo.Tiro t : mundo.tiros) linhas.circle(t.posicao.x, t.posicao.y, 0.05f, 8);
linhas.end();
vistaHud.apply();
lote.setProjectionMatrix(vistaHud.getCamera().combined);
lote.begin();
float topo = vistaHud.getScreenHeight() - 10;
fonte.draw(lote, "%06d vidas: %d onda: %d".formatted(mundo.pontos, mundo.vidas, mundo.onda), 10, topo);
if (mundo.estado == Mundo.Estado.FIM) {
fonte.draw(lote, "FIM DE JOGO - ENTER para recomeçar", vistaHud.getScreenWidth() / 2f - 130, vistaHud.getScreenHeight() / 2f);
}
lote.end();
}
private void desenharPedra(Asteroide ast) {
float[] v = ast.contorno;
float cos = MathUtils.cosDeg(ast.angulo), sen = MathUtils.sinDeg(ast.angulo);
for (int i = 0; i < v.length; i += 2) {
int j = (i + 2) % v.length;
float x1 = v[i] * cos - v[i + 1] * sen, y1 = v[i] * sen + v[i + 1] * cos;
float x2 = v[j] * cos - v[j + 1] * sen, y2 = v[j] * sen + v[j + 1] * cos;
linhas.line(ast.posicao.x + x1, ast.posicao.y + y1, ast.posicao.x + x2, ast.posicao.y + y2);
}
}
private void desenharNave() {
if (mundo.estado == Mundo.Estado.FIM) return;
var nave = mundo.nave;
// piscando enquanto invulnerável
if (nave.invulneravel > 0 && ((int) (nave.invulneravel * 8)) % 2 == 0) return;
nave.vertices(a, b, c);
linhas.triangle(a.x, a.y, b.x, b.y, c.x, c.y);
if (nave.motorLigado && MathUtils.randomBoolean()) { // chama do motor, tremulando
float fx = nave.posicao.x - MathUtils.cosDeg(nave.angulo) * 0.7f;
float fy = nave.posicao.y - MathUtils.sinDeg(nave.angulo) * 0.7f;
linhas.line(b.x * 0.6f + c.x * 0.4f, b.y * 0.6f + c.y * 0.4f, fx, fy);
linhas.line(c.x * 0.6f + b.x * 0.4f, c.y * 0.6f + b.y * 0.4f, fx, fy);
}
}
private void fotografar() {
Pixmap tela = Pixmap.createFromFrameBuffer(0, 0, Gdx.graphics.getBackBufferWidth(), Gdx.graphics.getBackBufferHeight());
PixmapIO.writePNG(Gdx.files.local("asteroides-" + (++fotos) + ".png"), tela, -1, true);
tela.dispose();
Gdx.app.log("Asteroides", "foto %d: %d pontos, %d pedras, %d tiros".formatted(
fotos, mundo.pontos, mundo.asteroides.size(), mundo.tiros.size()));
}
@Override
public void dispose() {
linhas.dispose();
lote.dispose();
fonte.dispose();
}
}
A rotação do contorno das pedras usa a fórmula de qualquer livro de geometria analítica. x' = x·cos θ − y·sen θ e y' = x·sen θ + y·cos θ giram cada vértice em torno do centro da pedra, com o seno e o cosseno calculados uma vez por pedra, e não por vértice. A lógica usa as mesmas coordenadas e as mesmas regras que os testes exercitaram; a casca só traduz.
O lançador de desktop é igual ao da aula anterior, trocando new Travessia() por new AsteroidesJogo(). Rodado com o libGDX 1.14.2 no JDK 27, com um F12 enviado ao InputAdapter três segundos depois de abrir, a captura mostrou o placar "000000 vidas: 3 onda: 1", quatro rochas grandes em contorno irregular, uma delas cortada pela borda de cima, e a nave triangular no centro:
[Asteroides] foto 1: 0 pontos, 4 pedras, 0 tiros
A partida com o teclado (girar, acelerar, atirar) não foi automatizada aqui: as regras que ela exercita são as dos oito testes acima.
Repare num detalhe que voltará em breve. A cada tiro, o mundo cria um new Tiro(); a cada divisão, um new ArrayList e dois new Asteroide; a cada quadro, a casca cria um new Comandos. São poucos objetos, e o jogo roda liso. Num jogo com milhares de partículas, porém, essa criação constante de objetos é o que faz o coletor de lixo aparecer na forma de engasgos. "Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos" vai medir isso e reescrever essas partes.
Detectar colisões foi a parte fácil. Responder a elas de forma realista é outra história: quando uma caixa cai sobre outra, quanto ela afunda, quanto quica, quanto desliza, e como uma pilha de dez caixas fica de pé sem tremer? Escrever isso à mão é um projeto de anos, e é o trabalho dos motores de física.
Fontes e leituras recomendadas
- Real-Time Collision Detection, de Christer Ericson: o livro de referência sobre o assunto, das formas básicas às estruturas de fase ampla.
- libGDX — Intersector (Javadoc): todos os testes de interseção prontos usados nesta aula.
- libGDX Wiki — ShapeRenderer: linhas, formas preenchidas e o custo de trocar de tipo.
- Game Programming Patterns — Spatial Partition, de Robert Nystrom: grades e árvores para a fase ampla.
- Asteroids (video game) — Wikipedia: a história do jogo, do monitor vetorial às regras originais.
- The Separating Axis Theorem (dyn4j): uma explicação visual e passo a passo do SAT.
Exercícios
Exercício 1
Acrescente o disco voador do Asteroids original: a cada 20 segundos, um disco atravessa a tela em linha reta e atira numa direção aleatória. Que formas de colisão você usaria para ele, e que testes escreveria?
Ver resposta
✓ Resposta: O disco é quase uma elipse larga. Um bom equilíbrio é representá-lo com dois círculos lado a lado, ou com um retângulo alinhado, e testar os tiros com o mesmo segmento contra círculo. A lógica entra no Mundo: um campo disco (nulo quando ausente), um cronômetro para o aparecimento, o movimento linear sem dar a volta (ele sai da tela e some) e os tiros inimigos numa segunda lista, que colide só com a nave. Os testes: "o disco aparece depois de 20 s" (avançar 1200 passos), "tiro do jogador no disco dá os pontos dele e o remove", "tiro do disco na nave invulnerável não mata", "o disco que sai da tela deixa de existir". Todos sem janela, como os oito da aula.
Exercício 2
No colisoesTiroAsteroide, um tiro pode acertar duas pedras que se sobrepõem no mesmo passo? O que o código faz, e por quê?
Ver resposta
✓ Resposta: Não. Assim que o primeiro acerto é encontrado, o tiro e a pedra são removidos e o break sai do laço das pedras: o tiro não existe mais para testar outras. O comportamento é deliberado e fiel ao original, em que um tiro destrói uma rocha. Uma melhoria possível é, entre as pedras que o segmento cruza, escolher a mais próxima do ponto anterior do tiro, a primeira que ele realmente encontraria. Isso exige calcular o ponto de entrada no círculo, e não só um sim ou não. Com pedras se sobrepondo, a ordem da lista decide qual é atingida, e isso pode parecer injusto ao jogador.
Exercício 3
As rochas que dão a volta pela borda "saltam" de um lado para o outro. Uma rocha grande parcialmente fora da tela pode atingir a nave que está do outro lado? O que seria preciso para tratar isso corretamente?
Ver resposta
✓ Resposta: No código atual, não: a colisão usa a posição do centro, e uma rocha com o centro em x = 15,8 não toca a nave em x = 0,2, embora visualmente parte dela devesse aparecer do outro lado. Para tratar o espaço como um toro de verdade, a distância entre dois pontos deve ser a menor considerando a volta: dx = abs(x1 - x2); dx = min(dx, LARGURA - dx), e o mesmo para dy. Os testes de segmento precisam ser repetidos com cópias "fantasmas" dos objetos próximos às bordas. O desenho também precisaria desenhar a rocha duas vezes perto da borda. O Asteroids original aceitava esse salto, e muitos jogadores nem percebem.
Exercício 4
Mude VEL_TIRO de 14 para 70 e rode o teste tiroNaPedraGrandeDaVinteEDivideEmDuasMedias. Ele continua passando? E se a colisão usasse só a posição atual do tiro?
Ver resposta
✓ Resposta: Continua passando: com a varredura, o segmento de cada passo (70/60 ≈ 1,17 unidade) cruza o círculo da pedra (raio 1,1), mesmo que nenhuma das duas posições caia dentro dele. Com o teste de ponto, a 1,17 unidade por passo o tiro ainda acertaria a pedra grande na maioria dos casos (o diâmetro é 2,2), mas passaria por cima das pequenas (diâmetro 0,6) com frequência. O teste tiroRapidoAtravessaAPedraNoTesteDePontoMasNaoNaVarredura mostra exatamente esse caso. Por isso o teste de regra não basta sozinho: ele precisa de um cenário com pedra pequena para pegar o bug.
Exercício 5
O Mundo usa new Random(semente). Descreva como gravar uma partida inteira para "repeti-la" depois, quadro a quadro, sem guardar as posições das pedras.
Ver resposta
✓ Resposta: Guarde a semente e a sequência de Comandos de cada passo, que é uma lista pequena, de três valores por passo. Para repetir, crie um Mundo com a mesma semente e chame atualizar(PASSO, comandoGravado[i]) para cada passo. Como o passo é fixo e a lógica é determinística (mesmo Random, mesma ordem de operações, sem depender do relógio), o resultado é idêntico, pedra por pedra. É exatamente o que a aula sobre game loop chamou de determinismo, e é como muitos jogos implementam replays e como jogos de estratégia sincronizam partidas em rede. O cuidado é que nada na lógica pode usar System.nanoTime(), MathUtils.random() (que tem outra semente) ou a ordem de um HashMap.