Mono muy mono con una ventana de un juego 2d al lado

Introducción al Game Loop y Animaciones en Swing

¿Qué es un Game Loop?

En esta sesión vamos a ver qué es el game loop, cómo construirlo en Java y cómo animar de forma simple dibujos e imágenes.

Un game loop es el corazón, el «motor» de cualquier juego. Se trata de un bucle que se ejecuta constantemente y que permite actualizar la lógica del juego y redibujar los elementos en pantalla.

Este mecanismo permite que los juegos sean dinámicos y responsivos, asegurando que los objetos se muevan, los eventos se procesen y los elementos gráficos se actualicen a una velocidad constante.

El game loop realiza tres tareas principales:

  1. Actualizar la lógica del juego (posiciones, colisiones, estados, etc.).
  2. Dibujar los objetos en la pantalla con sus nuevos estados.
  3. Mantener un ritmo de actualización constante. ¿Has oído hablar de los FPS (Frames per second)? El game loop es el encargado de mantener esa frecuencia de actualización constante. Esto se puede conseguir de varias formas. Aquí veremos desde las más sencillas hasta la más eficiente.

Creando un Game Loop en Java con Swing

En principio bastaría con que un game loop actualice y dibuje, como se muestra a continuación.

while (true) { // se repite siempre
    actualizar();     // cálculo de la lógica del juego
    dibujar();        // actualización en pantalla de los objetos
}

Sin embargo, si creamos un código que implemente esto y lo ejecutamos, veremos que sucede algo no deseado: no dibuja.

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

// Trata de ejecutar este código y comprueba cómo, aunque el código es correcto y debería funcionar, el círculo no se mueve.
public class Lienzo extends JPanel {
    private int x=10, y=10;

    @Override
    public void paintComponent(Graphics g) {
        super.paintComponent(g);
        g.setColor(Color.RED);
        g.fillOval(x,y, 20, 20);
    }

    public void update() {
        x++;
        y++;
    }

    public void draw() {
        this.repaint();
    }

    public static void main(String[] args) {
        JFrame frame = new JFrame("Círculo animado");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize(250,250);
        frame.setLocationRelativeTo(null);
        frame.setResizable(false);
        Lienzo game = new Lienzo();
        frame.setContentPane(game);
        frame.setVisible(true);

        // Game Loop
        while (true) {
            game.update();
            game.draw();
        }
    }
}

La necesidad de eventos de Java

Hagamos un breve apunte sobre esta cuestión, ya que nos va a resultar crucial para entender por qué necesitamos hacer ciertas cosas dentro del game loop.

En Java, la naturaleza de la arquitectura de eventos en la que se basan las Interfaces Gráficas (GUIs) provoca que mientras se están haciendo cálculos no se pueda actualizar esa GUI.

Aquí te explico la razón:

  1. El bucle de eventos: Las GUIs de Java funcionan con un bucle de eventos que se encarga de gestionar las interacciones del usuario con la interfaz. Este bucle espera eventos como clics de ratón, pulsaciones de teclas, etc. y los procesa.
  2. Bloqueo del hilo principal: Cuando un hilo (thread) ejecuta una tarea larga o bloqueada, como una operación de red o un cálculo complejo, el bucle de eventos se bloquea. Esto significa que la GUI deja de responder a las interacciones del usuario hasta que la tarea se completa.

Para evitar que el hilo que actualiza la GUI quede bloqueado es necesario usar hilos.

  1. Hilos independientes: Para evitar bloquear la GUI, se utilizan hilos independientes para ejecutar tareas largas. Estos hilos no bloquean el bucle de eventos, permitiendo que la GUI siga respondiendo a las interacciones del usuario.
  2. Actualización de la GUI: Cuando se necesita actualizar la GUI (por ejemplo, mostrar nuevos datos o cambiar el estado de un componente), se utiliza un hilo separado para realizar la actualización. Este hilo envía un mensaje al bucle de eventos, que a su vez actualiza la GUI.

Eso sí, la gestión de hilos puede ser compleja, y es importante entender los conceptos básicos de la concurrencia y la sincronización para evitar problemas.

Como aquí nos vamos a centrar en una aplicación muy específica, si te ciñes a las instrucciones seguro que todo irá bien.

Empecemos viendo cómo crear un game loop sin necesidad de crear hebras separadas. Es la forma más sencilla aunque también la menos eficiente de hacer que el game loop funcione.

Estructura básica de un Game Loop

Como hemos dicho, el game loop hace 3 tareas básicas esenciales: actualizar, dibujar y asegurar el ritmo de actualización.

while (true) { // se repite siempre
    actualizar();     // cálculo de la lógica del juego
    dibujar();        // actualización en pantalla de los objetos
    asegurarRitmo();  // asegurar que se dibuja a ritmo constante
}

GameLoop básico usando un intervalo de espera

Llevemos esto a la práctica. En el siguiente código haremos que el método update() se encargue de la lógica del juego, el método draw() de dibujar, y el método delta() de asegurar el ritmo.

Si ejecutas el siguiente código verás como ahora el círculo sí se mueve. Observa cómo en el método delta() pausamos el hilo durante 15 milisegundos, tiempo suficiente para que la interfaz gráfica tome la ejecución y se actualice.

import javax.swing.*; // Importa las clases necesarias para crear una interfaz gráfica
import java.awt.*; // Importa las clases necesarias para dibujar gráficos

public class GameLoopSimple extends JPanel {
    private int x = 10, y = 10; // Coordenadas iniciales del círculo

    @Override
    public void paintComponent(Graphics g) {
        super.paintComponent(g); // Llama al método paintComponent de la superclase para asegurarse de que el panel se dibuje correctamente
        g.setColor(Color.RED); // Establece el color de dibujo a rojo
        g.fillOval(x, y, 20, 20); // Dibuja un círculo (óvalo) en las coordenadas (x, y) con un ancho y alto de 20 píxeles
    }

    public void update() {
        x++; // Incrementa la coordenada x en 1
        y++; // Incrementa la coordenada y en 1
    }

    public void draw() {
        this.repaint(); // Llama al método repaint para solicitar que el panel se vuelva a dibujar
    }

    public void delta() {
        try {
            Thread.sleep(15); // Pausa la ejecución del programa durante 15 milisegundos
        } catch (InterruptedException e) {
            System.out.println("Error de sincronización."); // Imprime un mensaje de error si ocurre una interrupción
        }
    }

    public static void main(String[] args) {
        JFrame frame = new JFrame("Círculo animado");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setSize(250, 250);
        frame.setLocationRelativeTo(null);
        frame.setResizable(false);
        GameLoopSimple game = new GameLoopSimple();
        frame.setContentPane(game);
        frame.setVisible(true);

        // Game Loop
        while (true) {
            game.update(); // Actualiza las coordenadas del círculo
            game.draw(); // Redibuja el círculo en la nueva posición
            game.delta(); // Pausa la ejecución durante 15 milisegundos
        }
    }
}

Game Loop con Timer

Como hemos dicho antes, la forma anterior no es la mejor para gestionar un game loop y, aunque para hacer pruebas está bien, te muestro otras formas de menos correcta y eficiente a más correcta y eficiente (y también compleja).

El primer acercamiento a un game loop en Java es usar un javax.swing.Timer, que se ejecuta a intervalos regulares. Así eliminamos la necesidad de ejecutar un bucle encargado de actualizar y dibujar, porque el Timer se encarga de programar ejecuciones repetidas de estos métodos.

Ejemplo con Timer:

import javax.swing.*; // Importa las clases necesarias para crear una interfaz gráfica
import java.awt.*; // Importa las clases necesarias para dibujar gráficos
import java.awt.event.ActionEvent; // Importa la clase ActionEvent para manejar eventos de acción
import java.awt.event.ActionListener; // Importa la interfaz ActionListener para recibir eventos de acción

/**
 * Esta clase crea un juego simple donde un objeto se mueve a través de la pantalla de forma continua.
 * Utiliza un Timer para controlar la velocidad de la animación y un ActionListener para actualizar la posición del objeto.
 */
public class JuegoConTimer extends JPanel {
    private int x = 0; // Coordenada X inicial del objeto a animar.

    // Un temporizador que se activa cada 16 milisegundos para actualizar la animación.
    private Timer timer = new Timer(16, e -> {
        actualizar();
        repaint();
    });

    /**
     * Constructor de la clase.
     * Inicializa el temporizador y lo inicia.
     */
    public JuegoConTimer() {
        // Inicia el temporizador. El parámetro 16 indica un retraso de 16 milisegundos entre cada evento.
        // La expresión lambda `e -> { ... }` es una forma concisa de crear un ActionListener.
        timer.start();
    }

    /**
     * Actualiza la posición del objeto a animar.
     * Se llama desde el ActionListener del temporizador.
     */
    private void actualizar() {
        x += 5; // Incrementa la posición en X en 5 píxeles (movimiento hacia la derecha).
        if (x > getWidth()) { // Si el objeto sale de la ventana por la derecha...
            x = 0; // ...lo reiniciamos al principio.
        }
    }

    /**
     * Este método se llama automáticamente cada vez que la ventana necesita ser redibujada.
     * Aquí es donde se dibuja el objeto en su nueva posición.
     *
     * @param g El objeto Graphics utilizado para dibujar en el componente.
     */
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g); // Llama al método paintComponent de la superclase.
        g.fillOval(x, 100, 50, 50); // Dibuja un óvalo (círculo) en las coordenadas (x, 100) con un tamaño de 50x50 píxeles.
    }

    public static void main(String[] args) {
        JFrame ventana = new JFrame("Juego con Timer");
        JuegoConTimer juego = new JuegoConTimer();
        ventana.add(juego);
        ventana.setSize(500, 300);
        ventana.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        ventana.setVisible(true);
    }
}

Ventajas y desventajas del Timer:

✅ Sencillo de implementar. ✅ Integrado en Swing. ❌ Puede sufrir retardos si hay muchas tareas ejecutándose.


Forma intermedia: Uso de un Bucle en un Hilo (Thread)

Un Timer puede ser insuficiente en juegos más complejos. Para mejorar la eficiencia, podemos usar un hilo (Thread) que controle el game loop.

Ejemplo con un Hilo:

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

/**
 * Esta clase crea un juego simple donde un objeto se mueve a través de la pantalla de forma continua.
 * Utiliza un hilo separado (Thread) para manejar la animación.
 */
public class JuegoConHilo extends JPanel implements Runnable {
    private int x = 0; // Coordenada X inicial del objeto.
    private Thread hilo; // Referencia al hilo que ejecuta la animación.
    private boolean ejecutando = true; // Bandera para controlar si la animación está en ejecución.

    /**
     * Constructor de la clase.
     * Crea un nuevo hilo y lo inicia.
     */
    public JuegoConHilo() {
        hilo = new Thread(this); // Crea un nuevo hilo y asigna la instancia actual de esta clase como tarea a ejecutar.
        hilo.start(); // Inicia la ejecución del hilo.
    }

    /**
     * Actualiza la posición del objeto a animar.
     */
    private void actualizar() {
        x += 5; // Incrementa la posición en X en 5 píxeles (movimiento hacia la derecha).
        if (x > getWidth()) { // Si el objeto sale de la ventana por la derecha, lo reiniciamos al principio.
            x = 0;
        }
    }

    /**
     * Este método se llama automáticamente cada vez que la ventana necesita ser redibujada.
     * Aquí es donde se dibuja el objeto en su nueva posición.
     *
     * @param g El objeto Graphics utilizado para dibujar en el componente.
     */
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g); // Llama al método paintComponent de la superclase.
        g.fillOval(x, 100, 50, 50); // Dibuja un óvalo (círculo) en las coordenadas (x, 100) con un tamaño de 50x50 píxeles.
    }

    /**
     * Este método se ejecuta en el hilo separado.
     * Contiene el bucle principal de la animación.
     */
    @Override
    public void run() {
        while (ejecutando) { // Bucle principal de la animación.
            actualizar(); // Actualiza la posición del objeto.
            repaint(); // Solicita que se vuelva a dibujar el componente.
            try {
                Thread.sleep(16); // Pausa el hilo durante 16 milisegundos (aproximadamente 60 FPS).
            } catch (InterruptedException e) {
                e.printStackTrace(); // Manejo de excepciones (no crucial para este ejemplo).
            }
        }
    }

    public static void main(String[] args) {
        JFrame ventana = new JFrame("Juego con Hilo");
        JuegoConHilo juego = new JuegoConHilo();
        ventana.add(juego);
        ventana.setSize(500, 300);
        ventana.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        ventana.setVisible(true);
    }
}

Ventajas y desventajas del Thread:

✅ Mejor control sobre el game loop. ✅ Mayor eficiencia que Timer. ❌ Puede generar problemas de sincronización si no se maneja correctamente.


Forma más eficiente: Game Loop con delta time

Para obtener una animación fluida sin depender de la velocidad del hardware, podemos usar un game loop basado en «delta time».

¿Qué es Delta Time?

El concepto de delta time es fundamental en el desarrollo de videojuegos que permite que el juego funcione de manera fluida y consistente, independientemente de la tasa de fotogramas (frames per second, FPS) del hardware en el que se esté ejecutando.

Delta time (Δt) se refiere al tiempo transcurrido desde el último frame renderizado. Es crucial porque permite que el juego se ejecute de manera uniforme, sin importar la velocidad a la que se esté ejecutando.

¿Cómo Funciona un Game Loop Basado en Delta Time?

  1. Calcular Delta Time: En cada iteración del bucle, se calcula el tiempo que ha pasado desde la última actualización.
  2. Actualizar el juego: Utiliza este valor de delta time para ajustar la lógica del juego. Por ejemplo, si un objeto se mueve a una velocidad de 100 unidades por segundo, y el delta time es 0.016 segundos (aproximadamente 60 FPS), el objeto se movería 1.6 unidades en esa actualización.
  3. Renderizar: Después de actualizar la lógica del juego, se renderiza la nueva escena.

Beneficios de Usar Delta Time

  • Consistencia: Permite que el juego se ejecute de manera consistente en diferentes hardware, ya que la lógica del juego se ajusta según el tiempo real.
  • Suavidad: Mejora la experiencia del jugador al hacer que el movimiento y las animaciones sean más suaves y naturales.

Ejemplo con delta time:

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

/**
 * Esta clase crea un juego simple donde un objeto se mueve a través de la pantalla de forma continua.
 * Utiliza un hilo separado (Thread) y el concepto de Delta Time para una animación más suave y independiente del FPS.
 */
public class JuegoConDeltaTime extends JPanel implements Runnable {
    private int x = 0; // Coordenada X inicial del objeto.
    private Thread hilo; // Referencia al hilo que ejecuta la animación.
    private boolean ejecutando = true; // Bandera para controlar si la animación está en ejecución.
    private final int FPS = 60; // Frames por segundo deseados.

    /**
     * Constructor de la clase.
     * Crea un nuevo hilo y lo inicia.
     */
    public JuegoConDeltaTime() {
        hilo = new Thread(this);
        hilo.start();
    }

    /**
     * Actualiza la posición del objeto a animar utilizando Delta Time.
     *
     * @param deltaTime El tiempo transcurrido desde la última actualización (en segundos).
     */
    private void actualizar(double deltaTime) {
        // Calcula el desplazamiento basado en el tiempo transcurrido y la velocidad deseada.
        x += (int) (200 * deltaTime); // 200 es la velocidad en píxeles por segundo.
        if (x > getWidth()) {
            x = 0;
        }
    }

    /**
     * Este método se llama automáticamente cada vez que la ventana necesita ser redibujada.
     * Aquí es donde se dibuja el objeto en su nueva posición.
     *
     * @param g El objeto Graphics utilizado para dibujar en el componente.
     */
    @Override
    protected void paintComponent(Graphics g) {
        super.paintComponent(g);
        g.fillOval(x, 100, 50, 50);
    }

    /**
     * Este método se ejecuta en el hilo separado.
     * Contiene el bucle principal de la animación.
     */
    @Override
    public void run() {
        long tiempoPrevio = System.nanoTime(); // Tiempo del último frame.
        double tiempoPorFrame = 1_000_000_000.0 / FPS; // Tiempo esperado por frame en nanosegundos.

        while (ejecutando) {
            long tiempoActual = System.nanoTime(); // Tiempo actual.
            double deltaTime = (tiempoActual - tiempoPrevio) / tiempoPorFrame; // Calcula el Delta Time.
            tiempoPrevio = tiempoActual; // Actualiza el tiempo previo para el siguiente frame.

            actualizar(deltaTime); // Actualiza la posición del objeto usando Delta Time.
            repaint();

            try {
                Thread.sleep(16); // Pausa el hilo para mantener una tasa de frames aproximada.
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        JFrame ventana = new JFrame("Juego con Delta Time");
        JuegoConDeltaTime juego = new JuegoConDeltaTime();
        ventana.add(juego);
        ventana.setSize(500, 300);
        ventana.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        ventana.setVisible(true);
    }
}

Ventajas y desventajas del Delta Time:

✅ Movimiento independiente del hardware. ✅ Animaciones más fluidas y consistentes. ❌ Requiere un poco más de código y matemáticas.


Conclusión

El game loop es esencial para cualquier juego. Hemos visto desde una implementación básica con Timer, pasando por Thread, hasta una solución eficiente con delta time. Dependiendo de la complejidad del juego, podemos elegir la mejor estrategia.

¿Cuál es el siguiente paso?

🕹️ Agregar interactividad y físicas básicas para empezar a construir un juego real. ¡Vamos a programar! 🚀


Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.