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

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

Nesta aula você conhece a história do Java nos jogos, do applet de navegador ao jogo mais vendido de todos os tempos, entende por que um jogo não é um aplicativo comum e escreve o coração de qualquer jogo: um game loop com passo fixo, que produz a mesma física em qualquer máquina.
Java

• • 21 min de leitura

Nesta aula você conhece a história do Java nos jogos, do applet de navegador ao jogo mais vendido de todos os tempos, entende por que um jogo não é um aplicativo comum e escreve o coração de qualquer jogo: um game loop com passo fixo, que produz a mesma física em qualquer máquina.

Em todas as interfaces das últimas aulas, o programa passava a maior parte do tempo esperando. A thread da interface dormia até chegar um clique, uma tecla, uma resposta da API; tratava o evento e voltava a dormir. Um jogo inverte essa lógica. O mundo se move com ou sem o jogador: o inimigo avança, a água corre, o dia vira noite. O programa não espera eventos: ele roda um laço sem fim, dezenas de vezes por segundo, calculando o próximo instante do mundo e desenhando-o na tela. Esse laço é o game loop, e fazê-lo direito é mais sutil do que parece. É por ele, e pela história que levou o Java até ele, que começa a Trilha C.

Java nos jogos: uma história de altos e baixos

O Java chegou aos jogos pela porta do navegador. No fim dos anos 1990 e início dos 2000, os applets eram a forma de rodar programas interativos numa página web, e sites de jogos casuais se encheram deles. O caso mais duradouro é o RuneScape, lançado em 2001 como um applet Java, que se tornou um dos maiores jogos multijogador on-line da década. Os applets morreram com o fim dos plugins nos navegadores, e seu papel passou para o JavaScript e o WebAssembly.

A segunda onda foi a dos celulares. Antes dos smartphones, o Java ME (a versão móvel do Java, antes chamada J2ME) rodava em centenas de milhões de telefones da Nokia, da Motorola e da Sony Ericsson. Os jogos em Java ME eram, para muita gente no Brasil dos anos 2000, o primeiro contato com jogos no celular: arquivos .jar minúsculos, baixados por conexões lentíssimas. O iPhone e o Android encerraram essa era por volta de 2010, mas os jogos Android das primeiras gerações continuaram sendo escritos, em grande parte, na linguagem Java.

A terceira onda é a das bibliotecas e motores para desktop:

  • A LWJGL (Lightweight Java Game Library, de 2002) dá ao Java acesso direto a OpenGL, Vulkan, OpenAL e aos controles, que são as APIs nativas dos jogos.
  • O jMonkeyEngine (2003) é um motor 3D completo.
  • O libGDX (2009), criado por Mario Zechner, é um framework multiplataforma que compila o mesmo jogo para desktop, Android, iOS e web.

Jogos comerciais bem-sucedidos foram feitos com essas ferramentas. Slay the Spire (2019), um dos jogos independentes mais influentes da década, foi feito com libGDX. Mindustry e Delver também. Project Zomboid e Starsector são escritos em Java.

E por que o Java nunca dominou os grandes jogos de console? Três motivos, com pesos diferentes ao longo do tempo:

  • Consoles não rodam JVMs. PlayStation, Xbox e Nintendo exigem código nativo aprovado. Hoje isso é contornável com compilação antecipada, como o GraalVM Native Image de "Do JAR ao Executável — jlink, jpackage e GraalVM Native Image", mas por muito tempo foi um muro.
  • O coletor de lixo. A 60 quadros por segundo, cada quadro tem 16,6 ms. Uma pausa de coleta de 50 ms, comum nos coletores dos anos 2000, é uma engasgada visível. Os coletores modernos, como o ZGC de "Quem Recolhe o Lixo — os Coletores G1 e ZGC", pausam menos de um milissegundo, mas a reputação ficou. Veremos em "Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos" como os jogos em Java convivem com o coletor.
  • O ecossistema. A indústria consolidou-se em C++ (Unreal) e C# (Unity), com ferramentas, profissionais e cursos em volta. Motores novos, como o Godot, seguiram o mesmo caminho.

E ainda assim o jogo mais vendido da história foi escrito em Java.

O caso Minecraft

Em maio de 2009, o programador sueco Markus Persson, conhecido como Notch, publicou num fórum a primeira versão de um jogo de construção com blocos. Escrito em Java, com a LWJGL para os gráficos, ele rodava até no navegador, como applet. A versão 1.0 do Minecraft saiu em novembro de 2011. Em 2014 a Microsoft comprou a Mojang, a empresa de Persson, por 2,5 bilhões de dólares. Com mais de 300 milhões de cópias vendidas, o Minecraft é o videogame mais vendido de todos os tempos.

Consoles e celulares, onde a JVM não chega, ganharam outra versão. A própria Mojang já tinha reescrito o jogo em C++ em 2011, na Pocket Edition para celulares, e foi sobre esse código que a Microsoft unificou, em 2017, a Bedrock Edition, que roda em consoles, celulares e Windows. Mas a versão original não foi abandonada. A Java Edition continua viva e é a preferida de boa parte da comunidade no PC, por uma razão que diz muito sobre a plataforma Java: os mods. Bytecode é fácil de inspecionar e de transformar em tempo de carga, com as técnicas de "O Programa que se Examina — Anotações e Reflexão", só que levadas muito mais longe. Carregadores de mods como Forge, Fabric e NeoForge, e bibliotecas como a Mixin, injetam código nas classes do jogo enquanto elas são carregadas. Milhares de mods alteram tudo, da física à interface. Servidores multijogador, como o Paper, também são escritos em Java.

A história continua atual, e os dois fatos mais recentes interessam diretamente a este curso:

  • Java 25. Em março de 2026, a versão 26.1 da Java Edition passou a exigir o Java 25, a mesma versão LTS usada no curso inteiro.
  • Código sem ofuscação. Foi também a primeira versão distribuída sem ofuscação. Até então, os nomes de classes e métodos eram embaralhados no JAR publicado, e a comunidade precisava de mapeamentos para entender o código. A Mojang decidiu entregar os nomes reais, admitindo que o jogo tinha virado uma plataforma de programação.

Há ainda uma lição de arquitetura no Minecraft, e ela é exatamente o assunto desta aula. A lógica do jogo roda num ritmo fixo de 20 ticks por segundo, um a cada 50 ms. A cada tick, as plantas crescem, os monstros andam, a água escorre e as máquinas de pedra vermelha avançam um passo. O desenho, por outro lado, roda tão rápido quanto a placa de vídeo permitir (60, 144, 240 quadros por segundo) e interpola as posições entre um tick e outro. Desde 2012, até o modo de um jogador roda um "servidor integrado" separado do cliente que desenha. Com isso, um mecanismo de pedra vermelha montado num notebook fraco funciona exatamente igual num computador potente, e um servidor pode simular o mundo sem desenhar nada. Para entender por que essa separação é tão importante, é preciso ver o que acontece sem ela.

O problema: física que depende do computador

O game loop mais ingênuo é este: while (true) { atualizar(); desenhar(); }. O jogo anda mais rápido em computadores mais rápidos. Jogos dos anos 1980 e 1990 feitos assim ficam impossíveis de jogar em máquinas modernas.

A primeira correção que vem à cabeça é o passo variável: medir quanto tempo o quadro levou (dt) e avançar o mundo exatamente esse tempo. Um personagem a 5 m/s anda 5 * dt metros por quadro, e em teoria chega ao mesmo lugar em qualquer máquina. Na teoria, sim. Na prática, a física dos jogos é aproximada: avança em pequenos saltos (integração numérica), e o tamanho do salto muda o resultado. O programa abaixo simula o mesmo pulo em máquinas diferentes:

import java.util.Locale;
import java.util.Random;

/**
 * O mesmo pulo, simulado em máquinas com taxas de quadros diferentes.
 * Passo variável: a física avança "o tempo que o quadro levou".
 * Passo fixo: a física sempre avança 1/60 s; o quadro só decide QUANTOS passos dar.
 */
static final double GRAVIDADE = -30.0;          // m/s²
static final double IMPULSO = 12.0;             // m/s para cima
static final double PASSO = 1.0 / 60;           // o passo fixo

/** Estado mínimo de um personagem pulando. */
static final class Personagem {
    double y, vy = IMPULSO, alturaMaxima;

    /** Euler semi-implícito: primeiro a velocidade, depois a posição (o mais comum em jogos). */
    void avancar(double dt) {
        vy += GRAVIDADE * dt;
        y += vy * dt;
        alturaMaxima = Math.max(alturaMaxima, y);
    }
}

/** Durações de quadro: fixas (fps) ou com "tremida" de até ±50%, como numa máquina de verdade. */
double[] quadros(double fps, boolean tremido, int n) {
    var r = new Random(42);
    double[] d = new double[n];
    for (int i = 0; i < n; i++) d[i] = (1.0 / fps) * (tremido ? 0.5 + r.nextDouble() : 1.0);
    return d;
}

double pularComPassoVariavel(double[] duracoes) {
    var p = new Personagem();
    for (double dt : duracoes) {
        p.avancar(dt);
        if (p.y < 0) break;
    }
    return p.alturaMaxima;
}

double pularComPassoFixo(double[] duracoes) {
    var p = new Personagem();
    double acumulado = 0;
    for (double dt : duracoes) {
        acumulado += Math.min(dt, 0.25);        // trava contra a "espiral da morte"
        while (acumulado >= PASSO) {            // quantos passos de 1/60 s cabem neste quadro?
            p.avancar(PASSO);
            acumulado -= PASSO;
            if (p.y < 0) return p.alturaMaxima;
        }
    }
    return p.alturaMaxima;
}

void main() {
    IO.println("Altura máxima do pulo (a física exata diz 12²/(2·30) = 2,4000 m)");
    IO.println("%-22s %14s %14s".formatted("máquina", "passo variável", "passo fixo"));
    record Maquina(String nome, double fps, boolean tremido) {}
    for (var m : new Maquina[]{
            new Maquina("notebook fraco, 20 fps", 20, false),
            new Maquina("console, 30 fps", 30, false),
            new Maquina("PC comum, 60 fps", 60, false),
            new Maquina("monitor gamer, 144 fps", 144, false),
            new Maquina("60 fps com tremidas", 60, true)}) {
        double[] d = quadros(m.fps(), m.tremido(), 2000);
        IO.println(String.format(Locale.of("pt", "BR"), "%-22s %12.4f m %12.4f m",
                m.nome(), pularComPassoVariavel(d), pularComPassoFixo(d)));
    }
}
$ java PassoFixo.java
Altura máxima do pulo (a física exata diz 12²/(2·30) = 2,4000 m)
máquina                passo variável     passo fixo
notebook fraco, 20 fps       2,1000 m       2,3000 m
console, 30 fps              2,2000 m       2,3000 m
PC comum, 60 fps             2,3000 m       2,3000 m
monitor gamer, 144 fps       2,3585 m       2,3000 m
60 fps com tremidas          2,2912 m       2,3000 m

Com passo variável, o mesmo pulo tem cinco alturas diferentes: 2,10 m no notebook fraco, 2,36 m no monitor de 144 Hz. Se o jogo tem uma plataforma a 2,25 m, o jogador do notebook não consegue alcançá-la, e o do monitor gamer alcança. A história dos jogos está cheia desses casos. No Quake III Arena, de 1999, os jogadores descobriram que, rodando a exatamente 125 quadros por segundo, pulavam mais alto, e os competidores configuravam o jogo para essa taxa. A linha com "tremidas" mostra o pior: numa máquina real, com quadros de duração irregular, o resultado muda de uma partida para outra na mesma máquina. Replays, jogos em rede sincronizados e testes automatizados ficam impossíveis.

Com passo fixo, as cinco máquinas dão exatamente o mesmo resultado: 2,3000 m. Não é a resposta exata da física (2,4 m), porque a integração em passos de 1/60 s tem um erro, mas é o mesmo erro para todo mundo. Num jogo, ser determinístico vale mais do que ser exato: o designer ajusta a plataforma para a altura que o pulo realmente alcança, e ela é a mesma para todos.

O game loop com passo fixo

A técnica tem um nome consagrado, fix your timestep, do artigo de Glenn Fiedler que se tornou referência. A ideia central é um acumulador: o tempo real que passa entra nele, e a lógica consome esse tempo em fatias de tamanho fixo. Se o quadro demorou 40 ms, cabem duas fatias de 16,6 ms, e sobram 6,8 ms para o próximo quadro. Se demorou 5 ms, não cabe nenhuma, e o quadro só desenha. O desenho, por sua vez, acontece uma vez por volta do laço, na velocidade que a máquina aguentar, e usa a sobra do acumulador para interpolar entre o penúltimo e o último estado. Sem a interpolação, um jogo com lógica a 60 Hz desenhado a 144 Hz mostraria o mesmo quadro duas ou três vezes seguidas, e o movimento pareceria engasgado.

Um loop completo em Java 2D, com um barquinho atravessando o mar entre duas ilhas, a primeira cena da Travessia que construiremos:

import javax.imageio.ImageIO;
import javax.swing.JFrame;
import java.awt.*;
import java.awt.image.BufferStrategy;
import java.io.File;

/**
 * Um game loop completo em Java 2D: atualização a 60 Hz fixos, desenho tão rápido quanto possível,
 * com interpolação entre o estado anterior e o atual. Um barquinho atravessa o mar entre duas ilhas.
 */
static final double PASSO = 1.0 / 60;

/** O estado do mundo: o barco e a posição dele no passo anterior (para interpolar). */
static final class Mundo {
    double x = 60, xAnterior = 60, velocidade = 140;   // px/s

    void atualizar(double dt) {
        xAnterior = x;
        x += velocidade * dt;
        if (x > 520 || x < 60) velocidade = -velocidade;   // chegou numa ilha: dá meia-volta
    }
}

void main(String[] args) throws Exception {
    var janela = new JFrame("Travessia — game loop");
    var tela = new Canvas();
    tela.setPreferredSize(new Dimension(640, 240));
    tela.setIgnoreRepaint(true);                  // quem desenha é o loop, não o sistema de eventos
    janela.add(tela);
    janela.pack();
    janela.setResizable(false);
    janela.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    janela.setVisible(true);
    tela.createBufferStrategy(2);                 // duas telas: desenha numa enquanto a outra aparece
    BufferStrategy buffers = tela.getBufferStrategy();

    long duracaoMs = args.length > 0 ? Long.parseLong(args[0]) : Long.MAX_VALUE;
    long renderLentoNs = args.length > 1 ? Long.parseLong(args[1]) * 1_000_000 : 0;

    var mundo = new Mundo();
    double acumulado = 0;
    long anterior = System.nanoTime(), inicio = anterior, ultimoRelatorio = anterior;
    int atualizacoes = 0, quadros = 0, ups = 0, fps = 0;

    while (System.nanoTime() - inicio < duracaoMs * 1_000_000) {
        long agora = System.nanoTime();
        acumulado += Math.min((agora - anterior) / 1e9, 0.25);   // trava a "espiral da morte"
        anterior = agora;

        // 1. Lógica: sempre em passos de 1/60 s, quantos couberem
        while (acumulado >= PASSO) {
            mundo.atualizar(PASSO);
            acumulado -= PASSO;
            atualizacoes++;
        }

        // 2. Desenho: uma vez por volta, interpolando entre o passo anterior e o atual
        double alfa = acumulado / PASSO;
        double xDesenho = mundo.xAnterior + (mundo.x - mundo.xAnterior) * alfa;
        do {
            do {
                Graphics2D g = (Graphics2D) buffers.getDrawGraphics();
                desenhar(g, xDesenho, ups, fps);
                g.dispose();
            } while (buffers.contentsRestored());
            buffers.show();
        } while (buffers.contentsLost());
        Toolkit.getDefaultToolkit().sync();
        quadros++;
        if (renderLentoNs > 0) Thread.sleep(renderLentoNs / 1_000_000);   // simula uma placa de vídeo fraca

        if (agora - ultimoRelatorio >= 1_000_000_000L) {
            ups = atualizacoes; fps = quadros; atualizacoes = 0; quadros = 0; ultimoRelatorio = agora;
        }
        Thread.onSpinWait();
    }
    if (args.length > 2) {
        var r = new Robot();
        var p = tela.getLocationOnScreen();
        ImageIO.write(r.createScreenCapture(new Rectangle(p.x, p.y, tela.getWidth(), tela.getHeight())), "png", new File(args[2]));
    }
    IO.println("último segundo: %d atualizações, %d quadros; barco em x=%.1f".formatted(ups, fps, mundo.x));
    System.exit(0);
}

void desenhar(Graphics2D g, double x, int ups, int fps) {
    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    g.setColor(new Color(0x1e6fa8)); g.fillRect(0, 0, 640, 240);                  // mar
    g.setColor(new Color(0xe9d8a6)); g.fillOval(-60, 140, 140, 140);              // ilha da esquerda
    g.fillOval(560, 140, 140, 140);                                               // ilha da direita
    g.setColor(new Color(0x2e7d4f)); g.fillOval(-30, 150, 80, 60); g.fillOval(590, 150, 80, 60);
    g.setColor(new Color(0x8b5a2b)); g.fillRoundRect((int) x, 150, 60, 18, 12, 12);  // casco
    g.setColor(Color.WHITE); g.fillPolygon(new int[]{(int) x + 28, (int) x + 28, (int) x + 52}, new int[]{110, 148, 148}, 3);
    g.setColor(Color.WHITE);
    g.setFont(new Font(Font.MONOSPACED, Font.BOLD, 14));
    g.drawString("UPS " + ups + "   FPS " + fps, 12, 22);
}

Rodei o programa duas vezes por três segundos. Na segunda vez, um atraso artificial de 40 ms em cada desenho simulou uma placa de vídeo fraca:

$ java LoopJava2D.java 3000 0
último segundo: 60 atualizações, 2690 quadros; barco em x=477.7

$ java LoopJava2D.java 3000 40
último segundo: 61 atualizações, 25 quadros; barco em x=477.7

Esse é o resultado que importa. Na primeira execução, o desenho rodou a 2690 quadros por segundo (o número depende da máquina); na segunda, a 25, uma diferença de mais de cem vezes. Mas a lógica rodou a 60 atualizações por segundo nas duas (as 61 da segunda são o acumulador compensando atrasos acumulados), e o barco terminou no mesmo lugar, x = 477,7. A máquina lenta desenha menos vezes, mas o mundo que ela desenha é o mesmo. Repetindo as execuções, uma vez o barco lento parou em x = 473,0, dois passos atrás: o laço termina por tempo, e até 40 ms da última volta ainda estavam no acumulador, sem virar passo. Muda quantos passos couberam até o fim, nunca o resultado de cada passo. Na tela aparecem o mar azul, as duas ilhas de areia com vegetação, o barquinho de vela branca e o contador "UPS 60 FPS ..." no canto.

Os detalhes do código, um a um:

  • setIgnoreRepaint(true) e BufferStrategy: renderização ativa. Nas aulas de interface, o sistema decidia quando repintar (renderização passiva). Aqui é o loop que decide, e a estratégia de dois buffers desenha o próximo quadro numa tela invisível e a troca de uma vez, sem que o jogador veja o desenho pela metade (o flicker). Os dois do/while tratam o caso raro em que o sistema descarta a memória de vídeo, por exemplo ao minimizar a janela.
  • Math.min(dt, 0.25) contra a "espiral da morte": se a lógica for mais lenta que o próprio passo, cada quadro acumula mais tempo do que consegue consumir, o próximo quadro tem ainda mais passos para dar, e o jogo trava de vez. A trava limita o tempo que entra no acumulador. Numa máquina lenta demais, o jogo fica em câmera lenta em vez de travar.
  • xAnterior e alfa: a interpolação. O mundo guarda onde o barco estava no passo anterior. O desenho mistura as duas posições conforme a fração de passo que sobrou (alfa, entre 0 e 1). É o mesmo partial tick com que o Minecraft desenha a 144 Hz um mundo que anda a 20.
  • Laço desenhado na thread principal, e não na EDT. O loop roda no main. O Swing só criou a janela. É uma exceção consciente à regra da aula de janelas, possível porque o Canvas com BufferStrategy é desenhado diretamente, sem passar pelo sistema de pintura do Swing. Motores como o libGDX fazem o mesmo: uma thread dona do laço e do desenho.

Há um problema visível no primeiro resultado: 2690 quadros por segundo são mais de 2500 quadros inúteis. A tela do monitor só mostra 60 ou 144, e o resto é energia gasta, CPU a 100% e bateria de notebook drenada. Motores de verdade sincronizam o desenho com a atualização da tela do monitor (a vsync) ou limitam a taxa de quadros. Esse é o primeiro exercício desta aula.

O mapa da Trilha C

Com o game loop entendido, o resto da trilha é o que se constrói em volta dele.

  • "O Primeiro Sprite — Janela, Desenho e Input com libGDX". Troca o Java 2D pelo libGDX, que desenha pela GPU com OpenGL, cuida do loop, da vsync e da entrada, e compila o mesmo jogo para desktop, Android e web.
  • Colisões e o primeiro jogo completo, um Asteroids. Depois, física de verdade com o Box2D, o motor que também está por trás de Angry Birds.
  • Mapas de tiles e um jogo de plataforma. Ali o passo fixo desta aula decide se o pulo alcança a plataforma.
  • "Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos". Encara de frente a reputação do Java com o coletor, medindo alocações por quadro.
  • O capstone da trilha: o jogo de plataforma completo, empacotado e exportado.

Em todas essas etapas, o laço desta aula continua presente. O libGDX o esconde atrás de um ciclo de vida, com create, render, resize e dispose, mas cada chamada ao render é exatamente uma volta dele: o acumulador, os passos fixos e o desenho continuam sendo responsabilidade de quem escreve o jogo.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O LoopJava2D desenhou 2690 quadros por segundo. Modifique o laço para limitar o desenho a no máximo 120 quadros por segundo, sem afetar as 60 atualizações da lógica.

Ver resposta

✓ Resposta: Depois de buffers.show(), calcule quanto falta para completar o intervalo de um quadro e durma esse tempo:

long alvo = 1_000_000_000L / 120;
long gasto = System.nanoTime() - agora;
if (gasto < alvo) Thread.sleep((alvo - gasto) / 1_000_000, (int) ((alvo - gasto) % 1_000_000));

e remova o Thread.onSpinWait(). A lógica não é afetada porque o acumulador recebe o tempo real que passou, dormindo ou não: se a volta durou 8,3 ms, entram 8,3 ms, e a cada duas voltas cabe um passo de 16,6 ms. O sleep não é exato (o sistema pode acordar a thread um pouco depois), e por isso motores preferem a vsync da placa de vídeo, que sincroniza com o próprio monitor.

Exercício 2

No PassoFixo, troque o Euler semi-implícito (velocidade primeiro, depois posição) pelo Euler explícito (posição primeiro, com a velocidade antiga). O que acontece com as alturas no passo fixo? E no variável?

Ver resposta

✓ Resposta: Invertendo a ordem das linhas no avancar (y += vy * dt; vy += GRAVIDADE * dt;), cada passo usa a velocidade do início do passo. Como a velocidade diminui durante a subida, isso superestima o deslocamento, e a altura máxima fica acima dos 2,4 m exatos, em vez de abaixo. No passo fixo, o valor muda, mas continua igual nas cinco máquinas: o determinismo depende do passo ser fixo, não do método de integração. No passo variável, as cinco alturas continuam diferentes entre si. A lição é que a escolha do integrador muda a precisão, e a escolha do passo muda a reprodutibilidade.

Exercício 3

O Minecraft roda a lógica a 20 ticks por segundo, bem menos que os 60 do nosso loop. Que vantagens isso traz para um jogo como ele, e que cuidado passa a ser obrigatório no desenho?

Ver resposta

✓ Resposta: Com 20 ticks, cada atualização tem 50 ms de orçamento, em vez de 16,6 ms. Isso permite simular um mundo enorme (plantações, redstone, centenas de criaturas, fluidos), e um servidor com dezenas de jogadores consegue acompanhar o ritmo. Também diminui o tráfego de rede, porque o estado é sincronizado com os clientes a cada tick. O cuidado obrigatório é a interpolação: sem ela, criaturas e projéteis andariam aos saltos de 50 ms, visíveis a olho nu, mesmo com a tela a 144 Hz. O cliente desenha cada entidade entre sua posição no tick anterior e no atual, usando a fração de tick decorrida, exatamente o alfa do nosso loop.

Exercício 4

Por que o LoopJava2D guarda xAnterior dentro do Mundo, e não calcula a posição interpolada como x + velocidade * (acumulado), "prevendo" o futuro?

Ver resposta

✓ Resposta: A previsão (extrapolação) supõe que nada vai mudar até o próximo passo, e isso quebra justamente nos momentos que importam. Quando o barco chega à ilha e inverte a velocidade, a extrapolação o desenharia dentro da ilha por alguns quadros, antes de o passo seguinte corrigi-lo. O mesmo vale para colisões, pulos e mortes: o jogador veria coisas que nunca aconteceram no mundo lógico. A interpolação entre dois estados já calculados só mostra posições pelas quais o objeto realmente passou. O custo é um atraso de até um passo (16,6 ms aqui, 50 ms no Minecraft), imperceptível na prática.

Exercício 5

Imagine um jogo multijogador em que cada máquina simula o mesmo mundo a partir das mesmas entradas dos jogadores (o chamado lockstep, comum em jogos de estratégia). Por que o passo fixo é indispensável aqui, e que outro cuidado a linha "60 fps com tremidas" do PassoFixo sugere?

Ver resposta

✓ Resposta: No lockstep, as máquinas não trocam o estado do mundo, só as entradas. Cada uma precisa chegar exatamente ao mesmo resultado, passo a passo, ou os mundos divergem (as unidades de um jogador aparecem em lugares diferentes na tela do outro). Com passo variável, cada máquina integraria com dt diferentes e divergiria em segundos, como mostram as alturas diferentes da tabela. A linha "com tremidas" mostra que nem a mesma máquina repete o resultado com passo variável. O cuidado adicional é que o passo fixo garante o mesmo número de passos, mas os próprios cálculos também precisam ser idênticos em todas as máquinas: sem depender da ordem de iteração de um HashMap, sem geradores aleatórios com sementes diferentes e com atenção à aritmética de ponto flutuante. Por isso muitos jogos de estratégia usam aritmética de ponto fixo, com inteiros, na lógica.

Comentários

Mais em Java

Do Geral ao Específico — Herança e a Classe Object
Do Geral ao Específico — Herança e a Classe Object

Herança em Java: extends, a chamada a super e a ordem de construção…

Extensão — O Banco que Evolui: Spring Data e Migrations com Flyway
Extensão — O Banco que Evolui: Spring Data e Migrations com Flyway

Um esquema de banco muda ao longo da vida de um sistema, e os repositórios se…

Ponte para o Nativo — a Foreign Function & Memory API
Ponte para o Nativo — a Foreign Function & Memory API

Como chamar funções em C a partir do Java com a Foreign Function & Memory API…