Registrarse

[Otros] [Java] Desarrollo de un game loop [código completo :)]

enanogm

Usuario antiguo de Wah
Buenas!

Les comparto algo de lo que estoy usando ahora mismo en uno de mis proyectos:
Para aquellos que estén aprendiendo programación o que ya conozcan el tema, y quieran intentar practicar haciendo un juego (lo que es justo la manera difícil pero divertida, claro, por eso estamos aquí) les dejo por aquí el código necesario en Java para hacer andar la parte básica del motor de juego: la clase que genera el loop principal y ejecuta tanto las actualizaciones de lógica y entradas/salidas como el dibujado de pantalla.

Para quienes tengan mucha flojera en leer y sólo quieran el código, está al final.
Para quienes quieran entender cómo funciona, lo iré explicando paso a paso (así evitar un cachito de código sobrecomentado) pero no línea por línea, sino lo que creo que permite entender el funcionamiento en conjunto.
El código es bastante genérico pues se puede encontrar en múltiples lugares (tutos de YT, foros de creación de juegos como java-gaming, etc.) pero lo más importante: es funcional y fácil de usar y modificar.


PASOS
1. Se crea la clase que contendrá el método main() de Java y el main loop del juego
(hasta el paso 9, todo el código que sigue va dentro de esta clase en el mismo orden). Esta clase hereda de JFrame para poder crear una ventana y añadirle un canvas sobre el que realizar el dibujado.

Java:
public class GameEngine extends JFrame {
    private boolean running = false;
    private final String title;
    private final int width;
    private final int height;

    public static CustomCanvas cc;
    private static int UPS_TARGET = 60;
    private static int FPS_TARGET = 60;
    private static int fps = 0;
    private static int ups = 0;
}
La clase CustomCanvas (que se detalla más adelante) sirve para poder implementar el dibujado de pantalla sin tener que sobreescribir el metodo paint() de algún componente como JPanel (otra de las formas de realizar el dibujado de pantalla, tampoco es que esté mal).
Las variables UPS_TARGET y FPS_TARGET permiten configurar a qué frecuencia se actualizan los datos de juego y se redibuja la pantalla, respectivamente (Al final se describe un método para liberar los fps y usar todo el potencial del CPU).
Bloquear la cantidad de actualizaciones por segundo mediante UPS_TARGET es fundamental: esto evita que los elementos del juego (como personajes, animaciones, etc) se ejecuten demasiado lento en computadoras de muy bajos recursos y demasiado rápido en computadoras potentes, ya que la velocidad de movimiento de personajes, de animaciones, de cálculo de daños, etc. se ajustan a esta variable. Si no se realiza este control, en un ordenador muy potente, los personajes podrían recorrer toda la pantalla en décimas de segundo y hacer el juego "injugable".


2. El constructor del juego: se configura la ventana y se añade la clase CustomCanvas para poder dibujar sobre ella.

Java:
public GameEngine(final String title, final int width, final int height) {
        this.title = title;
        this.width = width;
        this.height = height;
        setTitle(title);
        cc = new CustomCanvas(width, height);
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setResizable(false);
        setLayout(new BorderLayout());
        add(cc, BorderLayout.CENTER);
        pack();
        setLocationRelativeTo(null);
        setVisible(true);
    }
El método pack() se llama, en reemplazo de setSize(), para que la ventana tenga el tamaño que se definió mediante width y height, dejando al Frame Layout Manager a cargo de la configuración específica del SO.


3. Métodos de inicialización
Java:
public void startGame() {
        initiate();
        running = true;
    }

private void initiate() {

}
El método startGame() pone en true la variable que permite iniciar el juego. Esta variable es vital porque permite tener una forma de "apagar" el juego (parece obvio, pero no está de más repetir).
El método initiate(), ahora vacío, es ideal para realizar la carga de recursos (imágenes, sonidos, fuentes, textos, etc.).


4. El MAIN LOOP (parte I): Se inician las variables necesarias para administrar las actualizaciones y los redibujados de pantalla
Java:
public void startMainLoop() {
        int accumulatedUpdates = 0;
        int accumulatedFrames = 0;

        final int NS_PER_SECOND = 1000000000;
        final double TIME_PER_UPDATE = NS_PER_SECOND / UPS_TARGET;
        final double TIME_PER_RENDER = NS_PER_SECOND / FPS_TARGET;

        long lastUpdate = System.nanoTime();
        long lastCounter = System.nanoTime();

        double currentTime;
        double deltaAps = 0;
        double deltaFps = 0;
Las variables accumulatedUpdates y accumulatedFrames llevan la cuenta de las actualizaciones y dibujados de pantalla para poder mostrarlos por pantalla.
NS_PER_SECOND es una constante que indica cuántos nanosegundos hay en un segundo (mil millones :) )
TIME_PER_UPDATE y TIME_PER_RENDER indica cuánto tiempo debe pasar antes de realizar otra actualización y dibujado de pantalla, respectivamente.
Las variables deltaAps y deltaFps permiten saber en qué momento se debe actualizar o dibujar, respectivamente.


5. El MAIN LOOP (parte II): el famoso "while(running)" y las actualizaciones de los datos del juego.

Java:
while (running) {
            final long beginLoop = System.nanoTime();

            currentTime = beginLoop - lastUpdate;
            lastUpdate = beginLoop;

            deltaAps += currentTime / TIME_PER_UPDATE;

            while (deltaAps >= 1) {
                update();
                accumulatedUpdates++;
                deltaAps--;
            }
Para llevar un control preciso, se debe registrar en cada iteración el tiempo en nanosegundos.
currentTime permite saber cuánto tiempo pasó entre el último ciclo y el actual (este mecanismo logra que, si por alguna razón el sistema se demoro en terminar la iteración anterior, en la actual se realicen todas las actualizaciones juntas).
Éste es el mecanismo para saber cuándo actualizar:
deltaAps += currentTime / TIME_PER_UPDATE esto permite saber si ya transcurrió el tiempo necesario para realizar una actualización. si esta división da < 1, no se realiza ninguna actualización pero deltaAps mantendrá este valor hasta la próxima iteración, acercándose cada vez a 1.
se delta llega a 1, se llama al método que actualiza los datos de juego.
Si por alguna razón pasó mucho tiempo desde la última actualización, esta división currentTime / TIME_PER_UPDATE podría arrojar un resultado mucho mayor a 1, como 4, 5, 7, etc. En este caso, el while hará que se realicen todas las actualizaciones, una tras otra, antes de continuar, así recuperar las actualizaciones perdidas y no estropear la lógica del programa (Recordar que muchas cosas pueden estar relacionadas a la velocidad de actualización).


6. El MAIN LOOP (parte III): el dibujado de pantalla
Java:
deltaFps += currentTime / TIME_PER_RENDER;

            if (deltaFps >= 1) {
                draw();
                accumulatedFrames++;
                deltaFps = 0;
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
Similar a como funciona deltaAps, la variable deltaFps permite saber cuándo hay que realizar un nuevo dibujado de pantalla.
El uso de la constante TIME_PER_RENDER para determinar cuándo se debe dibujar en pantalla permite que el juego no haga uso excesivo de CPU y mantega el sistema estable (no es tan así, al final hablo de ello).
Cuando se detecta que ha pasado el tiempo para un nuevo dibujado de pantalla mediante la comprobación en el if, se llama al método draw() que realiza el dibujado. A diferencia de las actualizaciones, no hace falta redibujar la pantalla muchas veces seguidas si no ha habido cambios en ella, por esa razón, por más que deltaFps tenga valores mucho mayores a uno, se dibuja una sola vez y deltaFps vuelve a cero.
¿Y qué hace el juego mientras no se está actualizando ni dibujando? Pues dormir. Mediante Thread.sleep(1), el juego permanece "inactivo" durante 1 milisegundo aprox. (literalmente pausa la ejecución de este hilo permitiendo que el procesador se ocupe de otras tareas, según la documentación de Oracle). Este mecanismo evita acaparar el uso de CPU y también evita problemas de calentamiento por usar el procesador al 100% (de nuevo, no es tan así, ya hablo de esto al final).

7. el MAIN LOOP (parte IV): actualizar en pantalla las actualizaciones y frames por segundo
Java:
if (System.nanoTime() - lastCounter > NS_PER_SECOND) {

                ups = accumulatedUpdates;
                fps = accumulatedFrames;

                accumulatedUpdates = 0;
                accumulatedFrames = 0;
                lastCounter = System.nanoTime();
            }
        }
    }
Cuando se detecta que ya ha pasado un segudo o más (mediante System.nanoTime() - lastCounter > NS_PER_SECOND), se actualizan el conteo de actualizaciones y dibujados de pantalla realizados en este segundo y se resetean los contadores. Las variables ups y fps son llamadas mediante getters por el CustomCanvas para mostrar sus valores.
Las llaves extra del final cierran el while(running) y el método startMainLoop() :)


8. Los métodos que actualizan y dibujan
Java:
private void update() {
        // Lógica del juego, captura de entradas, calculo de movimientos, ataques, etc.
    }

    private void draw() {
        cc.draw();
    }

public static int getFPS() {
    return fps;
}

public static int getUPS() {
    return ups;
}
Muy sencillos :)
el método update(): Todas las clases que realicen alguna de estas operaciones (movimientos, animaciones, calculos de vida restantes, daño, etc.) deben contener un método update() que será llamado en este método (por ejemplo: player.update(); enemy.update(); map.update())

el método draw(): se invoca al método homónimo del CustomCanvas, quien realiza el dibujado.


9. El main más principal de todos los main principales
Java:
public static void main(String[] args) {
        GameEngine game = new GameEngine("My Game", 640, 360);
        game.startGame();
        game.startMainLoop();
    }
    
}
Se crea una instancia del juego y se arranca la ejecución, nada más.
La resolución 640x360 respeta la distribución 16:9
la llave extra del final cierra la clase :)

10. El CustomCanvas: el constructor
Java:
public class CustomCanvas extends Canvas {

    private static final long serialVersionUID = -6227038142688953660L;

    private final int width;
    private final int height;

    public CustomCanvas(final int width, final int height) {
        this.width = width;
        this.height = height;
        setIgnoreRepaint(true);
        setPreferredSize(new Dimension(width, height));
        // Habria que agregar aquí los adaptadores de Mouse y Teclado para que el canvas captura sus eventos y se pueda procesar
        // addKeyListener(yourKeyboardAdapter);
        // addMouseListener(yourMouseAdapter);
        setFocusable(true);
        requestFocus();
    }

    public void update() {
    }
La clase extiende de Canvas para poder crear el método de dibujo sin conflictos con métodos de otros componentes.
El constructor configura la clase para que dibuje en pantalla cuando el GameEngine lo decida y no el SO (mediante setIgnoreRepaint(true)) y requestFocus() hace que la clase reciba los eventos de las entradas (teclado y mouse). Para poder recibir efectivamente los eventos que produce el teclado y el mouse hay que crear clases que hereden de sus respectivos Listener o, si no se quiere implementar todos los métodos abstractos, implementar los eventos pretendidos mediante implementar las interfaces Adapter.
el método update() aparece vacío ya que esta clase se encarga principalmente del dibujado de pantalla, no de la lógica del juego. Aún así, si es necesario, se pueden realizar actualizaciones en esta clase. (el método update de GameEngine debería invocar el método update de esta clase)


11. El método de dibujado
Java:
public void draw() {
        // Se configura el Canvas
        final BufferStrategy buffer = getBufferStrategy();
        if (buffer == null) {
            createBufferStrategy(3);
            return;
        }
        final Graphics2D g = (Graphics2D) buffer.getDrawGraphics();

        // Acá va todo lo que se tiene que dibujar de todas las entidades
        g.fillRect(10, 10, 130, 15);
        g.setColor(Color.yellow);
        g.drawString("UPS: " + GameEngine.getUPS() + " | FPS: " + GameEngine.getFPS(), 20, 20);

        // Configuracion y dibujado final
        Toolkit.getDefaultToolkit().sync();
        g.dispose();
        buffer.show();
    }
    
}
La parte del BufferStrategy: esto indica cuántas veces se van a dibujar todas las imágenes antes de ser mostradas en pantalla. Mientras más alto sea este número de bufferes solicitados mediante createBufferStrategy(), más sólido será el dibujado de pantalla pero más lento será; mientras más pequeño el número, más rápido pero es mucho más probable que el juego "parpadee". La experiencia muestra que 3 es el número ideal.

El objeto Graphics2D g es el que brinda todos los métodos necesarios para representar en pantalla casi cualquier cosa y es el utilizado para dibujar todas las imagenes. Basta explorar un poco los métodos provistos por esta clase para ver su potencia y elegir cuál es el apropiado en cada caso.

Esta prueba solo dibuja un pequeño rectangulo en pantalla (fillRect es uno de los métodos más costosos, incluso que dibujar imágenes) y dibuja un texto que indica la cantidad de actualizaciones y dibujados de pantalla que se realizan por segundo.

La línea Toolkit.getDefaultToolkit().sync() permite sincronizar las operaciones de dibujado con la interfaz que el SO provee para ello.
Por último, se devuelven los recursos al SO y se muestra lo dibujado en el buffer.

FINAL
He aquí el código completo para quienes saltaron el "tutorial"


Java:
import javax.swing.*;
import java.awt.*;

public class GameEngine extends JFrame {
    private boolean running = false;
    private final String title;
    private final int width;
    private final int height;

    public static CustomCanvas cc;
    private static int UPS_TARGET = 60;
    private static int FPS_TARGET = 60;
    private static int fps = 0;
    private static int ups = 0;


    public GameEngine(final String title, final int width, final int height) {
        this.title = title;
        this.width = width;
        this.height = height;
        setTitle(title);
        cc = new CustomCanvas(width, height);
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        setResizable(false);
        setLayout(new BorderLayout());
        add(cc, BorderLayout.CENTER);
        pack();
        setLocationRelativeTo(null);
        setVisible(true);
    }

    public void startGame() {
        initiate();
        running = true;
    }

    private void initiate() {
    }

    public void startMainLoop() {
        int accumulatedUpdates = 0;
        int accumulatedFrames = 0;

        final int NS_PER_SECOND = 1000000000;
        final double TIME_PER_UPDATE = NS_PER_SECOND / UPS_TARGET;
        final double TIME_PER_RENDER = NS_PER_SECOND / FPS_TARGET;

        long lastUpdate = System.nanoTime();
        long lastCounter = System.nanoTime();

        double currentTime;
        double deltaAps = 0;
        double deltaFps = 0;

        while (running) {
            final long beginLoop = System.nanoTime();

            currentTime = beginLoop - lastUpdate;
            lastUpdate = beginLoop;

            deltaAps += currentTime / TIME_PER_UPDATE;

            while (deltaAps >= 1) {
                update();
                accumulatedUpdates++;
                deltaAps--;
            }


            //draw();
            //accumulatedFrames++;

            deltaFps += currentTime / TIME_PER_RENDER;

            if (deltaFps >= 1) {
                draw();
                accumulatedFrames++;
                deltaFps = 0;
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            if (System.nanoTime() - lastCounter > NS_PER_SECOND) {

                ups = accumulatedUpdates;
                fps = accumulatedFrames;

                accumulatedUpdates = 0;
                accumulatedFrames = 0;
                lastCounter = System.nanoTime();
            }
        }
    }

    private void update() {
        // Lógica del juego, captura de entradas, calculo de movimientos, ataques, etc.
    }

    private void draw() {
        cc.draw();
    }

    public static int getFPS() {
        return fps;
    }

    public static int getUPS() {
        return ups;
    }



    public static void main(String[] args) {
        GameEngine game = new GameEngine("My Game", 640, 360);
        game.startGame();
        game.startMainLoop();
    }

}

Java:
import java.awt.*;
import java.awt.image.BufferStrategy;

public class CustomCanvas extends Canvas {

    private static final long serialVersionUID = -6227038142688953660L;

    private final int width;
    private final int height;

    public CustomCanvas(final int width, final int height) {
        this.width = width;
        this.height = height;
        setIgnoreRepaint(true);
        setPreferredSize(new Dimension(width, height));
        // Habria que agregar aquí los adaptadores de Mouse y Teclado para que el canvas captura sus eventos y se pueda procesar
        // addKeyListener(yourKeyboardAdapter);
        // addMouseListener(yourMouseAdapter);
        setFocusable(true);
        requestFocus();
    }

    public void update() {
    }

    public void draw() {
        // Se configura el Canvas
        final BufferStrategy buffer = getBufferStrategy();
        if (buffer == null) {
            createBufferStrategy(3);
            return;
        }
        final Graphics2D g = (Graphics2D) buffer.getDrawGraphics();

        // Acá va todo lo que se tiene que dibujar de todas las entidades
        g.fillRect(10, 10, 130, 15);
        g.setColor(Color.yellow);
        g.drawString("UPS: " + GameEngine.getUPS() + " | FPS: " + GameEngine.getFPS(), 20, 20);

        // Configuracion y dibujado final
        Toolkit.getDefaultToolkit().sync();
        g.dispose();
        buffer.show();
    }

}

Rendimiento
Este codigo bloquea la cantidad de FPS a 60 (teóricamente, un ojo sin entrenamiento no capta nunca más de esa cantidad) pero se puede probar con, por ejemplo, 144 y el juego los ejecuta de manera precisa.

Sobre el bloqueo de FPS:
Hace algunos años un loop del tipo "while(true)" era considerado un asesino de CPUs, porque lo hacía trabajar al 100% en un bucle infinito e imposible de detener.
Pero lo cierto es que en la actualidad, la JVM administra de manera muy eficiente (o digamos que mejor que antes) los recursos del sistema, el procesador entre ellos. De manera que aunque no se bloqueen los dibujados de pantalla por segundo y no se utilice un Thread.sleep(1) para permitir descansar al CPU, nunca se llega a utilizar el 100% de ningún núcleo de un procesador actual. Pero, queda a criterio de cada uno cómo utilizar este código. Siempre es conveniente realizar pruebas antes.

Cómo liberar los fps para que se dibuje a todo lo que da el procesador?
hay que realiza un pequeño cambio en el main loop, quedando así:
Java:
while (running) {
            final long beginLoop = System.nanoTime();

            currentTime = beginLoop - lastUpdate;
            lastUpdate = beginLoop;

            deltaAps += currentTime / TIME_PER_UPDATE;

            while (deltaAps >= 1) {
                update();
                accumulatedUpdates++;
                deltaAps--;
            }

            // Simplemente se dibuja en cada iteración, sin preguntar por tiempos ni deltas acumulados
            draw();
            accumulatedFrames++;

             // No es mala idea dejar esto por aquí comentado por si se nota bajones de rendimiento al ejecutar el juego
            /* deltaFps += currentTime / TIME_PER_RENDER;

            if (deltaFps >= 1) {
                draw();
                accumulatedFrames++;
                deltaFps = 0;
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } */

            if (System.nanoTime() - lastCounter > NS_PER_SECOND) {

                ups = accumulatedUpdates;
                fps = accumulatedFrames;

                accumulatedUpdates = 0;
                accumulatedFrames = 0;
                lastCounter = System.nanoTime();
            }
        }
se puede ver cómo los fps se desbocan pero a costa de forzar el CPU. Eso depende de cada uno.
Lo cierto es que la logica del juego no tienen ningún efecto notable sobre el rendimiento. Lo único que afecta (si no se hacen locuras como acceder al disco dentro del main loop) es el dibujado de imagenes.

Bueno, esto es todo
Es el paso inicial, por aquí se comienza.
Despues quedan muchas cosas, como implementar los eventos de las entradas, la máquina de estados para manejar correctamente las transiciones, los cargadores de recursos, etc. Pero teniendo el motor principal listo, todo va sobre ruedas.

espero les sea útil a quien quiera usarlo
No se necesita ningún tipo de crédito o mención! en serio, no hace falta, esto se puede conseguir por todos lados y hasta mejor explicado :)
Para quien le interesa, esta web contiene muchos datos interesantes sobre construcción de juegos en Java java-gaming.org y este es un hilo muy interesante sobre el desarrollo de game loops: game-loops

Saludos!!
 

Disturbo

Decomper
Iba a comentar antes pero se me olvidó. Oops.
Todo muy bastante bien definido y fácil de entender, aunque no creo que mucha gente llegue a usarlo. Por mi parte, quizá le dé una vuelta de tuerca y lo use en Kotlin. Buen tuto, mis dieses!
 

enanogm

Usuario antiguo de Wah
Iba a comentar antes pero se me olvidó. Oops.
Todo muy bastante bien definido y fácil de entender, aunque no creo que mucha gente llegue a usarlo. Por mi parte, quizá le dé una vuelta de tuerca y lo use en Kotlin. Buen tuto, mis dieses!
Sí, es muy cierto.
Java está cayendo en desuso...

Pero esa es la idea: esa estructura de motor de juegos se viene usando hace mucho tiempo en distintos lenguajes. Quien quiera hacerlo, puede tomar esta estructura y escribirla en cualquier otro lenguaje

en fin, gracias por la buena onda!
 
Arriba