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:
- 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. - O mundo. Desenhado com a câmera do
FitViewport, em metros. Olote.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âmetrossrcX, 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 comsetColor, um truque comum para desenhar retângulos coloridos no mesmo lote dos sprites. - 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
- libGDX — site oficial e documentação: ciclo de vida, gráficos, entrada, áudio e o guia de início.
- libGDX Wiki — The Life-Cycle: a ordem exata de
create,resize,render,pause,resumeedispose. - libGDX Wiki — Viewports:
FitViewport,ExtendViewport,ScreenViewporte quando usar cada um. - libGDX Wiki — SpriteBatch, TextureRegions and Sprites: como o lote funciona e por que atlas importam.
- gdx-liftoff: o gerador oficial de projetos.
- libGDX no GitHub: o código-fonte e o
CHANGEScom as novidades de cada versão.
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.