Nesta aula o plataforma vira um jogo completo, A Ilha dos Blocos: menu, duas fases, animação por quadros, partículas recicladas, pausa, recordes salvos em disco e placar. No fim, o jogo é empacotado com seu próprio Java, num programa que roda em outro computador sem instalação nenhuma.
Toda trilha deste curso termina num projeto que junta tudo. A trilha principal terminou no servidor HTTP "Farol", a Trilha A no site das "Trilhas da Ilha", e a Trilha B num app de desktop consumindo a API. Nesta aula juntamos as peças da Trilha C:
- o game loop de passo fixo de "Um Novo Território — Java nos Jogos, o Game Loop e o Caso Minecraft";
- os sprites e o viewport de "O Primeiro Sprite — Janela, Desenho e Input com libGDX";
- a disciplina de colisão de "Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids";
- os mapas e a física de "Mundos em Blocos — Mapas de Tiles e um Jogo de Plataforma";
- os Pools de "Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos".
O que falta para isso virar um jogo é quase tudo o que não é jogar: menu, transições, progresso e a distribuição.
A estrutura do projeto
ilha-dos-blocos/
├── assets/ fase1.tmx, fase2.tmx, terreno.png (gerados pelo GerarFases)
├── core/src/main/java/br/com/exemplo/plataforma/
│ ├── Grade.java, Comandos.java, Heroi.java, Fase.java ← regras (da aula anterior, com eventos)
│ ├── Mapas.java, GeradorDeMapa.java, FaseDoTiled.java ← fases
│ ├── Progresso.java ← recordes (Java puro)
│ ├── Poeira.java ← partículas com Pool
│ ├── Recursos.java ← texturas, fontes, mapas, animação
│ ├── JogoDaIlha.java ← o Game: troca de telas
│ └── TelaDeMenu.java, TelaDeJogo.java, TelaDeVitoria.java ← as Screens
├── core/src/test/java/... HeroiTest.java, CapstoneTest.java
└── lwjgl3/src/main/java/... Lwjgl3Launcher.java
A separação em camadas é a mesma das outras trilhas. As regras (Heroi, Fase, Progresso) não conhecem GPU, arquivos nem teclado, e por isso são testadas em JUnit puro. As telas conhecem tudo isso e quase nenhuma regra.
As fases como fonte única
As fases saem de um único lugar. Os testes usam o texto direto, e uma ferramenta gera os .tmx a partir dele:
package br.com.exemplo.plataforma;
/** Os "desenhos" das fases. O GeradorDeMapa transforma cada um num .tmx; os testes usam direto. */
public final class Mapas {
private Mapas() { }
public static final String[] FASE1 = {
"......................................................",
"......................................................",
"......................................................",
"......................................................",
"....................o.o.o.............................",
"...................#######..............o.o...........",
"...........o...........................#####.......B..",
"..........###.......................................##",
".H....................o.......o..........o.........###",
"######....#####...#######..######...#######..#########",
"######....#####...#######..######...#######..#########",
"######....#####...#######..######...#######..#########"};
public static final String[] FASE2 = {
"............................................................",
"............................................................",
"............................................................",
"................................o.o.........................",
"...............................#####........................",
"...............o.o...........................o..............",
"..............#####.........................###.........B...",
"..........................#.............................##..",
".H.......o..........o....##.........o.....o.......o....###..",
"#########...##########..###...###########...#####...########",
"#########...##########..###...###########...#####...########",
"#########...##########..###...###########...#####...########"};
public static final String[][] TODAS = {FASE1, FASE2};
}
import br.com.exemplo.plataforma.*;
import com.badlogic.gdx.utils.GdxNativesLoader;
import java.nio.file.Path;
/** Gera assets/fase1.tmx, assets/fase2.tmx (e o tileset) a partir dos desenhos em Mapas. */
public class GerarFases {
public static void main(String[] a) throws Exception {
GdxNativesLoader.load(); // Pixmap usa código nativo
for (int n = 0; n < Mapas.TODAS.length; n++) GeradorDeMapa.gerar(Path.of(a[0]), "fase" + (n + 1), Mapas.TODAS[n]);
}
}
A fase 2 introduz uma escada de blocos (um degrau de 1 e outro de 2 tiles, dentro da altura discreta de 2,33 m) e um trecho mais longo de plataformas. Antes de desenhar qualquer coisa, o piloto da aula anterior a venceu em 8,4 s, sem quedas. Se a fase for pensada para o Tiled, você desenha lá e apaga o Mapas. A regra "o teste do piloto precisa passar" continua valendo, só que lendo o .tmx.
A regra avisa, a tela decide o efeito
Partículas e sons precisam saber quando o herói pulou, pousou ou pegou uma moeda. A tentação é pôr poeira.emitir(...) dentro do Heroi, e isso amarraria a regra ao desenho. A saída é o padrão observador, que vimos na trilha principal: a Fase avisa, e quem quiser escuta. O Heroi ganha três campos e quatro linhas:
// Heroi.java — campos novos
public boolean pulouNestePasso, aterrissouNestePasso; // "eventos" para efeitos visuais e sons
public boolean olhandoParaDireita = true;
// no começo de atualizar():
anterior.set(posicao);
pulouNestePasso = aterrissouNestePasso = false;
if (cmd.direcao() != 0) olhandoParaDireita = cmd.direcao() > 0;
// dentro do bloco do pulo, depois de subindoPorPulo = true:
pulouNestePasso = true;
// depois do moverY, antes de atualizar desdeOChao:
if (noChao && !estavaNoChao) aterrissouNestePasso = true;
A Fase ganha uma interface de eventos, com métodos default para que cada ouvinte implemente só o que precisa, e um cronômetro:
// Fase.java — o que mudou em relação à aula anterior
public enum Estado { JOGANDO, VENCEU }
/** Quem quiser reagir ao que acontece (partículas, sons, placar) implementa só o que precisa. */
public interface Eventos {
default void pulou(float x, float y) { }
default void aterrissou(float x, float y) { }
default void pegouMoeda(float x, float y) { }
default void caiu() { }
default void venceu() { }
Eventos NENHUM = new Eventos() { };
}
public float tempo; // segundos de jogo (só conta enquanto JOGANDO)
public Eventos eventos = Eventos.NENHUM;
public void atualizar(float dt, Comandos cmd) {
if (estado != Estado.JOGANDO) return;
tempo += dt;
heroi.atualizar(dt, cmd, grade);
float pesX = heroi.posicao.x + Heroi.LARGURA / 2, pesY = heroi.posicao.y;
if (heroi.pulouNestePasso) eventos.pulou(pesX, pesY);
if (heroi.aterrissouNestePasso) eventos.aterrissou(pesX, pesY);
caixaDoHeroi.set(heroi.posicao.x, heroi.posicao.y, Heroi.LARGURA, Heroi.ALTURA);
for (int i = moedas.size() - 1; i >= 0; i--) { // de trás para frente: remover não pula ninguém
Rectangle m = moedas.get(i);
if (m.overlaps(caixaDoHeroi)) {
moedas.remove(i);
moedasPegas++;
eventos.pegouMoeda(m.x + m.width / 2, m.y + m.height / 2);
}
}
if (bandeira.overlaps(caixaDoHeroi)) {
estado = Estado.VENCEU;
eventos.venceu();
}
if (heroi.posicao.y < -3) { // caiu num buraco: volta ao início
quedas++;
heroi.colocarEm(inicio.x, inicio.y);
eventos.caiu();
}
}
O resto da Fase (campos, construtor e deTexto) é igual ao da aula anterior. O removeIf virou um laço de trás para frente, porque agora precisamos da posição de cada moeda para o evento.
Partículas com Pool
A Poeira aplica a aula anterior ao jogo: um Pool de grãos, um Array sem ordem e laços por índice.
package br.com.exemplo.plataforma;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.utils.Array;
import com.badlogic.gdx.utils.Pool;
/** Partículas pequenas (poeira do pulo, brilho da moeda) — recicladas por um Pool, sem lixo no laço. */
public final class Poeira {
static final class Grao implements Pool.Poolable {
float x, y, vx, vy, gravidade, vida, vidaTotal, tamanho;
final Color cor = new Color();
@Override public void reset() { vida = 0; }
}
private final Pool<Grao> pool = new Pool<>(64, 2000) {
@Override protected Grao newObject() { return new Grao(); }
};
private final Array<Grao> ativos = new Array<>(false, 256); // false = sem ordem: remover é O(1)
public void emitir(float x, float y, int quantidade, Color cor, float velocidade, float gravidade) {
for (int i = 0; i < quantidade; i++) {
Grao g = pool.obtain();
float angulo = MathUtils.random(0f, MathUtils.PI2);
float v = velocidade * MathUtils.random(0.4f, 1f);
g.x = x; g.y = y;
g.vx = MathUtils.cos(angulo) * v;
g.vy = Math.abs(MathUtils.sin(angulo)) * v; // para cima: fica bonito no chão
g.vidaTotal = g.vida = MathUtils.random(0.25f, 0.5f);
g.tamanho = MathUtils.random(0.08f, 0.16f);
g.gravidade = gravidade;
g.cor.set(cor);
ativos.add(g);
}
}
public void atualizar(float dt) {
for (int i = ativos.size - 1; i >= 0; i--) {
Grao g = ativos.get(i);
g.vida -= dt;
if (g.vida <= 0) {
ativos.removeIndex(i); // troca com o último (Array sem ordem)
pool.free(g); // e só DEPOIS devolve ao Pool
continue;
}
g.vy += g.gravidade * dt;
g.x += g.vx * dt;
g.y += g.vy * dt;
}
}
public void desenhar(SpriteBatch lote, Texture pixel) {
for (int i = 0; i < ativos.size; i++) { // índice: sem iterador, sem surpresa
Grao g = ativos.get(i);
lote.setColor(g.cor.r, g.cor.g, g.cor.b, g.vida / g.vidaTotal); // some aos poucos
lote.draw(pixel, g.x - g.tamanho / 2, g.y - g.tamanho / 2, g.tamanho, g.tamanho);
}
lote.setColor(Color.WHITE);
}
public int ativos() { return ativos.size; }
public void limpar() { pool.freeAll(ativos); ativos.clear(); }
}
A Color é um campo final do grão, preenchida com set(). Guardar a referência da cor recebida seria uma armadilha: se o chamador mudasse aquela Color depois, todos os grãos mudariam junto. Os laços usam índice porque o Array do libGDX reaproveita o próprio iterador, e dois for-each aninhados sobre o mesmo Array lançam exceção.
Recursos compartilhados e o AssetManager
Cada tela criando seu próprio SpriteBatch e carregando suas próprias texturas desperdiça memória de vídeo e tempo de carga. O padrão é um objeto de recursos criado uma vez pelo jogo e emprestado às telas:
package br.com.exemplo.plataforma;
import com.badlogic.gdx.assets.AssetManager;
import com.badlogic.gdx.assets.loaders.resolvers.InternalFileHandleResolver;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.Texture;
import com.badlogic.gdx.graphics.g2d.Animation;
import com.badlogic.gdx.graphics.g2d.BitmapFont;
import com.badlogic.gdx.graphics.g2d.SpriteBatch;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.maps.tiled.TiledMap;
import com.badlogic.gdx.maps.tiled.TmxMapLoader;
import com.badlogic.gdx.utils.Disposable;
/**
* Tudo que é pesado e compartilhado entre as telas: UM SpriteBatch, a fonte, as texturas, os mapas.
* Criado uma vez no create() do jogo e liberado uma vez no dispose() — as telas só pegam emprestado.
*/
public final class Recursos implements Disposable {
public static final String[] MAPAS = {"fase1.tmx", "fase2.tmx"};
public final AssetManager assets = new AssetManager(new InternalFileHandleResolver());
public SpriteBatch lote;
public BitmapFont fonte, fonteGrande;
public Texture pixel, moeda, folhaDoHeroi;
public TextureRegion heroiParado, heroiNoAr;
public Animation<TextureRegion> heroiCorrendo;
public void carregar() {
// 1) arquivos: o AssetManager sabe carregar mapas (e as texturas que eles citam) e contar referências
assets.setLoader(TiledMap.class, new TmxMapLoader(new InternalFileHandleResolver()));
for (String m : MAPAS) assets.load(m, TiledMap.class);
assets.finishLoading(); // num jogo grande: update() a cada quadro + barra de progresso
// 2) coisas geradas em código (no lugar de arquivos de arte)
lote = new SpriteBatch();
fonte = new BitmapFont();
fonteGrande = new BitmapFont();
fonteGrande.getData().setScale(2.5f);
pixel = textura(1, 1, p -> { p.setColor(Color.WHITE); p.fill(); });
moeda = textura(16, 16, p -> {
p.setColor(Color.GOLD); p.fillCircle(8, 8, 7);
p.setColor(Color.valueOf("fff59d")); p.fillCircle(6, 6, 2); // brilho
});
folhaDoHeroi = textura(6 * 14, 18, Recursos::desenharFolhaDoHeroi);
// 3) recorta a folha em quadros e monta a animação
TextureRegion[] quadros = TextureRegion.split(folhaDoHeroi, 14, 18)[0];
heroiParado = quadros[0];
heroiCorrendo = new Animation<>(0.09f, quadros[1], quadros[2], quadros[3], quadros[4]);
heroiCorrendo.setPlayMode(Animation.PlayMode.LOOP);
heroiNoAr = quadros[5];
}
public TiledMap mapa(int n) { return assets.get(MAPAS[n], TiledMap.class); }
/** 6 quadros de 14x18 px: parado, 4 de corrida, no ar. (No Pixmap, y cresce para BAIXO.) */
private static void desenharFolhaDoHeroi(Pixmap p) {
int[][] pernas = {{3, 8}, {1, 10}, {4, 7}, {10, 1}, {7, 4}, {2, 9}}; // x das duas pernas por quadro
for (int q = 0; q < 6; q++) {
int ox = q * 14;
int levanta = q == 5 ? 2 : 0; // no ar, as pernas sobem
p.setColor(Color.valueOf("e53935"));
p.fillRectangle(ox + 1, 1, 12, 12); // corpo
p.setColor(Color.WHITE);
p.fillRectangle(ox + 8, 4, 3, 3); // olho (olhando para a direita)
p.setColor(Color.BLACK);
p.fillRectangle(ox + 9, 5, 2, 2);
p.setColor(Color.valueOf("5d4037"));
p.fillRectangle(ox + pernas[q][0], 13 - levanta, 3, 5 - levanta); // perna 1
p.fillRectangle(ox + pernas[q][1], 13, 3, 5 - levanta); // perna 2
}
}
private static Texture textura(int l, int a, java.util.function.Consumer<Pixmap> pintor) {
Pixmap p = new Pixmap(l, a, Pixmap.Format.RGBA8888);
pintor.accept(p);
Texture t = new Texture(p);
p.dispose(); // a textura já foi para a GPU; o Pixmap pode ir embora
return t;
}
@Override
public void dispose() {
assets.dispose(); // mapas E as texturas dos tilesets
lote.dispose();
fonte.dispose();
fonteGrande.dispose();
pixel.dispose();
moeda.dispose();
folhaDoHeroi.dispose();
}
}
Duas ideias novas:
- O
AssetManagercarrega cada arquivo uma vez, sabe carregar as dependências (o.tmxcita oterreno.png, que é carregado junto) e conta referências. Se as duas fases citam o mesmo tileset, a textura existe uma única vez. Aqui usamosfinishLoading(), que bloqueia. Num jogo com muita arte, a tela de carregamento chamaassets.update()a cada quadro e desenhaassets.getProgress()numa barra. Os arquivos ficam emassets/, e oInternalFileHandleResolveros encontra tanto na pasta do projeto quanto dentro do jar, o que vai importar na exportação. - A animação por quadros. Uma folha de sprites é uma única textura com vários desenhos lado a lado. O
TextureRegion.splita recorta em regiões, e oAnimation<TextureRegion>responde "qual quadro mostrar no instante t" comgetKeyFrame(t). Uma textura só significa uma troca de estado só na GPU, e oSpriteBatchdesenha tudo num lote. Aqui a folha é desenhada em código. Num jogo real, ela vem de um artista, normalmente empacotada com outras num atlas peloTexturePacker.
Game e Screen: um jogo feito de telas
O libGDX tem duas classes para jogos com várias telas. O Game é um ApplicationListener que repassa render, resize, pause e resume para a tela atual. A Screen é cada tela, com o ciclo de vida show → (render/resize/pause/resume)* → hide → dispose.
package br.com.exemplo.plataforma;
import com.badlogic.gdx.Game;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.Screen;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.PixmapIO;
/**
* O jogo inteiro: um Game que troca de Screen (menu → fase → vitória → ...).
* É o "dono" dos recursos e do progresso; as telas recebem o jogo e pedem as trocas.
*/
public class JogoDaIlha extends Game {
public static final String NOME_DAS_PREFERENCIAS = "br.com.exemplo.plataforma";
public final Recursos recursos = new Recursos();
public final Progresso progresso = new Progresso(Recursos.MAPAS.length);
private Preferences preferencias;
private int fotos;
@Override
public void create() {
recursos.carregar();
preferencias = Gdx.app.getPreferences(NOME_DAS_PREFERENCIAS);
progresso.lerDe(preferencias);
Gdx.app.log("Ilha", "progresso lido: %d fase(s) liberada(s), recorde fase 1 = %.2f s".formatted(
progresso.fasesLiberadas(), progresso.melhorTempo(0)));
irParaMenu();
}
public void irParaMenu() { trocarPara(new TelaDeMenu(this)); }
public void jogar(int fase) { trocarPara(new TelaDeJogo(this, fase)); }
public void terminou(int fase, float tempo, int moedas, int total) {
Progresso.Resultado r = progresso.registrarVitoria(fase, tempo, moedas);
progresso.salvarEm(preferencias); // salva na hora: se o jogo fechar agora, nada se perde
trocarPara(new TelaDeVitoria(this, fase, tempo, moedas, total, r));
}
/** O Game NÃO chama dispose() da tela antiga — isso é nosso. */
private void trocarPara(Screen nova) {
Screen velha = getScreen();
setScreen(nova); // chama hide() na velha e show() + resize() na nova
if (velha != null) velha.dispose();
}
@Override
public void render() {
super.render(); // desenha a tela atual
if (Gdx.input.isKeyJustPressed(Keys.F12)) fotografar(); // tecla global, vale em qualquer tela
}
private void fotografar() {
Pixmap tela = Pixmap.createFromFrameBuffer(0, 0, Gdx.graphics.getBackBufferWidth(), Gdx.graphics.getBackBufferHeight());
String nome = "ilha-" + (++fotos) + ".png";
PixmapIO.writePNG(Gdx.files.local(nome), tela, -1, true);
tela.dispose();
Gdx.app.log("Ilha", "foto " + nome + " (" + getScreen().getClass().getSimpleName() + ")");
}
@Override
public void dispose() {
if (getScreen() != null) getScreen().dispose();
recursos.dispose();
}
}
O ponto que mais pega iniciante: setScreen não chama dispose na tela antiga. Ele chama hide. Quem esquece de chamar dispose vaza os recursos de cada tela a cada troca. Por isso toda troca passa por trocarPara.
A tela de jogo
A TelaDeJogo é o Plataforma da aula anterior, com as novidades: animação, partículas ligadas aos eventos, moedas "girando", pausa e o fim da fase.
package br.com.exemplo.plataforma;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.InputAdapter;
import com.badlogic.gdx.ScreenAdapter;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.OrthographicCamera;
import com.badlogic.gdx.graphics.g2d.TextureRegion;
import com.badlogic.gdx.maps.tiled.renderers.OrthogonalTiledMapRenderer;
import com.badlogic.gdx.math.MathUtils;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.utils.ScreenUtils;
import com.badlogic.gdx.utils.viewport.FitViewport;
import com.badlogic.gdx.utils.viewport.ScreenViewport;
/** Uma fase em andamento: o plataforma do artigo anterior, agora com animação, partículas e pausa. */
public class TelaDeJogo extends ScreenAdapter {
static final float PASSO = 1f / 60f;
static final float VISTA_LARG = 20f, VISTA_ALT = 11.25f;
static final float FESTA = 1.2f; // segundos de comemoração antes do placar
private final JogoDaIlha jogo;
private final int numero;
private final Fase fase;
private final OrthographicCamera camera = new OrthographicCamera();
private final FitViewport vista = new FitViewport(VISTA_LARG, VISTA_ALT, camera);
private final ScreenViewport vistaHud = new ScreenViewport();
private final OrthogonalTiledMapRenderer desenhistaDoMapa;
private final Poeira poeira = new Poeira();
private float acumulado, tempoDeAnimacao, festa;
private boolean puloPendente, pausado;
public TelaDeJogo(JogoDaIlha jogo, int numero) {
this.jogo = jogo;
this.numero = numero;
var mapa = jogo.recursos.mapa(numero); // o mapa é do AssetManager: NÃO damos dispose nele
this.fase = FaseDoTiled.ler(mapa); // estado novo a cada partida
this.desenhistaDoMapa = new OrthogonalTiledMapRenderer(mapa, 1 / 16f, jogo.recursos.lote); // lote emprestado
this.fase.eventos = new Fase.Eventos() { // a regra avisa; a tela decide o efeito
@Override public void pulou(float x, float y) { poeira.emitir(x, y, 8, Color.TAN, 3f, -12f); }
@Override public void aterrissou(float x, float y) { poeira.emitir(x, y, 12, Color.TAN, 4f, -12f); }
@Override public void pegouMoeda(float x, float y) { poeira.emitir(x, y, 16, Color.GOLD, 5f, -4f); }
@Override public void venceu() {
var b = fase.bandeira;
poeira.emitir(b.x + b.width / 2, b.y + b.height, 60, Color.WHITE, 7f, -6f);
}
};
camera.position.set(fase.heroi.posicao.x, VISTA_ALT / 2, 0);
}
@Override
public void show() {
Gdx.input.setInputProcessor(new InputAdapter() {
@Override
public boolean keyDown(int tecla) {
switch (tecla) {
case Keys.SPACE, Keys.UP, Keys.W -> puloPendente = !pausado; // evento: vale um passo
case Keys.P -> pausado = !pausado;
case Keys.ESCAPE -> jogo.irParaMenu();
default -> { }
}
return true;
}
});
}
/** O sistema tirou o foco do jogo (minimizou, trocou de app, ligação no celular): pausa sozinho. */
@Override public void pause() { pausado = true; }
@Override
public void render(float delta) {
float dt = Math.min(delta, 0.25f);
if (!pausado) {
acumulado += dt;
tempoDeAnimacao += dt;
float direcao = (Gdx.input.isKeyPressed(Keys.RIGHT) || Gdx.input.isKeyPressed(Keys.D) ? 1 : 0)
- (Gdx.input.isKeyPressed(Keys.LEFT) || Gdx.input.isKeyPressed(Keys.A) ? 1 : 0);
boolean segurando = Gdx.input.isKeyPressed(Keys.SPACE) || Gdx.input.isKeyPressed(Keys.UP) || Gdx.input.isKeyPressed(Keys.W);
while (acumulado >= PASSO) {
fase.atualizar(PASSO, new Comandos(direcao, puloPendente, segurando));
puloPendente = false;
poeira.atualizar(PASSO);
acumulado -= PASSO;
}
if (fase.estado == Fase.Estado.VENCEU) festa += dt;
}
desenhar(dt);
if (festa >= FESTA) { // SEMPRE por último: depois disto a tela é descartada
jogo.terminou(numero, fase.tempo, fase.moedasPegas, fase.totalDeMoedas);
}
}
private void desenhar(float dt) {
float alfa = pausado ? 1 : acumulado / PASSO;
var h = fase.heroi;
float hx = MathUtils.lerp(h.anterior.x, h.posicao.x, alfa), hy = MathUtils.lerp(h.anterior.y, h.posicao.y, alfa);
if (!pausado) seguirComACamera(hx, hy, dt);
ScreenUtils.clear(Color.valueOf("87ceeb"));
vista.apply();
desenhistaDoMapa.setView(camera);
desenhistaDoMapa.render();
var r = jogo.recursos;
var lote = r.lote;
lote.setProjectionMatrix(camera.combined);
lote.begin();
float giro = MathUtils.sin(tempoDeAnimacao * 5); // moedas "girando": largura oscila
for (Rectangle m : fase.moedas) {
float l = m.width * Math.abs(giro);
lote.draw(r.moeda, m.x + (m.width - l) / 2, m.y, l, m.height);
}
Rectangle b = fase.bandeira;
lote.setColor(Color.DARK_GRAY);
lote.draw(r.pixel, b.x + b.width / 2 - 0.05f, b.y, 0.1f, b.height);
lote.setColor(fase.estado == Fase.Estado.VENCEU ? Color.GOLD : Color.WHITE);
lote.draw(r.pixel, b.x + b.width / 2, b.y + b.height - 0.6f, 0.8f, 0.5f);
lote.setColor(Color.WHITE);
poeira.desenhar(lote, r.pixel);
TextureRegion quadro = !h.noChao() ? r.heroiNoAr
: Math.abs(h.velocidade.x) > 0.5f ? r.heroiCorrendo.getKeyFrame(tempoDeAnimacao) : r.heroiParado;
float largura = Heroi.LARGURA + 0.1f, altura = Heroi.ALTURA + 0.25f; // o desenho é um pouco maior que a caixa
float x = hx - 0.05f;
if (h.olhandoParaDireita) lote.draw(quadro, x, hy, largura, altura);
else lote.draw(quadro, x + largura, hy, -largura, altura); // largura negativa = espelhado
lote.end();
vistaHud.apply();
lote.setProjectionMatrix(vistaHud.getCamera().combined);
lote.begin();
r.fonte.draw(lote, "Fase %d moedas: %d/%d quedas: %d tempo: %.1f s".formatted(
numero + 1, fase.moedasPegas, fase.totalDeMoedas, fase.quedas, fase.tempo), 10, vistaHud.getScreenHeight() - 10);
if (pausado) {
r.fonteGrande.draw(lote, "PAUSADO", 0, vistaHud.getScreenHeight() / 2f + 20, vistaHud.getScreenWidth(),
com.badlogic.gdx.utils.Align.center, false);
r.fonte.draw(lote, "P continua | Esc volta ao menu", 0, vistaHud.getScreenHeight() / 2f - 30,
vistaHud.getScreenWidth(), com.badlogic.gdx.utils.Align.center, false);
}
lote.end();
}
private void seguirComACamera(float hx, float hy, float dt) {
float suavidade = 1 - (float) Math.exp(-6 * dt);
camera.position.x += (hx + Heroi.LARGURA / 2 - camera.position.x) * suavidade;
camera.position.y += (hy + 2 - camera.position.y) * suavidade;
camera.position.x = MathUtils.clamp(camera.position.x, VISTA_LARG / 2, fase.grade.colunas() - VISTA_LARG / 2);
camera.position.y = MathUtils.clamp(camera.position.y, VISTA_ALT / 2, Math.max(VISTA_ALT / 2, fase.grade.linhas() - VISTA_ALT / 2));
camera.update();
}
@Override
public void resize(int largura, int altura) {
vista.update(largura, altura);
vistaHud.update(largura, altura, true);
}
@Override
public void dispose() {
desenhistaDoMapa.dispose(); // não libera o lote: ele foi emprestado
poeira.limpar();
}
}
Os pontos de projeto que valem a pena:
- De quem é cada recurso. O mapa pertence ao
AssetManager, e a tela não chamadisposenele. O desenhista de mapas recebe oSpriteBatchcompartilhado no construtor, e aí o próprio libGDX sabe que não deve liberá-lo: odisposedo desenhista só libera o lote quando foi ele que o criou. - A troca de tela é a última linha do
render.jogo.terminou(...)chamatrocarPara, que chamadisposenesta mesma tela. Qualquer código depois disso usaria um desenhista já liberado. O teclado não tem esse problema: os eventos de teclado são processados antes dorender, e oGamepassa a desenhar a tela nova. - A pausa tem duas origens. A tecla P, e o método
pause(), que o libGDX chama quando a janela é minimizada ou, no Android, quando o jogador atende uma ligação. Durante a pausa, a interpolação usaalfa = 1, para a imagem não tremer entre dois estados. - Espelhar é desenhar com largura negativa. Assim uma folha de sprites olhando para a direita serve para os dois lados.
- Os efeitos ficam na tela, não na regra. A
Fasesó diz "pegou uma moeda em (x, y)". Se amanhã a moeda também tocar um som, só a tela muda.
Menu e placar
package br.com.exemplo.plataforma;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.InputAdapter;
import com.badlogic.gdx.ScreenAdapter;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.utils.Align;
import com.badlogic.gdx.utils.ScreenUtils;
import com.badlogic.gdx.utils.viewport.FitViewport;
/** Menu: escolhe a fase (setas + Enter). Fases bloqueadas aparecem, mas não abrem. */
public class TelaDeMenu extends ScreenAdapter {
private final JogoDaIlha jogo;
private final FitViewport vista = new FitViewport(960, 540); // unidades "de tela virtual"
private int escolhida;
public TelaDeMenu(JogoDaIlha jogo) { this.jogo = jogo; }
@Override
public void show() {
escolhida = jogo.progresso.fasesLiberadas() - 1; // começa na fase mais nova liberada
Gdx.input.setInputProcessor(new InputAdapter() { // cada tela instala o SEU processador
@Override
public boolean keyDown(int tecla) {
int total = jogo.progresso.totalDeFases();
switch (tecla) {
case Keys.UP -> escolhida = (escolhida + total - 1) % total;
case Keys.DOWN -> escolhida = (escolhida + 1) % total;
case Keys.ENTER, Keys.SPACE -> {
if (jogo.progresso.liberada(escolhida)) jogo.jogar(escolhida);
}
case Keys.ESCAPE -> Gdx.app.exit();
default -> { }
}
return true;
}
});
}
@Override
public void render(float delta) {
ScreenUtils.clear(Color.valueOf("1b3a4b"));
vista.apply();
var lote = jogo.recursos.lote;
lote.setProjectionMatrix(vista.getCamera().combined);
lote.begin();
jogo.recursos.fonteGrande.draw(lote, "A Ilha dos Blocos", 0, 440, 960, Align.center, false);
var p = jogo.progresso;
for (int n = 0; n < p.totalDeFases(); n++) {
String recorde = p.melhorTempo(n) > 0
? "recorde %.2f s | %d moeda(s)".formatted(p.melhorTempo(n), p.maisMoedas(n)) : "ainda sem recorde";
String linha = p.liberada(n) ? "Fase %d - %s".formatted(n + 1, recorde) : "Fase %d - bloqueada".formatted(n + 1);
var fonte = jogo.recursos.fonte;
fonte.setColor(!p.liberada(n) ? Color.GRAY : n == escolhida ? Color.GOLD : Color.WHITE);
fonte.draw(lote, (n == escolhida ? "> " : " ") + linha, 0, 320 - n * 40, 960, Align.center, false);
}
jogo.recursos.fonte.setColor(Color.LIGHT_GRAY);
jogo.recursos.fonte.draw(lote, "setas escolhem | Enter joga | Esc sai", 0, 80, 960, Align.center, false);
jogo.recursos.fonte.setColor(Color.WHITE);
lote.end();
}
@Override
public void resize(int largura, int altura) { vista.update(largura, altura, true); }
}
package br.com.exemplo.plataforma;
import com.badlogic.gdx.Gdx;
import com.badlogic.gdx.Input.Keys;
import com.badlogic.gdx.InputAdapter;
import com.badlogic.gdx.ScreenAdapter;
import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.utils.Align;
import com.badlogic.gdx.utils.ScreenUtils;
import com.badlogic.gdx.utils.viewport.FitViewport;
/** Placar da fase: tempo, moedas, recordes. Enter segue para a próxima (ou volta ao menu). */
public class TelaDeVitoria extends ScreenAdapter {
private final JogoDaIlha jogo;
private final int fase, moedas, total;
private final float tempo;
private final Progresso.Resultado resultado;
private final FitViewport vista = new FitViewport(960, 540);
public TelaDeVitoria(JogoDaIlha jogo, int fase, float tempo, int moedas, int total, Progresso.Resultado resultado) {
this.jogo = jogo; this.fase = fase; this.tempo = tempo; this.moedas = moedas; this.total = total; this.resultado = resultado;
}
@Override
public void show() {
Gdx.input.setInputProcessor(new InputAdapter() {
@Override
public boolean keyDown(int tecla) {
boolean temProxima = fase + 1 < jogo.progresso.totalDeFases() && jogo.progresso.liberada(fase + 1);
if (tecla == Keys.ENTER || tecla == Keys.SPACE) {
if (temProxima) jogo.jogar(fase + 1); else jogo.irParaMenu();
} else if (tecla == Keys.R) jogo.jogar(fase); // tentar bater o recorde
else if (tecla == Keys.ESCAPE) jogo.irParaMenu();
return true;
}
});
}
@Override
public void render(float delta) {
ScreenUtils.clear(Color.valueOf("0f5132"));
vista.apply();
var lote = jogo.recursos.lote;
var r = jogo.recursos;
lote.setProjectionMatrix(vista.getCamera().combined);
lote.begin();
r.fonteGrande.draw(lote, "Fase %d completa!".formatted(fase + 1), 0, 430, 960, Align.center, false);
r.fonte.draw(lote, "tempo: %.2f s%s".formatted(tempo, resultado.novoRecordeDeTempo() ? " * novo recorde!" : ""),
0, 320, 960, Align.center, false);
r.fonte.draw(lote, "moedas: %d/%d%s".formatted(moedas, total, resultado.novoRecordeDeMoedas() ? " * recorde!" : ""),
0, 285, 960, Align.center, false);
if (resultado.liberouProxima()) r.fonte.draw(lote, "Fase %d liberada!".formatted(fase + 2), 0, 240, 960, Align.center, false);
boolean ultima = fase + 1 >= jogo.progresso.totalDeFases();
r.fonte.draw(lote, (ultima ? "Enter: menu" : "Enter: próxima fase") + " | R: jogar de novo | Esc: menu",
0, 120, 960, Align.center, false);
lote.end();
}
@Override
public void resize(int largura, int altura) { vista.update(largura, altura, true); }
}
Na primeira versão eu usei "—", "•" e "★" nos textos. A fonte padrão do libGDX (lsans-15) só tem os caracteres do Latin-1: os acentos do português estão lá, mas esses símbolos não, e eles simplesmente não apareceriam. Troquei por "-", "|" e "*". Para símbolos ou outros alfabetos, gere a sua própria fonte com a extensão FreeType do libGDX ou com a ferramenta Hiero.
Recordes que sobrevivem: Preferences
O Preferences do libGDX é um dicionário chave–valor persistente. No desktop ele vira um arquivo XML em ~/.prefs/<nome>, no Android usa as SharedPreferences e na web o localStorage do navegador. A regra de recorde, porém, é Java puro:
package br.com.exemplo.plataforma;
import com.badlogic.gdx.Preferences;
/**
* O que o jogo lembra entre uma sessão e outra: fases liberadas, melhor tempo e mais moedas por fase.
* A regra (o que é recorde, o que libera o quê) é Java puro e testável; Preferences é só o "disco".
*/
public final class Progresso {
public record Resultado(boolean novoRecordeDeTempo, boolean novoRecordeDeMoedas, boolean liberouProxima) { }
private final int totalDeFases;
private int fasesLiberadas = 1;
private final float[] melhorTempo; // 0 = ainda não terminou
private final int[] maisMoedas;
public Progresso(int totalDeFases) {
this.totalDeFases = totalDeFases;
this.melhorTempo = new float[totalDeFases];
this.maisMoedas = new int[totalDeFases];
}
/** Registra uma vitória na fase {@code n} (começando em 0). */
public Resultado registrarVitoria(int n, float tempo, int moedas) {
boolean recordeTempo = melhorTempo[n] == 0 || tempo < melhorTempo[n];
if (recordeTempo) melhorTempo[n] = tempo;
boolean recordeMoedas = moedas > maisMoedas[n];
if (recordeMoedas) maisMoedas[n] = moedas;
boolean liberou = n + 1 < totalDeFases && fasesLiberadas == n + 1;
if (liberou) fasesLiberadas = n + 2;
return new Resultado(recordeTempo, recordeMoedas, liberou);
}
public boolean liberada(int n) { return n < fasesLiberadas; }
public int fasesLiberadas() { return fasesLiberadas; }
public float melhorTempo(int n) { return melhorTempo[n]; }
public int maisMoedas(int n) { return maisMoedas[n]; }
public int totalDeFases() { return totalDeFases; }
// ---- persistência: as chaves são o "formato do arquivo"; mudar uma chave é mudar o formato ----
public void lerDe(Preferences p) {
fasesLiberadas = Math.clamp(p.getInteger("liberadas", 1), 1, totalDeFases); // nunca confie no disco
for (int n = 0; n < totalDeFases; n++) {
melhorTempo[n] = Math.max(0, p.getFloat("tempo." + n, 0));
maisMoedas[n] = Math.max(0, p.getInteger("moedas." + n, 0));
}
}
public void salvarEm(Preferences p) {
p.putInteger("liberadas", fasesLiberadas);
for (int n = 0; n < totalDeFases; n++) {
p.putFloat("tempo." + n, melhorTempo[n]);
p.putInteger("moedas." + n, maisMoedas[n]);
}
p.flush(); // sem flush, nada vai para o disco
}
}
Dois cuidados:
- O
flush(). Sem ele, os valores ficam só na memória e se perdem ao fechar o jogo. - Desconfiar do disco. O arquivo é um XML legível e editável por qualquer pessoa.
Math.clamp(Java 21+) garante que umliberadas=99não quebre o menu. O exercício 3 mostra um caso que esta versão ainda não trata.
Os testes do capstone
As regras do jogo inteiro, testadas sem janela:
package br.com.exemplo.plataforma;
import com.badlogic.gdx.Preferences;
import com.badlogic.gdx.graphics.Color;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class CapstoneTest {
/** O mesmo "robô" do Piloto: corre e pula diante de buraco ou parede. */
static Fase jogarComOPiloto(String[] mapa, Fase.Eventos eventos) {
Fase f = Fase.deTexto(mapa);
f.eventos = eventos;
int segurar = 0;
for (int passo = 0; passo < 60 * 60 && f.estado == Fase.Estado.JOGANDO; passo++) {
var h = f.heroi;
int col = (int) Math.floor(h.posicao.x + Heroi.LARGURA + 0.3f), lin = (int) Math.floor(h.posicao.y);
boolean pular = h.noChao() && ((!f.grade.solido(col, lin - 1) && !f.grade.solido(col, lin - 2)) || f.grade.solido(col, lin));
if (pular) segurar = 24;
f.atualizar(1f / 60, new Comandos(1, pular, segurar-- > 0));
}
return f;
}
@Test
void todasAsFasesPodemSerVencidas() {
for (String[] mapa : Mapas.TODAS) {
Fase f = jogarComOPiloto(mapa, Fase.Eventos.NENHUM);
assertEquals(Fase.Estado.VENCEU, f.estado);
assertEquals(0, f.quedas);
}
}
@Test
void eventosChegamNaOrdemCerta() {
List<String> log = new ArrayList<>();
Fase f = jogarComOPiloto(Mapas.FASE1, new Fase.Eventos() {
public void pulou(float x, float y) { log.add("pulou"); }
public void aterrissou(float x, float y) { log.add("aterrissou"); }
public void pegouMoeda(float x, float y) { log.add("moeda"); }
public void venceu() { log.add("venceu"); }
});
assertEquals("aterrissou", log.getFirst()); // começa no ar e cai no chão
assertEquals("venceu", log.getLast());
assertEquals(f.moedasPegas, log.stream().filter("moeda"::equals).count());
long pulos = log.stream().filter("pulou"::equals).count();
assertEquals(6, pulos); // os 6 pulos que o piloto dá na fase 1
System.out.printf("fase 1: %d eventos, %d pulos, tempo %.2f s%n", log.size(), pulos, f.tempo);
}
@Test
void tempoParaDeContarQuandoVence() {
Fase f = jogarComOPiloto(Mapas.FASE1, Fase.Eventos.NENHUM);
float t = f.tempo;
for (int i = 0; i < 60; i++) f.atualizar(1f / 60, Comandos.NADA);
assertEquals(t, f.tempo);
}
@Test
void progressoRegistraRecordesELiberaFases() {
var p = new Progresso(2);
assertTrue(p.liberada(0));
assertFalse(p.liberada(1));
var r1 = p.registrarVitoria(0, 9.5f, 3);
assertTrue(r1.novoRecordeDeTempo() && r1.novoRecordeDeMoedas() && r1.liberouProxima());
assertTrue(p.liberada(1));
var r2 = p.registrarVitoria(0, 10.0f, 5); // mais lento, mas com mais moedas
assertFalse(r2.novoRecordeDeTempo());
assertTrue(r2.novoRecordeDeMoedas());
assertFalse(r2.liberouProxima()); // a fase 2 já estava liberada
assertEquals(9.5f, p.melhorTempo(0));
var r3 = p.registrarVitoria(1, 12f, 0); // última fase: não há próxima
assertFalse(r3.liberouProxima());
}
/** Um Preferences de mentira, guardado num Map — basta para testar o formato das chaves. */
@SuppressWarnings("unchecked")
static Preferences preferenciasEmMemoria(Map<String, Object> disco) {
return (Preferences) Proxy.newProxyInstance(Preferences.class.getClassLoader(), new Class<?>[]{Preferences.class},
(proxy, metodo, args) -> {
String nome = metodo.getName();
if (nome.startsWith("put")) { disco.put((String) args[0], args[1]); return proxy; }
if (nome.startsWith("get") && args != null && args.length == 2) return disco.getOrDefault((String) args[0], args[1]);
if (nome.equals("flush")) return null;
throw new UnsupportedOperationException(nome);
});
}
@Test
void progressoSobreviveAIdaEVoltaPeloDisco() {
Map<String, Object> disco = new HashMap<>();
var antes = new Progresso(2);
antes.registrarVitoria(0, 7.42f, 3);
antes.salvarEm(preferenciasEmMemoria(disco));
var depois = new Progresso(2);
depois.lerDe(preferenciasEmMemoria(disco));
assertEquals(2, depois.fasesLiberadas());
assertEquals(7.42f, depois.melhorTempo(0));
assertEquals(3, depois.maisMoedas(0));
System.out.println("no disco: " + new java.util.TreeMap<>(disco));
}
@Test
void discoAdulteradoNaoQuebraOJogo() {
Map<String, Object> disco = new HashMap<>(Map.of("liberadas", 99, "tempo.0", -5f));
var p = new Progresso(2);
p.lerDe(preferenciasEmMemoria(disco));
assertEquals(2, p.fasesLiberadas()); // limitado ao número real de fases
assertEquals(0f, p.melhorTempo(0)); // tempo negativo vira "sem recorde"
}
@Test
void poeiraReciclaEmVezDeCriar() {
var poeira = new Poeira();
for (int quadro = 0; quadro < 600; quadro++) { // 10 s emitindo 10 grãos por quadro
poeira.emitir(0, 0, 10, Color.TAN, 3, -10);
poeira.atualizar(1f / 60);
}
int vivos = poeira.ativos();
assertTrue(vivos > 0 && vivos <= 10 * 31, "vivos=" + vivos); // vida máx 0,5 s ≈ 30 quadros
poeira.limpar();
assertEquals(0, poeira.ativos());
}
}
O preferenciasEmMemoria usa um proxy dinâmico do Java: um objeto que implementa uma interface em tempo de execução, mandando toda chamada para um único lambda. Para testar só put*, get* e flush, é muito mais curto do que implementar os mais de vinte métodos da interface Preferences.
Com os 9 testes do herói, são 16 testes, e todos passaram:
fase 1: 16 eventos, 6 pulos, tempo 7.40 s
no disco: {liberadas=2, moedas.0=3, moedas.1=0, tempo.0=7.42, tempo.1=0.0}
[ 16 tests successful ]
[ 0 tests failed ]
O lançador
package br.com.exemplo.plataforma.lwjgl3;
import br.com.exemplo.plataforma.JogoDaIlha;
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3Application;
import com.badlogic.gdx.backends.lwjgl3.Lwjgl3ApplicationConfiguration;
public class Lwjgl3Launcher {
public static void main(String[] args) {
var cfg = new Lwjgl3ApplicationConfiguration();
cfg.setTitle("A Ilha dos Blocos");
cfg.setWindowedMode(960, 540);
cfg.useVsync(true);
cfg.setForegroundFPS(Lwjgl3ApplicationConfiguration.getDisplayMode().refreshRate + 1);
new Lwjgl3Application(new JogoDaIlha(), cfg);
}
}
O projeto gerado pelo gdx-liftoff inclui também uma classe StartupHelper, chamada no começo do main. Ela reinicia a JVM com as opções que o macOS exige para janelas OpenGL. Mantenha a que o gerador criar.
Exportando: do jar ao programa com o próprio Java
Um jogo só está pronto quando outra pessoa consegue jogá-lo. Há três degraus.
1. O jar executável ("gordo"). Um único .jar com o seu código, o libGDX, o LWJGL, as bibliotecas nativas e a pasta assets. No projeto do gdx-liftoff, é uma tarefa do Gradle:
./gradlew lwjgl3:jar # gera lwjgl3/build/libs/<NomeDoJogo>-<versão>.jar
java -jar lwjgl3/build/libs/IlhaDosBlocos-1.0.0.jar
Montando esse jar à mão com as mesmas dependências, inclusive as bibliotecas nativas das sete plataformas que o backend LWJGL 3 do libGDX 1.14.2 declara (Linux, macOS e Windows, em mais de uma arquitetura), ele ficou com 14 MB. Os mapas vão dentro dele, e o InternalFileHandleResolver os encontra no classpath. O problema é que quem vai jogar precisa ter Java 25 instalado.
2. Um Java só com o necessário (jlink). O jdeps descobre quais módulos do JDK o jar usa, e o jlink monta um runtime só com eles:
jdeps --ignore-missing-deps --print-module-deps --multi-release 25 ilha.jar
# java.base,java.desktop,jdk.unsupported
jlink --add-modules java.base,java.desktop,jdk.unsupported \
--strip-debug --no-header-files --no-man-pages --compress zip-6 \
--output runtime
O runtime ficou com 61 MB (no Temurin 25.0.4.1), contra algumas centenas de megabytes de um JDK completo. O jdk.unsupported aparece porque as bibliotecas nativas usam sun.misc.Unsafe. Rode o jdeps no seu jar, porque o resultado depende das bibliotecas usadas.
3. O programa (jpackage). O jpackage, que vem no JDK, junta o jar e o runtime numa pasta com um executável nativo (--type app-image) ou num instalador (--type deb, rpm, msi, exe, dmg, pkg):
mkdir entrada && cp ilha.jar entrada/
jpackage --type app-image --name IlhaDosBlocos --app-version 1.0.0 \
--input entrada --main-jar ilha.jar --runtime-image runtime \
--java-options '--enable-native-access=ALL-UNNAMED' \
--dest saida
saida/IlhaDosBlocos/
├── bin/IlhaDosBlocos ← o executável que o jogador abre
└── lib/
├── app/ilha.jar ← o jogo
└── runtime/ ← o Java (61 MB)
A pasta inteira tem 76 MB, e compactada em .tar.gz ficou com 50 MB. A opção --enable-native-access=ALL-UNNAMED evita o aviso do Java 25 sobre bibliotecas que carregam código nativo, que em versões futuras vai virar bloqueio.
Joguei essa versão empacotada do começo ao fim, com o runtime e o jar do pacote e as teclas vindas de um roteiro (os mesmos instantes de pulo do piloto):
- menu com a fase 2 bloqueada;
- Enter;
- fase 1 vencida em 7,40 s com 3 moedas;
- Enter no placar, direto para a fase 2 recém-liberada;
- pausa com P no meio da fase 2 (a foto mostra "PAUSADO" sobre a cena congelada);
- fase 2 vencida em 8,37 s com 2 de 10 moedas;
- menu com os dois recordes.
O arquivo ~/.prefs/br.com.exemplo.plataforma ficou assim:
<properties>
<entry key="moedas.0">3</entry>
<entry key="moedas.1">2</entry>
<entry key="liberadas">2</entry>
<entry key="tempo.0">7.400042</entry>
<entry key="tempo.1">8.366711</entry>
</properties>
Os tempos são os do piloto nos testes (7,40 s e 8,37 s): a janela, o passo fixo e o roteiro reproduzem a mesma partida. Abrindo de novo, agora pelo executável bin/IlhaDosBlocos, a primeira linha do log foi progresso lido: 2 fase(s) liberada(s), recorde fase 1 = 7.40 s. O progresso sobreviveu ao fechamento.
O jeito do ecossistema libGDX. Os três passos à mão servem para entender o que acontece por baixo. No dia a dia, o gdx-liftoff pode adicionar ao projeto o plugin Construo, um plugin do Gradle que faz jlink e empacotamento para outros sistemas a partir do seu: gera o Windows e o macOS rodando no Linux, por exemplo, algo que o jpackage não faz, porque ele só empacota para o sistema em que está rodando. As tarefas têm nomes como lwjgl3:packageLinuxX64 e lwjgl3:packageWinX64, e a lista exata está na documentação do plugin. A página "Bundling a JRE" da wiki do libGDX compara as opções: Construo, jpackage e o antigo packr.
As outras plataformas. O core do jogo não muda. O que muda é o módulo lançador:
- Android: o módulo
androiddo liftoff gera um.apk/.aabcom oAndroidApplication. Opause()da nossa tela passa a ser chamado de verdade quando o jogador sai do app, e oPreferencesviraSharedPreferences, sem mudar uma linha nossa. - Web: pelo GWT, o backend oficial, ou pelo TeaVM, via gdx-teavm, que o liftoff oferece. Os dois traduzem o Java para JavaScript/WebAssembly e têm restrições (reflexão, threads, parte da biblioteca padrão). Nosso código usa
record,switchcom->eString.formatted, e isso precisa ser conferido com o backend escolhido antes de prometer uma versão web. - iOS: pelo MobiVM, um fork mantido do RoboVM, que compila o Java antecipadamente para código nativo da Apple. É o caminho mais trabalhoso: exige um Mac e uma conta de desenvolvedor.
Fechando a Trilha C
A Trilha C começou com uma pergunta de "Um Novo Território — Java nos Jogos, o Game Loop e o Caso Minecraft": o que um jogo tem de diferente de todos os programas que escrevemos antes? A resposta foi o laço. Um servidor espera por pedidos, e um jogo não espera por nada: a cada 16,7 ms ele lê a entrada, avança o mundo num passo fixo e desenha. Tudo o que veio depois foi aprender a encher esse laço sem quebrá-lo:
- "O Primeiro Sprite — Janela, Desenho e Input com libGDX" trocou o Java 2D pela GPU e pensar em pixels por pensar em metros, com um viewport que se adapta a qualquer tela.
- "Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids" mostrou que colisão é geometria mais disciplina: testes de varredura para objetos rápidos, remoção segura durante a iteração e regras testadas sem janela.
- "Gravidade de Verdade — Física com Box2D" entregou a física a um motor de verdade e ensinou o preço dos recursos nativos, com uma falha de segmentação real e a correção.
- "Mundos em Blocos — Mapas de Tiles e um Jogo de Plataforma" mostrou o oposto: às vezes a física boa é a que nós escrevemos, com poucas regras bem testadas, e é o level design que decide se o jogo é justo.
- "Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos" mediu o que costuma ser folclore: 74 bytes por passo não são problema, 113 KB por quadro já põem uma pausa do coletor no pior quadro, e o pior quadro importa mais do que a média.
- Este capstone juntou tudo num jogo com começo, meio e fim, que outra pessoa baixa e joga.
Por baixo das novidades, a trilha reaproveitou o curso inteiro: records, switch com padrões, interfaces com métodos default, o padrão observador, testes com JUnit, o cuidado com recursos (dispose é o close dos jogos), o jlink e o jpackage. Nada disso é exclusivo de jogos, e é esse o ponto: um jogo é um programa Java como qualquer outro, só que com um relógio de 16,7 ms.
Com isso terminam as três trilhas de extensão: a web e os dados, o desktop e o Android, e os jogos.
Fontes e leituras recomendadas
- libGDX Wiki — Bundling a JRE
- Construo — plugin Gradle de empacotamento multiplataforma
- gdx-liftoff — gerador de projetos libGDX
- libGDX Wiki — Preferences
- libGDX Wiki — Managing your assets (AssetManager)
- Oracle — Packaging Tool User's Guide (jpackage), Java 25
Exercícios
Exercício 1
Explique por que trocarPara chama velha.dispose() depois de setScreen(nova), e por que na TelaDeJogo a chamada jogo.terminou(...) precisa ser a última coisa do render. O que aconteceria se ela fosse feita dentro do laço while (acumulado >= PASSO)?
Ver resposta
✓ Resposta: O setScreen chama hide() na tela velha e show()/resize() na nova. Liberar a velha antes disso deixaria o Game chamando hide() numa tela já descartada. Dentro do render, jogo.terminou dispara trocarPara, que libera esta mesma TelaDeJogo: o desenhista de mapas e o pool de poeira deixam de valer. Se a chamada ficasse dentro do laço de passos, o laço continuaria rodando (fase.atualizar, poeira.atualizar) e, depois dele, desenhar usaria o OrthogonalTiledMapRenderer já liberado. Em recursos nativos isso pode derrubar o processo, como a junta destruída na aula do Box2D. Com a troca na última linha, nada mais desta tela executa depois dela.
Exercício 2
Descreva todas as mudanças necessárias para adicionar uma terceira fase. Quais testes passam a cobri-la automaticamente?
Ver resposta
✓ Resposta: (a) Acrescente FASE3 em Mapas e inclua-a em TODAS. (b) Acrescente "fase3.tmx" em Recursos.MAPAS. (c) Rode GerarFases para gerar o .tmx, ou desenhe no Tiled. O Progresso usa Recursos.MAPAS.length, o menu percorre totalDeFases() e a vitória libera a próxima pela regra genérica, então nenhuma outra classe muda. O teste todasAsFasesPodemSerVencidas percorre Mapas.TODAS, e passa a exigir que o piloto vença a fase nova sem quedas. Recordes antigos continuam valendo, porque as chaves tempo.0 e tempo.1 não mudam, e a chave tempo.2 simplesmente nasce com zero.
Exercício 3
Um jogador curioso abre o arquivo de preferências e troca tempo.0 para abc. O que acontece ao abrir o jogo? Como corrigir?
Ver resposta
✓ Resposta: No backend LWJGL3, getFloat faz Float.parseFloat no texto guardado (conferi no código-fonte do Lwjgl3Preferences), e "abc" lança NumberFormatException dentro do create(): o jogo fecha na abertura. O Math.clamp e o Math.max só protegem contra números absurdos, não contra texto inválido. A correção é ler cada valor com um método que trate o erro, por exemplo float lerFloat(Preferences p, String k) { try { return Math.max(0, p.getFloat(k, 0)); } catch (NumberFormatException e) { return 0; } }, e acrescentar ao CapstoneTest um caso com "tempo.0" = "abc". Para isso, o proxy de teste precisa simular o parseFloat do backend real.
Exercício 4
A versão do jpackage desta aula gera um programa para o sistema em que foi executado. Você desenvolve no Linux e quer entregar também para Windows e macOS. Quais são os caminhos?
Ver resposta
✓ Resposta: O jpackage não faz empacotamento cruzado: ele precisa rodar em cada sistema de destino, com um JDK daquele sistema. Os caminhos são: (a) rodar o mesmo roteiro de jlink + jpackage em máquinas ou em CI (por exemplo, GitHub Actions com Windows e macOS), um trabalho por sistema; ou (b) usar o Construo, que baixa os JDKs de cada plataforma e monta os pacotes a partir de um único sistema. Em qualquer caso, o jar do jogo é o mesmo, porque o LWJGL 3 traz as bibliotecas nativas de todas as plataformas. No macOS há ainda a assinatura e a notarização da Apple, sem as quais o sistema bloqueia a abertura.
Exercício 5
Por que a Poeira usa g.cor.set(cor), e não um campo Color cor que simplesmente guarda a referência recebida? Dê um cenário concreto em que a segunda opção produziria um bug visível.
Ver resposta
✓ Resposta: Com set, cada grão tem a própria Color (criada uma vez, junto com o grão, e reaproveitada pelo Pool) e copia os valores. Guardando a referência, todos os grãos emitidos com Color.TAN apontariam para o mesmo objeto estático do libGDX. O cenário: para o brilho sumir aos poucos, alguém muda a transparência com g.cor.a = g.vida / g.vidaTotal. Com a referência compartilhada, essa linha altera o Color.TAN global, e todas as partículas marrons, a do pulo de agora e a do pouso de daqui a pouco, piscam juntas com a transparência do último grão atualizado. Pior: qualquer outro código do jogo que use Color.TAN passa a receber uma cor semitransparente. A regra é a mesma do Pool: dados que o objeto vai modificar precisam ser dele.