Mundos em Blocos — Mapas de Tiles e um Jogo de Plataforma

Mundos em Blocos — Mapas de Tiles e um Jogo de Plataforma

Nesta aula você desenha uma fase no formato do editor Tiled, carrega esse mapa no libGDX e escreve, sem Box2D, a física de um jogo de plataforma que responde bem ao controle: colisão com a grade, pulo de altura variável, coyote time e jump buffer. No fim, a fase inteira é jogada por um "robô" nos testes, para...
Java

• • 31 min de leitura

Nesta aula você desenha uma fase no formato do editor Tiled, carrega esse mapa no libGDX e escreve, sem Box2D, a física de um jogo de plataforma que responde bem ao controle: colisão com a grade, pulo de altura variável, coyote time e jump buffer. No fim, a fase inteira é jogada por um "robô" nos testes, para garantir que dá para vencê-la.

Em "Gravidade de Verdade — Física com Box2D" usamos um motor de física completo, e vimos que ele é ótimo para caixas que tombam, pontes que balançam e projéteis que ricocheteiam. Mas quase nenhum jogo de plataforma famoso usa física realista. Em Super Mario, Celeste ou Hollow Knight, o personagem acelera e para de um jeito que nenhum corpo real faria, e é exatamente isso que faz o controle ser "gostoso". Nesta aula o mundo vira uma grade de blocos (tiles) e a física passa a ser nossa: poucas regras, todas testáveis.

Por que mundos em blocos

Um mundo em tiles é uma matriz: cada célula guarda o número de um pequeno desenho (16 × 16 pixels, no nosso caso) ou zero, se estiver vazia. Isso resolve três problemas de uma vez:

  • Arte: um único tileset (a folha com os blocos) monta mundos enormes. A nossa folha tem só dois blocos, "grama por cima de terra" e "terra", e desenha a fase inteira.
  • Memória e desenho: o mapa é uma matriz de inteiros, e o desenhista só desenha os tiles que estão dentro da câmera.
  • Colisão: perguntar "tem chão aqui?" é ler uma célula da matriz. Não há geometria para intersectar.

A ferramenta padrão para desenhar esses mapas é o Tiled, um editor gratuito e de código aberto. Ele salva no formato TMX, que é XML, e o libGDX lê esse formato nativamente.

Uma unidade para tudo: 1 tile = 1 metro

Na aula do Box2D aprendemos a pensar em metros, e não em pixels. Aqui a escolha fica natural: cada tile mede 1 × 1 no mundo. O herói tem 0,7 de largura e 0,9 de altura, e a câmera mostra 20 × 11,25 tiles (a proporção 16:9). Os pixels só aparecem em dois lugares:

  • no arquivo do mapa, porque o Tiled trabalha em pixels;
  • no desenhista, que recebe a escala 1/16f para converter de volta.

O formato TMX por dentro

No dia a dia você desenha no Tiled. Para enxergar o formato por dentro, esta aula gera o arquivo a partir de um desenho em texto, em que # é chão, H é o início, o é moeda, B é a bandeira e . é vazio:

package br.com.exemplo.plataforma;

import com.badlogic.gdx.graphics.Color;
import com.badlogic.gdx.graphics.Pixmap;
import com.badlogic.gdx.graphics.PixmapIO;
import com.badlogic.gdx.files.FileHandle;

import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;

/**
 * Gera um mapa .tmx (o formato do Tiled) e o tileset .png a partir de um desenho em texto.
 * No dia a dia você desenha no editor Tiled; aqui geramos o arquivo para ver o formato por dentro.
 */
public final class GeradorDeMapa {
    static final int T = 16;                                      // pixels por tile

    public static void gerar(Path pasta, String nome, String... linhas) throws Exception {
        Files.createDirectories(pasta);
        tileset(pasta.resolve("terreno.png"));
        int alt = linhas.length, larg = linhas[0].length();
        var csv = new StringBuilder();
        var objetos = new StringBuilder();
        int id = 1;
        for (int i = 0; i < alt; i++) {
            for (int c = 0; c < larg; c++) {
                char ch = linhas[i].charAt(c);
                boolean solido = ch == '#';
                boolean temSolidoEmCima = i > 0 && linhas[i - 1].charAt(c) == '#';
                int gid = !solido ? 0 : (temSolidoEmCima ? 2 : 1);  // 1 = grama (superfície), 2 = terra
                csv.append(gid).append(c == larg - 1 && i == alt - 1 ? "" : ",");
                String obj = switch (ch) {
                    case 'H' -> objeto(id++, "inicio", c * T + 2.4f, i * T, 11.2f, 16);
                    case 'o' -> objeto(id++, "moeda", c * T + 4, i * T + 4, 8, 8);
                    case 'B' -> objeto(id++, "bandeira", c * T + 5, (i - 1) * T, 6, 32);
                    default -> null;
                };
                if (obj != null) objetos.append(obj);
            }
            csv.append('\n');
        }
        String tmx = """
                <?xml version="1.0" encoding="UTF-8"?>
                <map version="1.10" tiledversion="1.11.2" orientation="orthogonal" renderorder="right-down" \
                width="%d" height="%d" tilewidth="%d" tileheight="%d" infinite="0" nextlayerid="3" nextobjectid="%d">
                 <tileset firstgid="1" name="terreno" tilewidth="%d" tileheight="%d" tilecount="2" columns="2">
                  <image source="terreno.png" width="%d" height="%d"/>
                 </tileset>
                 <layer id="1" name="chao" width="%d" height="%d">
                  <data encoding="csv">
                %s</data>
                 </layer>
                 <objectgroup id="2" name="objetos">
                %s </objectgroup>
                </map>
                """.formatted(larg, alt, T, T, id, T, T, 2 * T, T, larg, alt, csv, objetos);
        Files.writeString(pasta.resolve(nome + ".tmx"), tmx, StandardCharsets.UTF_8);
    }

    private static String objeto(int id, String nome, float x, float y, float w, float h) {
        return "  <object id=\"%d\" name=\"%s\" x=\"%s\" y=\"%s\" width=\"%s\" height=\"%s\"/>\n".formatted(id, nome, x, y, w, h);
    }

    /** Duas células de 16 x 16: grama por cima de terra, e terra. */
    private static void tileset(Path arquivo) {
        var p = new Pixmap(2 * T, T, Pixmap.Format.RGBA8888);
        for (int t = 0; t < 2; t++) {
            p.setColor(Color.valueOf("8b5a2b"));
            p.fillRectangle(t * T, 0, T, T);
            p.setColor(Color.valueOf("7a4d24"));
            for (int k = 0; k < 6; k++) p.fillRectangle(t * T + (k * 5) % 14, 4 + (k * 7) % 10, 2, 2);   // pedrinhas
            if (t == 0) {
                p.setColor(Color.valueOf("4caf50"));
                p.fillRectangle(0, 0, T, 5);                          // grama no topo (Pixmap: y para baixo)
                p.setColor(Color.valueOf("388e3c"));
                p.fillRectangle(0, 5, T, 1);
            }
        }
        PixmapIO.writePNG(new FileHandle(arquivo.toFile()), p);
        p.dispose();
    }
}

O começo do fase1.tmx gerado mostra as três partes do formato:

<map version="1.10" tiledversion="1.11.2" orientation="orthogonal" renderorder="right-down"
     width="54" height="12" tilewidth="16" tileheight="16" infinite="0" nextlayerid="3" nextobjectid="12">
 <tileset firstgid="1" name="terreno" tilewidth="16" tileheight="16" tilecount="2" columns="2">
  <image source="terreno.png" width="32" height="16"/>
 </tileset>
 <layer id="1" name="chao" width="54" height="12">
  <data encoding="csv">
0,0,0,0,0,0,0,0,0,0,0,0, ...
1,1,1,1,1,1,0,0,0,0,1,1,1,1,1,0,0,0,1,1, ...
2,2,2,2,2,2,0,0,0,0,2,2,2,2,2,0,0,0,2,2, ...
</data>
 </layer>
 <objectgroup id="2" name="objetos">
  <object id="1" name="moeda" x="324.0" y="68.0" width="8.0" height="8.0"/>
  <object id="7" name="bandeira" x="821.0" y="80.0" width="6.0" height="32.0"/>
  <object id="8" name="inicio" x="18.4" y="128.0" width="11.2" height="16.0"/>
  ...
  • O tileset diz qual imagem recortar e em que tamanho. firstgid="1" significa que o primeiro bloco da folha é o número 1. O zero fica reservado para "vazio".
  • A camada de tiles (chao) é a matriz em CSV, de cima para baixo: a primeira linha do CSV é o topo do mapa.
  • A camada de objetos (objetos) guarda retângulos livres, em pixels, também com o y crescendo para baixo. É onde ficam as coisas que não são blocos: o ponto de início, as moedas e a bandeira.

O TmxMapLoader do libGDX inverte o y ao carregar, tanto das células quanto dos objetos, para que tudo cresça para cima, como no resto do libGDX. Por isso nosso código nunca faz essa conta.

A grade: tudo o que a física precisa saber

A física não precisa conhecer o Tiled, nem texturas, nem arquivos. Ela precisa de uma única resposta: "esta célula é sólida?". Uma interface pequena separa as duas coisas:

package br.com.exemplo.plataforma;

/** O que a física precisa saber do mapa: quais células são sólidas. 1 célula = 1 metro. */
public interface Grade {
    int colunas();
    int linhas();
    boolean solidoNaCelula(int coluna, int linha);

    /** Fora do mapa: as laterais são paredes, o fundo é o vazio (dá para cair), o céu é aberto. */
    default boolean solido(int coluna, int linha) {
        if (coluna < 0 || coluna >= colunas()) return true;
        if (linha < 0 || linha >= linhas()) return false;
        return solidoNaCelula(coluna, linha);
    }
}

O método default decide o que existe fora do mapa, e cada escolha é uma regra do jogo:

  • as laterais são paredes, então o herói não sai pela esquerda;
  • abaixo do mapa não há chão, então dá para cair num buraco;
  • acima dele não há teto, então dá para pular acima da borda de cima.

Os comandos chegam num record, separando o que foi apertado neste passo, que é um evento, do que está sendo segurado, que é um estado:

package br.com.exemplo.plataforma;

/**
 * direcao: -1..1. puloApertado: a tecla FOI apertada neste passo (evento).
 * puloSegurado: a tecla ESTÁ apertada (para o pulo de altura variável).
 */
public record Comandos(float direcao, boolean puloApertado, boolean puloSegurado) {
    public static final Comandos NADA = new Comandos(0, false, false);
}

O herói: física de plataforma em 100 linhas

O herói é uma caixa alinhada aos eixos, a AABB (axis-aligned bounding box) que vimos em "Quando as Coisas se Tocam — Colisão e o Primeiro Jogo: Asteroids". Cada ideia do código resolve um problema clássico do gênero:

package br.com.exemplo.plataforma;

import com.badlogic.gdx.math.Vector2;

/**
 * O personagem: uma caixa (AABB) que anda, cai e pula numa grade de tiles.
 * Sem Box2D: num plataforma, o "controle gostoso" vale mais que a física realista.
 */
public final class Heroi {
    public static final float LARGURA = 0.7f, ALTURA = 0.9f;
    static final float GRAVIDADE = -40f;          // m/s² (bem mais que 9,8: jogos "caem rápido")
    static final float QUEDA_MAXIMA = -18f;       // velocidade terminal: 0,3 m por passo < 1 tile
    static final float VEL_MAX = 7f;
    static final float ACEL_CHAO = 60f, ACEL_AR = 35f, FREIO = 50f;
    static final float PULO = 14f;                // v0 do pulo: altura máxima v0²/2g ≈ 2,45 m
    static final float CORTE_DO_PULO = 0.45f;     // soltar o botão na subida corta a velocidade
    static final float COYOTE = 0.10f;            // s: ainda pode pular logo depois de sair da borda
    static final float BUFFER = 0.12f;            // s: um pulo apertado antes de tocar o chão vale

    public final Vector2 posicao = new Vector2();  // canto inferior esquerdo
    public final Vector2 anterior = new Vector2();
    public final Vector2 velocidade = new Vector2();
    boolean noChao;
    float desdeOChao = 99f;                       // tempo desde que saiu do chão
    float bufferDePulo;                           // tempo restante do pulo "guardado"
    boolean subindoPorPulo;

    public boolean noChao() { return noChao; }

    public void atualizar(float dt, Comandos cmd, Grade grade) {
        anterior.set(posicao);

        // horizontal: acelera na direção pedida, freia quando solta (no ar, controle reduzido)
        float alvo = cmd.direcao() * VEL_MAX;
        float acel = cmd.direcao() != 0 ? (noChao ? ACEL_CHAO : ACEL_AR) : (noChao ? FREIO : ACEL_AR * 0.3f);
        velocidade.x = aproximar(velocidade.x, alvo, acel * dt);

        // pulo: buffer + coyote time
        if (cmd.puloApertado()) bufferDePulo = BUFFER;
        boolean podePular = noChao || desdeOChao <= COYOTE;
        if (bufferDePulo > 0 && podePular) {
            velocidade.y = PULO;
            bufferDePulo = 0;
            desdeOChao = COYOTE + 1;              // não deixa pular duas vezes com o mesmo coyote
            noChao = false;
            subindoPorPulo = true;
        }
        if (subindoPorPulo && !cmd.puloSegurado() && velocidade.y > 0) {
            velocidade.y *= CORTE_DO_PULO;         // pulo curto
            subindoPorPulo = false;
        }
        bufferDePulo = Math.max(0, bufferDePulo - dt);

        velocidade.y = Math.max(velocidade.y + GRAVIDADE * dt, QUEDA_MAXIMA);

        // colisão: um eixo de cada vez (primeiro X, depois Y)
        moverX(velocidade.x * dt, grade);
        boolean estavaNoChao = noChao;
        moverY(velocidade.y * dt, grade);
        if (noChao) { desdeOChao = 0; subindoPorPulo = false; }
        else desdeOChao += dt;
        if (estavaNoChao && !noChao && velocidade.y <= 0) desdeOChao = dt;   // acabou de sair da borda
    }

    private void moverX(float dx, Grade g) {
        posicao.x += dx;
        int linBaixo = (int) Math.floor(posicao.y + 0.001f), linCima = (int) Math.floor(posicao.y + ALTURA - 0.001f);
        if (dx > 0) {
            int col = (int) Math.floor(posicao.x + LARGURA);
            for (int l = linBaixo; l <= linCima; l++) if (g.solido(col, l)) { posicao.x = col - LARGURA; velocidade.x = 0; return; }
        } else if (dx < 0) {
            int col = (int) Math.floor(posicao.x);
            for (int l = linBaixo; l <= linCima; l++) if (g.solido(col, l)) { posicao.x = col + 1; velocidade.x = 0; return; }
        }
    }

    private void moverY(float dy, Grade g) {
        posicao.y += dy;
        noChao = false;
        int colEsq = (int) Math.floor(posicao.x + 0.001f), colDir = (int) Math.floor(posicao.x + LARGURA - 0.001f);
        if (dy < 0) {
            int lin = (int) Math.floor(posicao.y);
            for (int c = colEsq; c <= colDir; c++) if (g.solido(c, lin)) { posicao.y = lin + 1; velocidade.y = 0; noChao = true; return; }
        } else if (dy > 0) {
            int lin = (int) Math.floor(posicao.y + ALTURA);
            for (int c = colEsq; c <= colDir; c++) if (g.solido(c, lin)) { posicao.y = lin - ALTURA; velocidade.y = 0; return; }
        } else {
            // parado em y: confere se ainda há chão logo abaixo
            int lin = (int) Math.floor(posicao.y - 0.01f);
            for (int c = colEsq; c <= colDir; c++) if (g.solido(c, lin)) { noChao = true; return; }
        }
    }

    private static float aproximar(float atual, float alvo, float passo) {
        return atual < alvo ? Math.min(atual + passo, alvo) : Math.max(atual - passo, alvo);
    }

    void colocarEm(float x, float y) {
        posicao.set(x, y);
        anterior.set(x, y);
        velocidade.setZero();
        noChao = false;
    }

    static float alturaTeoricaDoPulo() { return PULO * PULO / (2 * -GRAVIDADE); }
}

As ideias, uma a uma:

  • Um eixo de cada vez. Primeiro o herói anda em X e, se entrou numa parede, é empurrado para a borda do tile. Depois anda em Y e, se entrou no chão ou no teto, acontece o mesmo. Resolver os dois eixos juntos obriga a descobrir por qual lado a caixa entrou no bloco, e é daí que vêm os bugs famosos de "grudar na quina". Separando os eixos, a pergunta nunca aparece.
  • O epsilon de 0,001. Ao calcular quais linhas a caixa ocupa, encolhemos a caixa um milésimo. Sem isso, um herói apoiado exatamente em y = 1 "ocupa" a linha 0 (o chão) e trataria o próprio chão como parede ao andar para o lado.
  • Velocidade terminal. Com QUEDA_MAXIMA = -18 e passo de 1/60 s, o herói desce no máximo 0,3 m por passo, menos que um tile. Por isso ele nunca atravessa um chão de um bloco de espessura. É o mesmo problema de túnel que o Box2D resolve com bullet, só que aqui resolvido por construção.
  • Aceleração diferente no chão e no ar. No ar há controle, mas menos. Soltar a direção no chão freia depressa, e no ar freia devagar.
  • Pulo de altura variável. Enquanto o botão está segurado, o pulo segue inteiro. Se o jogador solta durante a subida, a velocidade vertical é multiplicada por 0,45. Um toque rápido dá um pulinho, e segurar dá o pulo completo.
  • Coyote time (0,10 s). O jogador sempre aperta o pulo um pouquinho depois de sair da borda. O nome vem do coiote do desenho animado, que corre alguns passos no ar antes de cair. Durante 100 ms depois de sair do chão, o pulo ainda vale.
  • Jump buffer (0,12 s). O caso oposto: o jogador aperta o pulo um pouco antes de tocar o chão. O pedido fica guardado por 120 ms e o pulo acontece no instante do pouso.

Nenhuma dessas regras é "física". São decisões de game design, e é por isso que elas precisam de testes.

A fase: as regras do jogo

A Fase junta a grade, o herói, as moedas e a bandeira. O método estático deTexto cria uma fase direto do desenho em texto, sem arquivo nem GPU, o que é perfeito para testes:

package br.com.exemplo.plataforma;

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

import java.util.ArrayList;
import java.util.List;

/** Uma fase: a grade, onde o herói começa, as moedas e a bandeira. As regras do jogo moram aqui. */
public final class Fase {
    public enum Estado { JOGANDO, VENCEU }

    public final Grade grade;
    public final Vector2 inicio;
    public final List<Rectangle> moedas;
    public final Rectangle bandeira;
    public final Heroi heroi = new Heroi();
    public final int totalDeMoedas;
    public int moedasPegas, quedas;
    public Estado estado = Estado.JOGANDO;
    private final Rectangle caixaDoHeroi = new Rectangle();

    public Fase(Grade grade, Vector2 inicio, List<Rectangle> moedas, Rectangle bandeira) {
        this.grade = grade;
        this.inicio = inicio;
        this.moedas = new ArrayList<>(moedas);
        this.bandeira = bandeira;
        this.totalDeMoedas = moedas.size();
        heroi.colocarEm(inicio.x, inicio.y);
    }

    public void atualizar(float dt, Comandos cmd) {
        if (estado != Estado.JOGANDO) return;
        heroi.atualizar(dt, cmd, grade);
        caixaDoHeroi.set(heroi.posicao.x, heroi.posicao.y, Heroi.LARGURA, Heroi.ALTURA);
        moedas.removeIf(m -> {
            boolean pegou = m.overlaps(caixaDoHeroi);
            if (pegou) moedasPegas++;
            return pegou;
        });
        if (bandeira.overlaps(caixaDoHeroi)) estado = Estado.VENCEU;
        if (heroi.posicao.y < -3) {                   // caiu num buraco: volta ao início
            quedas++;
            heroi.colocarEm(inicio.x, inicio.y);
        }
    }

    /**
     * Fase a partir de "desenho em texto" (útil em testes e para gerar mapas):
     * '#' chão, 'H' início, 'o' moeda, 'B' bandeira, '.' vazio. A primeira linha do texto é o TOPO.
     */
    public static Fase deTexto(String... linhas) {
        int alt = linhas.length, larg = linhas[0].length();
        boolean[][] solido = new boolean[larg][alt];
        Vector2 inicio = new Vector2();
        List<Rectangle> moedas = new ArrayList<>();
        Rectangle bandeira = new Rectangle(-10, -10, 0, 0);
        for (int i = 0; i < alt; i++) {
            int lin = alt - 1 - i;                      // o texto cresce para baixo; o mundo, para cima
            for (int col = 0; col < larg; col++) {
                switch (linhas[i].charAt(col)) {
                    case '#' -> solido[col][lin] = true;
                    case 'H' -> inicio.set(col + (1 - Heroi.LARGURA) / 2, lin);
                    case 'o' -> moedas.add(new Rectangle(col + 0.25f, lin + 0.25f, 0.5f, 0.5f));
                    case 'B' -> bandeira.set(col + 0.3f, lin, 0.4f, 2f);
                    default -> { }
                }
            }
        }
        Grade grade = new Grade() {
            public int colunas() { return larg; }
            public int linhas() { return alt; }
            public boolean solidoNaCelula(int c, int l) { return solido[c][l]; }
        };
        return new Fase(grade, inicio, moedas, bandeira);
    }
}

Do Tiled para a Fase

A segunda porta de entrada lê um TiledMap carregado pelo libGDX:

package br.com.exemplo.plataforma;

import com.badlogic.gdx.maps.MapObject;
import com.badlogic.gdx.maps.objects.RectangleMapObject;
import com.badlogic.gdx.maps.tiled.TiledMap;
import com.badlogic.gdx.maps.tiled.TiledMapTileLayer;
import com.badlogic.gdx.math.Rectangle;
import com.badlogic.gdx.math.Vector2;

import java.util.ArrayList;
import java.util.List;

/** Traduz um mapa do Tiled para a Fase: a camada "chao" vira a grade; a camada "objetos", o resto. */
public final class FaseDoTiled {
    private FaseDoTiled() {}

    public static Fase ler(TiledMap mapa) {
        var chao = (TiledMapTileLayer) mapa.getLayers().get("chao");
        float tile = chao.getTileWidth();                         // pixels por tile (16): o mapa vem em pixels
        Grade grade = new Grade() {
            public int colunas() { return chao.getWidth(); }
            public int linhas() { return chao.getHeight(); }
            public boolean solidoNaCelula(int c, int l) { return chao.getCell(c, l) != null; }
        };

        Vector2 inicio = new Vector2(1, 2);
        List<Rectangle> moedas = new ArrayList<>();
        Rectangle bandeira = new Rectangle(-10, -10, 0, 0);
        for (MapObject obj : mapa.getLayers().get("objetos").getObjects()) {
            if (!(obj instanceof RectangleMapObject r)) continue;
            // de pixels para metros (tiles); o carregador já inverteu o y para "para cima"
            Rectangle ret = new Rectangle(r.getRectangle().x / tile, r.getRectangle().y / tile,
                    r.getRectangle().width / tile, r.getRectangle().height / tile);
            // identificamos pelo NOME: o libGDX lê o atributo "type", mas o Tiled 1.9 gravava "class"
            switch (String.valueOf(obj.getName())) {
                case "inicio" -> inicio.set(ret.x, ret.y);
                case "moeda" -> moedas.add(ret);
                case "bandeira" -> bandeira.set(ret);
                default -> { }
            }
        }
        return new Fase(grade, inicio, moedas, bandeira);
    }
}

Duas armadilhas estão registradas nos comentários:

  • As unidades. Os objetos vêm em pixels e precisam ser divididos pelo tamanho do tile.
  • O "tipo" do objeto. O Tiled grava o tipo de um objeto no atributo type, menos na versão 1.9, que passou a gravar class; a 1.10 voltou ao type, para ser compatível com as versões anteriores. O carregador do libGDX só lê type: num mapa salvo pelo Tiled 1.9, os objetos chegam sem tipo, e não há erro nenhum. Identificar os objetos pelo nome funciona em qualquer versão.

A casca gráfica: mapa, câmera e passo fixo

A classe do jogo junta tudo o que já sabemos:

  • o passo fixo com acumulador e interpolação, de "Um Novo Território — Java nos Jogos, o Game Loop e o Caso Minecraft";
  • o FitViewport em metros, de "O Primeiro Sprite — Janela, Desenho e Input com libGDX";
  • duas novidades: o desenhista de mapas e a câmera que segue o herói.
package br.com.exemplo.plataforma;

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.maps.tiled.TiledMap;
import com.badlogic.gdx.maps.tiled.TmxMapLoader;
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;

/** A casca gráfica do plataforma: mapa do Tiled, câmera que segue o herói, passo fixo. */
public class Plataforma extends ApplicationAdapter {
    static final float PASSO = 1f / 60f;
    static final float VISTA_LARG = 20f, VISTA_ALT = 11.25f;       // quanto do mundo aparece (em tiles)

    private final String arquivoDoMapa;
    private TiledMap mapa;
    private OrthogonalTiledMapRenderer desenhistaDoMapa;
    private Fase fase;
    private OrthographicCamera camera;
    private FitViewport vista;
    private ScreenViewport vistaHud;
    private SpriteBatch lote;
    private BitmapFont fonte;
    private Texture texHeroi, texMoeda, texPixel;
    private float acumulado;
    private boolean puloPendente;
    private int fotos;

    public Plataforma(String arquivoDoMapa) { this.arquivoDoMapa = arquivoDoMapa; }

    @Override
    public void create() {
        mapa = new TmxMapLoader().load(arquivoDoMapa);
        desenhistaDoMapa = new OrthogonalTiledMapRenderer(mapa, 1 / 16f);   // 16 px por tile → 1 unidade
        fase = FaseDoTiled.ler(mapa);
        camera = new OrthographicCamera();
        vista = new FitViewport(VISTA_LARG, VISTA_ALT, camera);
        vistaHud = new ScreenViewport();
        lote = new SpriteBatch();
        fonte = new BitmapFont();
        texHeroi = quadrado(Color.valueOf("e53935"));
        texMoeda = circulo(Color.GOLD);
        texPixel = quadrado(Color.WHITE);
        camera.position.set(fase.heroi.posicao.x, VISTA_ALT / 2, 0);

        Gdx.input.setInputProcessor(new InputAdapter() {
            @Override
            public boolean keyDown(int tecla) {
                if (tecla == Keys.SPACE || tecla == Keys.UP || tecla == Keys.W) puloPendente = true;   // evento
                if (tecla == Keys.F12) fotografar();
                if (tecla == Keys.ESCAPE) Gdx.app.exit();
                return true;
            }
        });
    }

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

    @Override
    public void render() {
        float dt = Math.min(Gdx.graphics.getDeltaTime(), 0.25f);
        acumulado += 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;                                    // o evento vale para UM passo
            acumulado -= PASSO;
        }
        float alfa = 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);

        seguirComACamera(hx, hy, dt);

        ScreenUtils.clear(Color.valueOf("87ceeb"));                  // céu
        vista.apply();
        desenhistaDoMapa.setView(camera);
        desenhistaDoMapa.render();                                   // só os tiles visíveis são desenhados

        lote.setProjectionMatrix(camera.combined);
        lote.begin();
        for (Rectangle m : fase.moedas) lote.draw(texMoeda, m.x, m.y, m.width, m.height);
        Rectangle b = fase.bandeira;
        lote.setColor(Color.DARK_GRAY);
        lote.draw(texPixel, b.x + b.width / 2 - 0.05f, b.y, 0.1f, b.height);        // mastro
        lote.setColor(Color.WHITE);
        lote.draw(texPixel, b.x + b.width / 2, b.y + b.height - 0.6f, 0.8f, 0.5f);   // bandeira
        lote.setColor(Color.WHITE);
        lote.draw(texHeroi, hx, hy, Heroi.LARGURA, Heroi.ALTURA);
        lote.end();

        vistaHud.apply();
        lote.setProjectionMatrix(vistaHud.getCamera().combined);
        lote.begin();
        String msg = fase.estado == Fase.Estado.VENCEU ? "   CHEGOU!" : "";
        fonte.draw(lote, "moedas: %d/%d   quedas: %d%s".formatted(fase.moedasPegas, fase.totalDeMoedas, fase.quedas, msg),
                10, vistaHud.getScreenHeight() - 10);
        lote.end();
    }

    /** A câmera persegue o herói com suavidade, mas nunca mostra além das bordas do mapa. */
    private void seguirComACamera(float hx, float hy, float dt) {
        float suavidade = 1 - (float) Math.exp(-6 * dt);             // independente da taxa de quadros
        camera.position.x += (hx + Heroi.LARGURA / 2 - camera.position.x) * suavidade;
        camera.position.y += (hy + 2 - camera.position.y) * suavidade;
        float larguraDoMapa = fase.grade.colunas(), alturaDoMapa = fase.grade.linhas();
        camera.position.x = MathUtils.clamp(camera.position.x, VISTA_LARG / 2, larguraDoMapa - VISTA_LARG / 2);
        camera.position.y = MathUtils.clamp(camera.position.y, VISTA_ALT / 2, Math.max(VISTA_ALT / 2, alturaDoMapa - VISTA_ALT / 2));
        camera.update();
    }

    private static Texture quadrado(Color cor) {
        var p = new Pixmap(8, 8, Pixmap.Format.RGBA8888);
        p.setColor(cor);
        p.fill();
        var t = new Texture(p);
        p.dispose();
        return t;
    }

    private static Texture circulo(Color cor) {
        var p = new Pixmap(16, 16, Pixmap.Format.RGBA8888);
        p.setColor(cor);
        p.fillCircle(8, 8, 7);
        var t = new Texture(p);
        p.dispose();
        return t;
    }

    private void fotografar() {
        Pixmap tela = Pixmap.createFromFrameBuffer(0, 0, Gdx.graphics.getBackBufferWidth(), Gdx.graphics.getBackBufferHeight());
        PixmapIO.writePNG(Gdx.files.local("plataforma-" + (++fotos) + ".png"), tela, -1, true);
        tela.dispose();
        Gdx.app.log("Plataforma", "foto %d: herói em %s, moedas %d/%d, estado %s".formatted(
                fotos, fase.heroi.posicao, fase.moedasPegas, fase.totalDeMoedas, fase.estado));
    }

    @Override
    public void dispose() {
        mapa.dispose();                                              // também libera as texturas do tileset
        desenhistaDoMapa.dispose();
        lote.dispose();
        fonte.dispose();
        texHeroi.dispose();
        texMoeda.dispose();
        texPixel.dispose();
    }
}

Três detalhes:

  • O pulo é lido de dois jeitos. Apertar é um evento (keyDown), porque uma consulta por quadro perderia um toque rápido que coubesse entre dois quadros. Segurar é consultado a cada quadro (isKeyPressed). O evento vale para um passo e é zerado em seguida.
  • Câmera suave independente da taxa de quadros. Fazer "anda 10% da distância a cada quadro" deixa a câmera duas vezes mais rápida a 120 Hz do que a 60 Hz. O fator 1 − e^(−6·dt) dá o mesmo movimento em qualquer taxa: a cada segundo, a distância que falta cai para e⁻⁶ ≈ 0,25% do que era.
  • mapa.dispose() libera as texturas do tileset. O mapa é dono delas.

O lançador para desktop segue o padrão da aula do primeiro sprite:

package br.com.exemplo.plataforma.lwjgl3;

import br.com.exemplo.plataforma.Plataforma;
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("Plataforma");
        cfg.setWindowedMode(960, 540);
        cfg.useVsync(true);
        cfg.setForegroundFPS(Lwjgl3ApplicationConfiguration.getDisplayMode().refreshRate + 1);
        new Lwjgl3Application(new Plataforma("fase1.tmx"), cfg);   // fase1.tmx e terreno.png em assets/
    }
}

Testando o "controle gostoso"

O controle de um plataforma parece coisa de sensação, mas cada sensação é uma regra, e regras se testam. Como a Fase não depende de GPU, os testes rodam em JUnit comum:

package br.com.exemplo.plataforma;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

/** O "controle gostoso" de um plataforma, transformado em regras verificáveis. */
class HeroiTest {
    static final float PASSO = 1f / 60f;

    static void passos(Fase f, int n, Comandos c) { for (int i = 0; i < n; i++) f.atualizar(PASSO, c); }

    static final String[] PLANICIE = {
            "..............................",
            "..............................",
            "..............................",
            "..............................",
            "......H.......................",
            "..............................",
            "##############################",
    };

    @Test
    void caiEParaExatamenteEmCimaDoChao() {
        Fase f = Fase.deTexto(PLANICIE);
        passos(f, 120, Comandos.NADA);
        assertTrue(f.heroi.noChao());
        assertEquals(1f, f.heroi.posicao.y, 1e-6f, "o topo do chão é y = 1");
    }

    @Test
    void naoAtravessaOChaoMesmoCaindoDeMuitoAlto() {
        String[] torre = new String[200];
        java.util.Arrays.fill(torre, "...");
        torre[0] = ".H.";
        torre[199] = "###";                                   // 198 m de queda
        Fase f = Fase.deTexto(torre);
        passos(f, 60 * 20, Comandos.NADA);
        assertEquals(1f, f.heroi.posicao.y, 1e-6f);
        assertEquals(0, f.quedas, "velocidade terminal: nunca pula mais de 1 tile por passo");
    }

    @Test
    void puloCompletoAlcancaAAlturaEsperadaSempreIgual() {
        float[] alturas = new float[2];
        for (int rodada = 0; rodada < 2; rodada++) {
            Fase f = Fase.deTexto(PLANICIE);
            passos(f, 120, Comandos.NADA);
            f.atualizar(PASSO, new Comandos(0, true, true));
            float max = 0;
            for (int i = 0; i < 90; i++) { f.atualizar(PASSO, new Comandos(0, false, true)); max = Math.max(max, f.heroi.posicao.y - 1); }
            alturas[rodada] = max;
        }
        assertEquals(alturas[0], alturas[1], 0f, "determinístico: mesmo pulo, mesma altura");
        // Euler semi-implícito com passo fixo: a altura exata é v0²/2g − v0·dt/2
        float esperado = Heroi.alturaTeoricaDoPulo() - Heroi.PULO * PASSO / 2;
        System.out.printf("pulo completo: %.4f m (contínuo: %.4f m; discreto: %.4f m)%n",
                alturas[0], Heroi.alturaTeoricaDoPulo(), esperado);
        assertEquals(esperado, alturas[0], 0.001f);
    }

    /** Altura máxima de um pulo em que o botão fica segurado por "passosSegurando" passos. */
    static float alturaDoPulo(int passosSegurando) {
        Fase f = Fase.deTexto(PLANICIE);
        passos(f, 120, Comandos.NADA);
        f.atualizar(PASSO, new Comandos(0, true, true));
        float max = 0;
        for (int i = 0; i < 120; i++) {
            f.atualizar(PASSO, new Comandos(0, false, i < passosSegurando));
            max = Math.max(max, f.heroi.posicao.y - 1);
        }
        return max;
    }

    @Test
    void soltarOBotaoCedoDaUmPuloMaisBaixo() {
        float completo = alturaDoPulo(1000), curto = alturaDoPulo(5), medio = alturaDoPulo(15);
        System.out.printf("segurando 6 passos: %.4f m | 16 passos: %.4f m | até o fim: %.4f m%n", curto, medio, completo);
        assertTrue(curto < medio && medio < completo, "quanto mais segura, mais alto");
        assertTrue(curto < 0.65f * completo, "o toque rápido é bem mais baixo que o pulo completo");
    }

    static final String[] PENHASCO = {
            "..........",
            ".H........",
            "####......",
            "####......",
    };

    /** Anda até sair da borda e aperta pulo alguns passos DEPOIS de já estar no ar. */
    static boolean pulaDepoisDeSairDaBorda(int passosNoAr) {
        Fase f = Fase.deTexto(PENHASCO);
        passos(f, 30, Comandos.NADA);
        int guarda = 0;
        while (f.heroi.noChao() && guarda++ < 200) f.atualizar(PASSO, new Comandos(1, false, false));
        passos(f, passosNoAr - 1, new Comandos(1, false, false));
        f.atualizar(PASSO, new Comandos(1, true, true));
        return f.heroi.velocidade.y > 5f;
    }

    @Test
    void coyoteTimeDeixaPularLogoDepoisDeSairDaBorda() {
        assertTrue(pulaDepoisDeSairDaBorda(4), "4 passos (67 ms) no ar: ainda vale");
        assertFalse(pulaDepoisDeSairDaBorda(9), "9 passos (150 ms) no ar: tarde demais");
    }

    @Test
    void puloApertadoUmPoucoAntesDeTocarOChaoAconteceAoTocar() {
        Fase f = Fase.deTexto(PLANICIE);
        passos(f, 120, Comandos.NADA);
        f.atualizar(PASSO, new Comandos(0, true, true));        // primeiro pulo
        while (f.heroi.velocidade.y > -5f) f.atualizar(PASSO, new Comandos(0, false, true));
        while (f.heroi.posicao.y - 1 > 0.6f) f.atualizar(PASSO, Comandos.NADA);   // descendo, perto do chão
        f.atualizar(PASSO, new Comandos(0, true, true));        // aperta ANTES de tocar
        assertFalse(f.heroi.noChao());
        boolean pulouDeNovo = false;
        for (int i = 0; i < 10 && !pulouDeNovo; i++) {
            f.atualizar(PASSO, new Comandos(0, false, true));
            pulouDeNovo = f.heroi.velocidade.y > 5f;
        }
        assertTrue(pulouDeNovo, "o buffer guardou o pulo até o pouso");
    }

    @Test
    void paredeSeguraOHeroi() {
        Fase f = Fase.deTexto(
                "..........",
                ".H....#...",
                "##########");
        passos(f, 120, new Comandos(1, false, false));
        assertEquals(6f - Heroi.LARGURA, f.heroi.posicao.x, 1e-5f, "encostado na parede da coluna 6");
    }

    @Test
    void moedasEBandeira() {
        Fase f = Fase.deTexto(
                "............",
                ".H..o..o..B.",
                "############");
        passos(f, 240, new Comandos(1, false, false));
        assertEquals(2, f.moedasPegas);
        assertEquals(Fase.Estado.VENCEU, f.estado);
    }

    @Test
    void cairNoBuracoVoltaAoInicio() {
        Fase f = Fase.deTexto(
                ".H.......",
                "###...###");
        passos(f, 20, Comandos.NADA);
        int guarda = 0;
        while (f.quedas == 0 && guarda++ < 600) f.atualizar(PASSO, new Comandos(1, false, false));
        assertEquals(1, f.quedas);
    }
}

Os 9 testes passaram em 176 ms, e dois deles imprimem números que valem uma explicação:

pulo completo: 2.3333 m (contínuo: 2.4500 m; discreto: 2.3333 m)
segurando 6 passos: 1.3833 m | 16 passos: 2.2389 m | até o fim: 2.3333 m

A fórmula do ensino médio diz que um pulo com v₀ = 14 m/s e g = 40 m/s² sobe v₀²/2g = 2,45 m. O jogo sobe 2,3333 m, sempre. A diferença não é erro: é o integrador. Com o Euler semi-implícito, que primeiro atualiza a velocidade e depois a posição, cada passo subtrai a gravidade antes de mover, e a soma exata dá v₀²/2g − v₀·dt/2 = 2,45 − 0,1167 = 2,3333 m. O teste confere esse valor com tolerância de um milímetro. Na prática, isso significa que o level design precisa usar a altura discreta: uma plataforma a 2,4 m de altura parece alcançável pela conta do papel, mas nunca será.

O teste do pulo curto também me corrigiu. Na primeira versão eu exigia que o toque de 6 passos subisse menos de 1,2 m, e ele sobe 1,3833 m: a velocidade só é cortada quando o botão é solto, e até lá o herói já subiu um pedaço. O limite absoluto estava errado. O teste final compara as alturas entre si: mais tempo segurando dá um pulo mais alto, e o toque rápido fica abaixo de 65% do pulo completo.

Uma fase que dá para vencer

A primeira fase que desenhei tinha buracos de 5 tiles. Parecia razoável. Para conferir, escrevi um "piloto": um robô que segura a direita e pula sempre que há buraco ou parede logo à frente, mantendo o pulo por 24 passos. O piloto caiu 38 vezes e não terminou a fase. Com 7 m/s de velocidade máxima e pouco mais de 0,7 s no ar, sobra pouca margem para 5 tiles quando o pulo sai um pouco antes ou depois da borda.

Refiz a fase com buracos de 2 a 4 tiles, e o piloto a venceu em 7,4 s, com 3 das 9 moedas e nenhuma queda:

......................................................
......................................................
......................................................
......................................................
....................o.o.o.............................
...................#######..............o.o...........
...........o...........................#####.......B..
..........###.......................................##
.H....................o.......o..........o.........###
######....#####...#######..######...#######..#########
######....#####...#######..######...#######..#########
######....#####...#######..######...#######..#########

Depois rodei o jogo de verdade, em janela, com as teclas vindas de um roteiro (e não do teclado) com os mesmos instantes de pulo que o piloto usou (0,62 s, 2,03 s, 3,47 s, …). As fotos da tecla F12, tiradas com 1 s, com 4,5 s e depois da chegada, registraram:

[Plataforma] foto 1: herói em (7.6347284,5.2999997), moedas 0/9, estado JOGANDO
[Plataforma] foto 2: herói em (31.301413,3.0), moedas 2/9, estado JOGANDO
[Plataforma] foto 3: herói em (50.649998,5.022222), moedas 3/9, estado VENCEU

A janela chega à bandeira no mesmo ponto que o piloto dos testes, com as mesmas 3 moedas.

A lição vale para qualquer jogo: teste se a fase pode ser vencida. Um robô simples não substitui um jogador, mas pega na hora o buraco que ninguém consegue pular.

Nesta aula, cada moeda pega foi removida de uma lista com removeIf, e cada passo criou um Comandos novo. Um jogo com milhares de partículas faz isso milhares de vezes por segundo, e quanto essas alocações custam de verdade, ou se o coletor de lixo atrapalha, é pergunta que só se responde medindo.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Troque PULO para 16 m/s, mantendo a gravidade em −40 m/s². Qual altura o teste puloCompletoAlcancaAAlturaEsperadaSempreIgual vai imprimir, nas duas versões, contínua e discreta? Uma plataforma a 3,1 m passa a ser alcançável?

Ver resposta

✓ Resposta: A altura contínua passa a ser 16²/(2·40) = 256/80 = 3,2 m. A discreta é 3,2 − 16·(1/60)/2 = 3,2 − 0,1333 = 3,0667 m. A plataforma a 3,1 m continua fora de alcance, apesar de a conta do papel dizer que sobra folga. É exatamente por isso que o teste usa a fórmula discreta: o que vale é a altura do jogo, e não a do livro de física.

Exercício 2

Por que o herói resolve a colisão primeiro em X e depois em Y, em vez de mover os dois eixos juntos e depois "desempurrar"? Descreva o que daria errado com o herói caindo em diagonal sobre a quina de um bloco.

Ver resposta

✓ Resposta: Movendo os dois eixos de uma vez, a caixa que termina dentro do bloco pela quina está ao mesmo tempo "dentro pela lateral" e "dentro por cima", e o código precisa adivinhar por onde ela entrou. Se adivinhar lateral, o herói é empurrado para fora do bloco e escorrega pela beirada em vez de pousar. Se adivinhar em cima, ele pode subir numa parede que só roçou. Separando os eixos, cada movimento tem uma direção só: em X, a única correção possível é lateral, e em Y, só vertical. A quina deixa de ser ambígua.

Exercício 3

Suponha que alguém remova o limite QUEDA_MAXIMA. Qual teste deve falhar, e por quê? Faça a conta da velocidade depois de 198 m de queda.

Ver resposta

✓ Resposta: Deve falhar o naoAtravessaOChaoMesmoCaindoDeMuitoAlto. Sem limite, a velocidade após 198 m é √(2·40·198) ≈ 125,9 m/s, o que dá cerca de 2,1 m por passo de 1/60 s. O moverY só confere a linha onde os pés terminam o passo. Se o herói estava em y = 1,5 e termina em y = −0,6, ele confere a linha −1, que fica abaixo do mapa e não é sólida, e o chão de um bloco de espessura, na linha 0, é pulado. É o túnel. Rodando, é o que acontece: o teste falha com expected: <1.0> but was: <182.5>, porque o herói atravessa o chão, cai abaixo de −3, volta ao início e recomeça a queda.

Exercício 4

Um colega marcou as moedas no Tiled 1.9 usando o campo "Class" = moeda, deixou o nome vazio, e o FaseDoTiled não encontrou nenhuma. Sem mudar a versão do libGDX, dê duas soluções.

Ver resposta

✓ Resposta: O Tiled 1.9 grava esse campo no atributo class, e o carregador do libGDX só lê type. A primeira solução é a desta aula: preencher o nome do objeto no editor e identificar por obj.getName(). A segunda é usar uma propriedade personalizada (Custom Property) do Tiled, por exemplo tipo = moeda, que o libGDX carrega para o MapProperties do objeto, e ler obj.getProperties().get("tipo", String.class). Uma terceira é atualizar o Tiled: da 1.10 em diante ele voltou a gravar type, que o libGDX coloca nas propriedades do objeto, e o código pode ler obj.getProperties().get("type", String.class). As duas primeiras funcionam em qualquer versão.

Exercício 5

Troque a suavização da câmera por camera.position.x += (alvoX − camera.position.x) * 0.1f;. Compare o comportamento num monitor de 30 Hz e num de 144 Hz.

Ver resposta

✓ Resposta: Com o fator fixo por quadro, em 1 segundo a distância que falta é multiplicada por 0,9³⁰ ≈ 0,042 a 30 Hz e por 0,9¹⁴⁴ ≈ 0,00000026 a 144 Hz. No monitor rápido a câmera fica praticamente colada no herói, e no lento ela se arrasta bem atrás. Com 1 − e^(−6·dt), o que falta é multiplicado por e^(−6·dt) a cada quadro, e o produto em 1 segundo dá e⁻⁶ ≈ 0,0025 em qualquer taxa. O jogo fica igual para todos os jogadores.

Comentários

Mais em Java

Iguais ou o Mesmo? — equals, hashCode e Identidade
Iguais ou o Mesmo? — equals, hashCode e Identidade

Identidade e igualdade em Java: o contrato do equals, Double.compare, hashCode…

O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets
O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets

Começa o projeto final: o que trafega entre navegador e servidor no HTTP/1.1…

Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes
Ferramentas de Precisão — Locks, Atomics e Coleções Concorrentes

Quando o synchronized pesa demais, o java.util.concurrent oferece peças sob…