Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids

Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids

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.
Java

• • 25 min de leitura

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, e soma * soma també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, com it.remove(). Um lista.remove(x) dentro de um for-each lançaria ConcurrentModificationException, 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 Random com 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

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.

Comentários

Mais em Java

Listas, Conjuntos e Mapas — o Collections Framework
Listas, Conjuntos e Mapas — o Collections Framework

O Collections Framework do Java: interfaces e implementações, ArrayList e a…

Muitos Valores sob um Nome — Arrays
Muitos Valores sob um Nome — Arrays

Arrays em Java: criação, índices, valores padrão, limites vigiados pela JVM…

Dublês e Disciplina — Mockito e Testes que Não Mentem
Dublês e Disciplina — Mockito e Testes que Não Mentem

Como testar código que depende de banco, e-mail e relógio com dublês de teste…