O Primeiro Sprite — Janela, Desenho e Input com libGDX

O Primeiro Sprite — Janela, Desenho e Input com libGDX

Nesta aula você monta o primeiro projeto com libGDX: uma janela acelerada por GPU, texturas desenhadas com SpriteBatch, um mundo medido em metros em vez de pixels, e leitura de teclado e mouse. O resultado é a primeira cena da Travessia, com o game loop de passo fixo da aula anterior funcionando por dentro.
Java

• • 20 min de leitura

Nesta aula você monta o primeiro projeto com libGDX: uma janela acelerada por GPU, texturas desenhadas com SpriteBatch, um mundo medido em metros em vez de pixels, e leitura de teclado e mouse. O resultado é a primeira cena da Travessia, com o game loop de passo fixo da aula anterior funcionando por dentro.

Em "Um Novo Território — Java nos Jogos, o Game Loop e o Caso Minecraft" escrevemos um game loop inteiro à mão, com Java 2D: a janela, a estratégia de buffers, o acumulador, a interpolação. Funcionou, mas o Java 2D desenha pela CPU, não tem som, não sabe o que é um controle de videogame e não roda num celular. Um jogo de verdade precisa de tudo isso, e é para isso que existem os frameworks. Nesta trilha usaremos o libGDX.

O que o libGDX faz por você

O libGDX nasceu em 2009, criado por Mario Zechner, para fazer jogos para os primeiros celulares Android sem perder a produtividade de desenvolver no desktop. A ideia central continua a mesma: o jogo é escrito uma vez, num módulo core, e cada plataforma tem um "lançador" (backend) fino. As plataformas são:

  • desktop (via LWJGL 3, com OpenGL);
  • Android;
  • iOS (compilado de forma antecipada);
  • web (via GWT).

O que o libGDX assume por você:

Responsabilidade No Java 2D da aula anterior No libGDX
Janela e contexto gráfico JFrame + Canvas + BufferStrategy o backend cria tudo
Desenho CPU, Graphics2D GPU, OpenGL, com lotes de sprites
Laço principal escrito à mão o backend chama render() a cada quadro, com vsync
Entrada eventos AWT teclado, mouse, toque, controles, por consulta ou por evento
Áudio, arquivos, preferências não havia módulos Gdx.audio, Gdx.files, Gdx.app.getPreferences
Matemática de jogo escrita à mão vetores, matrizes, interseções, interpolação, números aleatórios

O libGDX não é um motor com editor visual, como Unity ou Godot. É uma biblioteca: você escreve código Java, e o jogo é o seu programa. Isso tem um custo, porque não há arrastar e soltar, e uma vantagem, que combina com este curso: não há mágica. Tudo o que acontece está no seu código ou no código aberto da biblioteca.

Criando o projeto

O gerador oficial de projetos é o gdx-liftoff, um programa Java que monta a estrutura com Gradle. Escolha o nome do projeto e o pacote (br.com.exemplo.travessia), marque só a plataforma Desktop (LWJGL3) por enquanto e gere. A estrutura resultante:

travessia/
├── core/                      ← o jogo: tudo que não depende de plataforma
│   └── src/main/java/br/com/exemplo/travessia/
├── lwjgl3/                    ← o lançador de desktop
│   └── src/main/java/br/com/exemplo/travessia/lwjgl3/Lwjgl3Launcher.java
├── assets/                    ← imagens, sons, fontes, mapas
├── build.gradle
└── gradle.properties          ← gdxVersion=1.14.2

As dependências essenciais, que o gerador já escreve:

// core/build.gradle
dependencies {
    api "com.badlogicgames.gdx:gdx:$gdxVersion"
}

// lwjgl3/build.gradle
dependencies {
    implementation project(':core')
    implementation "com.badlogicgames.gdx:gdx-backend-lwjgl3:$gdxVersion"
    implementation "com.badlogicgames.gdx:gdx-platform:$gdxVersion:natives-desktop"
}

O artefato natives-desktop traz as bibliotecas nativas (.so, .dll, .dylib) que o libGDX usa para operações pesadas: decodificar imagens, copiar buffers para a GPU, multiplicar matrizes. É a mesma ponte entre Java e código nativo de "Ponte para o Nativo — a Foreign Function & Memory API", só que feita pelo JNI clássico. ./gradlew lwjgl3:run compila e abre o jogo.

Uma decisão de versão: se você pretende levar o jogo ao Android ou ao iOS, mantenha o módulo core numa versão de Java que essas plataformas aceitam (17 é uma escolha segura, como vimos em "Extensão — Java no Android: o que Mudou com o Kotlin e um App que Consome a API"). Para um jogo só de desktop, o core pode usar o Java 25 sem restrições.

O lançador e o ciclo de vida

O lançador de desktop é curto: configura a janela e entrega o jogo ao backend.

package br.com.exemplo.travessia.lwjgl3;

import br.com.exemplo.travessia.Travessia;
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application;
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration;

public class Lwjgl3Launcher {
    public static void main(String[] args) {
        var config = new Lwjgl3ApplicationConfiguration();
        config.setTitle("Travessia");
        config.setWindowedMode(960, 540);
        config.useVsync(true);                         // um quadro por atualização do monitor
        // limite de segurança caso a vsync esteja desligada no driver: taxa do monitor + 1
        config.setForegroundFPS(Lwjgl3ApplicationConfiguration.getDisplayMode().refreshRate + 1);
        new Lwjgl3Application(new Travessia(), config);
    }
}

Repare no useVsync(true). É a resposta ao primeiro exercício da aula anterior, quando nosso loop desenhava 2690 quadros por segundo para um monitor que mostra 60. Com a vsync, a troca de buffers espera o monitor, e a CPU descansa.

O jogo em si implementa ApplicationListener, normalmente estendendo ApplicationAdapter, que já traz métodos vazios. O backend chama os métodos nesta ordem:

Método Quando é chamado Para quê
create() uma vez, com o contexto OpenGL pronto carregar texturas, criar objetos
resize(w, h) ao abrir e a cada redimensionamento ajustar a câmera e os viewports
render() a cada quadro (60 vezes por segundo, com vsync a 60 Hz) atualizar e desenhar
pause() / resume() ao perder e recuperar o foco (no celular: ao ir para segundo plano) pausar o jogo, salvar
dispose() ao fechar liberar recursos da GPU e nativos

Não há update() separado: o libGDX chama só render(), com um Gdx.graphics.getDeltaTime() variável. O passo fixo da aula anterior continua sendo responsabilidade sua, e vamos escrevê-lo dentro do render().

Coordenadas: metros, não pixels

O OpenGL tem a origem no canto inferior esquerdo, com o y crescendo para cima, ao contrário do Java 2D e do Swing. E o libGDX não impõe unidade nenhuma. A decisão mais importante de um jogo 2D é esta: pense em unidades do mundo, não em pixels da tela.

Nosso mundo mede 16 × 9 metros. Uma janela de 960 × 540 mostra 60 pixels por metro; uma tela 4K mostraria 240. Mas a lógica não muda: o barco anda 4 m/s em qualquer tela. Quem faz a conversão são duas peças:

  • a câmera ortográfica (OrthographicCamera), que decide que retângulo do mundo está sendo visto;
  • o viewport, que decide como esse retângulo ocupa a janela.

O FitViewport(16, 9) mostra sempre o mundo inteiro, mantendo a proporção. Se a janela tiver outra proporção, sobram faixas pretas. Outras escolhas comuns são o ExtendViewport, que mostra mais mundo em vez das faixas, e o ScreenViewport, que usa um pixel por unidade e é ótimo para textos e HUD.

A conversão inversa, do pixel clicado para o metro do mundo, é o viewport.unproject(vetor). Ele desconta as faixas, a escala e o y invertido das coordenadas do mouse, que vêm com a origem no topo.

Texturas e o SpriteBatch

Uma Texture é uma imagem que mora na memória da placa de vídeo. Um Pixmap é uma imagem na memória nativa comum, que você pode desenhar pixel a pixel. Num jogo real, as texturas vêm de arquivos PNG na pasta assets (new Texture("barco.png")). Aqui, para o exemplo ser autocontido, desenhamos com Pixmap e enviamos à GPU:

package br.com.exemplo.travessia;

import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;

/** Texturas desenhadas por código, para o exemplo não depender de arquivos de imagem. */
final class Texturas {
    private Texturas() {}

    static Texture barco() {
        var p = new Pixmap(64, 64, Pixmap.Format.RGBA8888);
        p.setColor(Color.valueOf("8b5a2b"));                  // casco
        p.fillTriangle(4, 44, 60, 44, 50, 56);
        p.fillTriangle(4, 44, 14, 56, 50, 56);
        p.setColor(Color.WHITE);                              // vela (Pixmap: y cresce para BAIXO)
        p.fillTriangle(30, 6, 30, 42, 54, 42);
        p.setColor(Color.DARK_GRAY);
        p.fillRectangle(29, 6, 2, 38);                        // mastro
        return criar(p);
    }

    static Texture ilha() {
        var p = new Pixmap(128, 64, Pixmap.Format.RGBA8888);
        p.setColor(Color.valueOf("e9d8a6"));
        p.fillCircle(64, 64, 62);                             // areia (meio círculo visível)
        p.setColor(Color.valueOf("2e7d4f"));
        p.fillCircle(64, 64, 40);                             // mata
        return criar(p);
    }

    static Texture pixelBranco() {
        var p = new Pixmap(1, 1, Pixmap.Format.RGBA8888);
        p.setColor(Color.WHITE);
        p.fill();
        return criar(p);
    }

    private static Texture criar(Pixmap p) {
        var t = new Texture(p);                               // copia os pixels para a GPU...
        t.setFilter(Texture.TextureFilter.Linear, Texture.TextureFilter.Linear);
        p.dispose();                                          // ...e a cópia na memória nativa pode ir embora
        return t;
    }
}

Repare no comentário da vela. O Pixmap tem o y para baixo, como uma imagem, enquanto o mundo tem o y para cima. São dois sistemas de coordenadas, cada um no seu lugar.

E repare no dispose(). Texturas, Pixmaps, fontes e o próprio SpriteBatch ocupam memória fora do heap do Java, seja na GPU ou em memória nativa. O coletor de lixo não sabe que ela existe, e esquecer o dispose é um vazamento que só aparece depois de horas de jogo. É a disciplina do close() de "Recursos que se Fecham Sozinhos — try-with-resources, Arquivos e NIO.2", só que sem o try: o dono do recurso libera no fim da vida dele.

O SpriteBatch é a peça que torna o desenho rápido. Mandar um retângulo por vez para a GPU seria lento, porque cada ordem de desenho (draw call) tem um custo fixo alto. O SpriteBatch acumula os retângulos texturizados entre begin() e end() e manda todos de uma vez. O lote é enviado mais cedo em dois casos: quando a textura muda ou quando o lote enche. Por isso jogos reais juntam as imagens numa única textura grande, o atlas, e desenham centenas de sprites com um só envio.

O barco: a lógica sem desenho

Como em "Extensão — Um App Desktop que Consome a API (MVVM)", a regra fica separada da apresentação. O barco não sabe que existe uma GPU:

package br.com.exemplo.travessia;

import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Vector2;

/** O estado do barco, em unidades do MUNDO (1 unidade = 1 metro), sem nada de desenho. */
final class Barco {
    static final float LARGURA = 1.6f, ALTURA = 1.6f;
    static final float VELOCIDADE_MAXIMA = 4f;               // m/s
    static final float ACELERACAO = 10f;                     // m/s²
    static final float ARRASTO = 3f;                         // quanto a água "segura" o barco

    final Vector2 posicao = new Vector2();
    final Vector2 anterior = new Vector2();                  // para a interpolação
    final Vector2 velocidade = new Vector2();
    final Vector2 destino = new Vector2();
    boolean temDestino;

    /** direcaoX e direcaoY em -1..1, vindos do teclado; o destino, do clique. */
    void atualizar(float dt, float direcaoX, float direcaoY, float largMundo, float altMundo) {
        anterior.set(posicao);
        float ax = direcaoX, ay = direcaoY;
        if (temDestino && ax == 0 && ay == 0) {               // sem teclas: navega até o ponto clicado
            float dx = destino.x - posicao.x, dy = destino.y - posicao.y;
            float dist = (float) Math.sqrt(dx * dx + dy * dy);
            if (dist < 0.1f) temDestino = false; else { ax = dx / dist; ay = dy / dist; }
        }
        velocidade.add(ax * ACELERACAO * dt, ay * ACELERACAO * dt);
        velocidade.scl(1f / (1f + ARRASTO * dt));             // arrasto: perde velocidade sem motor
        velocidade.limit(VELOCIDADE_MAXIMA);
        posicao.mulAdd(velocidade, dt);
        // o mar tem margens: o barco não sai do mundo
        posicao.x = MathUtils.clamp(posicao.x, 1.5f, largMundo - 1.5f - LARGURA);
        posicao.y = MathUtils.clamp(posicao.y, 0.2f, altMundo - ALTURA - 0.2f);
    }
}

Vector2 é o vetor 2D mutável do libGDX. Métodos como add, scl (escala), limit e mulAdd (soma um vetor multiplicado por um número) alteram o próprio vetor e o devolvem. Isso é o oposto da imutabilidade que praticamos o curso inteiro, e é deliberado. Um jogo roda o passo 60 vezes por segundo, e criar vetores novos a cada operação geraria milhares de objetos por segundo para o coletor recolher. Voltaremos a isso em "Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos".

O jogo

package br.com.exemplo.travessia;

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.Texture;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
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 primeira cena: um barco entre duas ilhas, guiado pelo teclado ou por cliques. */
public class Travessia extends ApplicationAdapter {
    static final float LARGURA = 16f, ALTURA = 9f;           // o mundo mede 16 x 9 metros
    static final float PASSO = 1f / 60f;

    private SpriteBatch lote;
    private OrthographicCamera camera;
    private FitViewport vistaDoMundo;
    private ScreenViewport vistaDoHud;
    private BitmapFont fonte;
    private Texture texBarco, texIlha, texPixel;
    private final Barco barco = new Barco();
    private final Vector2 toque = new Vector2();
    private float acumulado, tempo;
    private int fotos;

    @Override
    public void create() {
        lote = new SpriteBatch();
        camera = new OrthographicCamera();
        vistaDoMundo = new FitViewport(LARGURA, ALTURA, camera);   // o mundo sempre inteiro, com faixas se preciso
        vistaDoHud = new ScreenViewport();                         // o texto em pixels de tela
        fonte = new BitmapFont();                                  // a fonte padrão que vem no libGDX
        texBarco = Texturas.barco();
        texIlha = Texturas.ilha();
        texPixel = Texturas.pixelBranco();
        barco.posicao.set(2f, ALTURA / 2 - Barco.ALTURA / 2);
        barco.anterior.set(barco.posicao);

        // Eventos: coisas que acontecem UMA vez (um clique, uma tecla apertada)
        Gdx.input.setInputProcessor(new InputAdapter() {
            @Override
            public boolean touchDown(int x, int y, int ponteiro, int botao) {
                vistaDoMundo.unproject(toque.set(x, y));             // pixels da tela → metros do mundo
                barco.destino.set(toque.x - Barco.LARGURA / 2, toque.y - Barco.ALTURA / 2);
                barco.temDestino = true;
                return true;
            }

            @Override
            public boolean keyDown(int tecla) {
                if (tecla == Keys.F12) { fotografar(); return true; }
                if (tecla == Keys.ESCAPE) { Gdx.app.exit(); return true; }
                return false;
            }
        });
    }

    @Override
    public void resize(int largura, int altura) {
        vistaDoMundo.update(largura, altura, true);
        vistaDoHud.update(largura, altura, true);
    }

    @Override
    public void render() {
        // 1. Lógica em passo fixo (o render do libGDX é chamado com passo VARIÁVEL)
        float dt = Math.min(Gdx.graphics.getDeltaTime(), 0.25f);
        acumulado += dt;
        tempo += dt;
        while (acumulado >= PASSO) {
            barco.atualizar(PASSO, eixo(Keys.LEFT, Keys.A, Keys.RIGHT, Keys.D),
                    eixo(Keys.DOWN, Keys.S, Keys.UP, Keys.W), LARGURA, ALTURA);
            acumulado -= PASSO;
        }
        float alfa = acumulado / PASSO;
        float x = MathUtils.lerp(barco.anterior.x, barco.posicao.x, alfa);
        float y = MathUtils.lerp(barco.anterior.y, barco.posicao.y, alfa)
                + MathUtils.sin(tempo * 2.5f) * 0.05f;               // balanço das ondas (só visual)

        // 2. Desenho do mundo, em metros
        ScreenUtils.clear(Color.BLACK);                              // as faixas do FitViewport ficam pretas
        vistaDoMundo.apply();
        lote.setProjectionMatrix(camera.combined);
        lote.begin();
        lote.setColor(Color.valueOf("1e6fa8"));
        lote.draw(texPixel, 0, 0, LARGURA, ALTURA);                  // o mar
        lote.setColor(Color.WHITE);
        lote.draw(texIlha, -2f, ALTURA / 2 - 1f, 4f, 2f);                   // 4 x 2 m: a mesma proporção 2:1 da textura
        lote.draw(texIlha, LARGURA - 2f, ALTURA / 2 - 1f, 4f, 2f);
        boolean indoParaEsquerda = barco.velocidade.x < -0.05f;
        lote.draw(texBarco, x, y, Barco.LARGURA, Barco.ALTURA, 0, 0, 64, 64, indoParaEsquerda, false);
        lote.end();

        // 3. HUD por cima, em pixels
        vistaDoHud.apply();
        lote.setProjectionMatrix(vistaDoHud.getCamera().combined);
        lote.begin();
        fonte.draw(lote, "Setas/WASD ou clique para navegar  |  %d FPS  |  %.1f m/s"
                .formatted(Gdx.graphics.getFramesPerSecond(), barco.velocidade.len()), 10, vistaDoHud.getScreenHeight() - 10);
        lote.end();
    }

    /** Teclado por consulta (polling): "a tecla ESTÁ apertada agora?", perfeito para movimento contínuo. */
    private float eixo(int menos1, int menos2, int mais1, int mais2) {
        float v = 0;
        if (Gdx.input.isKeyPressed(menos1) || Gdx.input.isKeyPressed(menos2)) v -= 1;
        if (Gdx.input.isKeyPressed(mais1) || Gdx.input.isKeyPressed(mais2)) v += 1;
        return v;
    }

    private void fotografar() {
        Pixmap tela = Pixmap.createFromFrameBuffer(0, 0, Gdx.graphics.getBackBufferWidth(), Gdx.graphics.getBackBufferHeight());
        var arquivo = Gdx.files.local("travessia-" + (++fotos) + ".png");
        PixmapIO.writePNG(arquivo, tela, -1, true);                  // true: inverte, pois o OpenGL lê de baixo para cima
        tela.dispose();
        Gdx.app.log("Travessia", "foto salva em " + arquivo.path() + "; barco em " + barco.posicao);
    }

    @Override
    public void dispose() {                                          // memória de GPU: não é o coletor que libera
        lote.dispose();
        fonte.dispose();
        texBarco.dispose();
        texIlha.dispose();
        texPixel.dispose();
    }
}

O render() tem três partes, e a ordem importa:

  1. Lógica em passo fixo. É o acumulador de "Um Novo Território — Java nos Jogos, o Game Loop e o Caso Minecraft", agora dentro do render(). O teclado é lido a cada passo e entregue ao barco como números de -1 a 1.
  2. O mundo. Desenhado com a câmera do FitViewport, em metros. O lote.draw(textura, x, y, largura, altura) diz onde e de que tamanho no mundo, e a textura é esticada para caber. A versão longa, com os parâmetros srcX, srcY, srcW, srcH, flipX, flipY, espelha o barco na horizontal quando ele anda para a esquerda. O mar é um único pixel branco esticado sobre 16 × 9 metros e tingido de azul com setColor, um truque comum para desenhar retângulos coloridos no mesmo lote dos sprites.
  3. O HUD. Outro viewport, em pixels. O mesmo SpriteBatch é reaproveitado com outra matriz de projeção: o texto fica nítido e do mesmo tamanho em qualquer janela, enquanto o mundo escala.

Entrada: consultar ou ser avisado

O libGDX oferece as duas formas de ler a entrada, e o jogo usa as duas, cada uma para o que ela faz bem:

Consulta (polling) Eventos (InputProcessor)
Pergunta "a tecla está apertada agora?" "a tecla foi apertada"
Uso movimento contínuo: acelerar, girar, andar ações únicas: pular, atirar uma vez, abrir menu, clicar
Código Gdx.input.isKeyPressed(Keys.LEFT) keyDown, keyUp, touchDown num InputAdapter
Risco perder um toque rápido entre dois quadros tratar a mesma ação duas vezes se também consultar

Um pulo por consulta dispararia em todos os quadros em que a tecla está apertada, e um toque rapidíssimo, entre dois quadros, poderia nem ser visto. Por evento, cada toque vira exatamente um pulo. Já o movimento por eventos exigiria guardar o estado de cada tecla à mão, e é exatamente isso que o isKeyPressed já faz.

Rodando

O jogo foi conferido com o libGDX 1.14.2 no JDK 27, numa janela de verdade no Linux. Para não depender de um jogador, um lançador de teste chamou o próprio InputAdapter do jogo, como o backend faria: um clique perto do canto superior direito e, quatro segundos depois, F12:

[Travessia] foto salva em travessia-1.png; barco em (12.9,7.054512)

A captura mostrou o mar azul, as duas ilhas nas bordas, com a areia e a mata, e o barco a vela no alto, à direita. O x = 12,9 é a margem do clamp (16 − 1,5 − 1,6), e o HUD dizia "61 FPS | 3.3 m/s" com o barco parado: o destino ficou além da margem e nunca é alcançado, e o clamp segura a posição, mas não zera a velocidade. O teclado foi conferido na lógica pura, chamando atualizar passo a passo: 90 passos (1,5 s) de seta para a direita levam o barco de x = 2 a 5,90, a 3,29 m/s, e, solta a tecla, ele segue deslizando, freado pelo arrasto.

Repare também nas ilhas: a textura tem 128 × 64 pixels e é desenhada em 4 × 2 metros. Num retângulo de 4 × 4 metros, elas sairiam esticadas na vertical: a proporção do que se desenha no mundo deve bater com a da imagem, ou tudo sai deformado.

A cena tem um barco e nenhum obstáculo, e um jogo precisa de coisas que se tocam: tiros que acertam, naves que batem, jogadores que pisam em plataformas. Tudo isso se apoia no que esta aula montou: o mundo em metros, a lógica separada do desenho e o passo fixo dentro do render().

Fontes e leituras recomendadas

Exercícios

Exercício 1

Com direita e cima apertadas juntas, o barco acelera mais rápido do que com uma seta só. Por quê? Corrija.

Ver resposta

✓ Resposta: O vetor de direção (1, 1) tem comprimento √2 ≈ 1,41, então a aceleração na diagonal é 41% maior. A velocidade máxima é a mesma, porque o limit corta, mas o barco chega nela mais depressa. A correção é normalizar a direção quando ela é maior que 1: no atualizar, depois de calcular ax e ay, faça float n = (float) Math.sqrt(ax * ax + ay * ay); if (n > 1) { ax /= n; ay /= n; }. Com controles analógicos, que dão valores intermediários, essa normalização preserva a intensidade de um direcional parcial e só corrige o excesso.

Exercício 2

Troque o FitViewport por um ExtendViewport(16, 9) e redimensione a janela para um formato bem largo. O que muda na tela, e que cuidado a lógica do jogo passa a exigir?

Ver resposta

✓ Resposta: O ExtendViewport garante que pelo menos 16 × 9 metros fiquem visíveis, mas, em vez de faixas pretas, mostra mais mundo na direção que sobra: numa janela larga, aparecem metros além de x = 16 à direita. O mar desenhado com 16 × 9 fica curto, e aparece o fundo preto. A lógica passa a precisar saber o tamanho real visível (vista.getWorldWidth()), por exemplo para desenhar o mar até a borda, e o design precisa aceitar que jogadores com telas diferentes veem porções diferentes do mundo. Em jogos competitivos isso é uma vantagem injusta, e muitos preferem o FitViewport justamente por isso.

Exercício 3

Um colega remove o dispose() da Travessia "porque o Java tem coletor de lixo". O que acontece ao fechar o jogo? E se o jogo recriasse as texturas a cada troca de fase, sem dispose?

Ver resposta

✓ Resposta: Ao fechar, o processo inteiro termina, e o sistema operacional recupera a memória. Nada visível acontece, e é isso que engana. Mas as texturas vivem na memória da GPU e na memória nativa, que o coletor não enxerga: um objeto Texture sem referência pode ser coletado, mas a textura na GPU fica lá. Recriando texturas a cada fase sem dispose, a memória de vídeo cresce fase após fase até o driver falhar ou o jogo ficar lento. A regra é que todo objeto que implementa Disposable tem um dono, e o dono chama dispose() quando o recurso deixa de ser usado.

Exercício 4

O clique leva o barco ao ponto clicado. Explique por que o código usa vistaDoMundo.unproject(toque.set(x, y)) em vez de simplesmente dividir x e y por 60 (os pixels por metro de uma janela de 960 × 540).

Ver resposta

✓ Resposta: Porque a conversão real depende de três coisas que a divisão ignora. Primeiro, o y do mouse cresce para baixo, a partir do topo, e o do mundo para cima: é preciso inverter. Segundo, a escala muda com o tamanho da janela: a 1920 × 1080 são 120 pixels por metro. Terceiro, o FitViewport pode ter faixas pretas, e o mundo começa deslocado. O unproject aplica a inversa exata da transformação usada no desenho (câmera, escala e deslocamento do viewport), e o clique cai no ponto certo em qualquer janela e em qualquer proporção.

Exercício 5

Transforme o "pulo" num evento: ao apertar a barra de espaço, o barco dá um impulso único de 3 m/s na direção em que está indo. Por que isso não deve ser feito com isKeyPressed dentro do passo?

Ver resposta

✓ Resposta: No keyDown do InputAdapter, acrescente if (tecla == Keys.SPACE) { impulsoPendente = true; return true; }. No laço do passo fixo, se impulsoPendente, aplique barco.velocidade.add(barco.velocidade.cpy().nor().scl(3f)) (ou a direção padrão, se estiver parado) e zere a flag. Com isKeyPressed, o impulso seria aplicado em todos os passos em que a tecla está apertada: um toque de 0,2 s daria cerca de 12 impulsos. A flag transporta o evento, que acontece uma vez, para dentro do passo fixo, que é onde as regras rodam. Aplicar direto no keyDown também funcionaria, mas misturaria entrada e lógica fora do passo.

Comentários

Mais em Java

O Cadeado e a Memória — synchronized, volatile e o Java Memory Model
O Cadeado e a Memória — synchronized, volatile e o Java Memory Model

Como coordenar threads com synchronized e volatile: a trava que torna o…

Moldes e Instâncias — Classes, Objetos e Construtores
Moldes e Instâncias — Classes, Objetos e Construtores

Classes e objetos em Java: campos, new e referências, o heap, null e a…

Um Novo Território — Java nos Jogos, o Game Loop e o Caso Minecraft
Um Novo Território — Java nos Jogos, o Game Loop e o Caso Minecraft

Nesta aula você conhece a história do Java nos jogos, do applet de navegador…