Programando el juego Hundir la flota en Java paso a paso

Introducción

En esta unidad, vamos a desglosar una implementación avanzada del juego clásico de estrategia naval. A diferencia de otros ejemplos que pueden ser más simples, este código introduce los siguientes conceptos a nivel más avanzado:

  1. Motor de renderizado ANSI: Manipulación de la consola del sistema operativo para mostrar colores.
  2. Modelo de datos robusto: Uso de enumerados (enum) para encapsular comportamiento y configuración.
  3. Algoritmos de colocación espacial: Lógica matemática para gestionar colisiones y perímetros de seguridad en una matriz.
  4. Niebla de guerra: Separación entre el estado interno del programa y lo que se muestra al usuario.

En este enlace puedes encontrar una presentación explicando el código, y a continuación dispones de una infografía y un vídeo explicativo.

Estructuras de datos: El uso de enum frente a enteros

Implementación en el enum TipoBarco

El objetivo principal de este enum es actuar como bloque de definición de datos para la configuración de la flota, persiguiendo los siguientes objetivos:

  • Centralización de reglas: Imagina que quieres cambiar las reglas del juego para que el submarino sea más grande (4 casillas en vez de 3). Si usaras variables sueltas dispersas por el código, tendrías que buscar y cambiar ese número en varios sitios. Con este enum, cambias el número en una sola línea (SUBMARINO(4)) y todo el juego (condiciones de victoria, lógica de colocación, etc.) se actualiza automáticamente.
  • Eliminación de «números mágicos»: En programación, encontrar un 5 suelto en el código es confuso (¿es la munición? ¿es el tamaño del tablero?). Al usar TipoBarco.PORTAAVIONES.getLongitud(), el código se explica por sí mismo.
  • Agrupación lógica: Vincula inseparablemente el nombre de la entidad (portaaviones) con su propiedad (tamaño 5). No puedes tener un portaaviones sin tamaño, ni un tamaño sin barco.

Cuando el programa arranca, Java crea automáticamente una única instancia para cada uno de estos nombres. Por ejemplo, al escribir PORTAAVIONES(5), Java está haciendo internamente algo similar a: new TipoBarco(5). Le está pasando el valor 5 al constructor. De esta forma, cada barco tiene su propia variable interna llamada longitud, con las siguientes características:

  • Es private: Nadie desde fuera puede modificarla directamente. Para acceder a su valor, utilizaremos un método público para que el resto del programa pueda preguntar: «¿Cuánto mide este barco?»
  • Es final: Una vez asignada, no puede cambiar. Un destructor siempre medirá 2 durante toda la ejecución del programa. Esto garantiza la integridad de las reglas.
    /**
     * Define los tipos de barcos y sus longitudes.
     * Centraliza la configuración: si cambiamos un número aquí, el juego se adapta.
     */
    enum TipoBarco {
        PORTAAVIONES(5),
        ACORAZADO(4),
        CRUCERO(3),
        SUBMARINO(3),
        DESTRUCTOR(2);

        private final int longitud;

        TipoBarco(int longitud) {
            this.longitud = longitud;
        }

        public int getLongitud() {
            return longitud;
        }
    }

Estructura de colores RGB en consola

Esta es una de las partes más interesantes del código. Java, por defecto, imprime texto plano en la consola. Sin embargo, los terminales modernos (CMD, PowerShell, Bash) soportan Secuencias de Escape ANSI.

Cuando imprimimos un carácter especial llamado ESCAPE (código ASCII 27, representado en Java como \u001B), le estamos diciendo a la consola: «Atención, lo que viene a continuación no es texto para leer, es una orden de configuración».

El código implementa el estándar RGB TrueColor con la estructura «\u001B[38;2;R;G;Bm«:

  • \u001B[: Inicio de la secuencia de control (CSI).
  • 38: Indica que vamos a cambiar el color del texto (foreground).
  • 2: Indica que usaremos el modo RGB (Red, Green, Blue).
  • R;G;B: Son tres números del 0 al 255 que definen la mezcla de color.
  • m: Indica el fin de la instrucción.

Implementación en el enum EstadoCasilla

En lugar de escribir estos códigos crípticos cada vez que queremos imprimir algo, los encapsulamos en el constructor del enum:

  1. Constructor: Recibe los valores enteros R, G y B.
  2. String.format: Construye la secuencia ANSI dinámica.
  3. toString: Sobrescribe el método estándar para devolver: COLOR + SÍMBOLO + RESET. El código de RESET (\u001B[0m) es vital; si no lo pusiéramos, toda la consola se quedaría pintada de ese color indefinidamente.
    /**
     * Motor de colores RGB.
     * Transforma códigos numéricos en secuencias de escape ANSI.
     */
    enum EstadoCasilla {
        // Definición semántica con sus valores visuales (Símbolo, R, G, B)
        AGUA     ( "~",      0, 180, 255),   // Agua (azul claro)
        BARCO    ( "B",    160,  32, 240),   // Púrpura (visible al acabar)
        TOCADO   ( "X",    255,   0,   0),   // Rojo puro (impacto)
        FALLO    ( "o",    180, 180, 180);   // Gris (agua con impacto)

        private final String simbolo;
        private final String codigoColor;
        // Secuencia para restablecer el color por defecto de la terminal
        private static final String RESET = "\u001B[0m";

        /**
         * Constructor que inyecta la lógica de color.
         * La secuencia mágica es: \u001B[38;2;R;G;Bm
         */
        EstadoCasilla(String simbolo, int r, int g, int b) {
            this.simbolo = simbolo;
            this.codigoColor = String.format("\u001B[38;2;%d;%d;%dm", r, g, b);
        }

        @Override
        public String toString() {
            // Devuelve: [Instrucción Color] + [Carácter] + [Instrucción Reset]
            return codigoColor + simbolo + RESET;
        }
    }

Gestión de memoria y estado global

Para gestionar el tablero, utilizamos una matriz bidimensional de objetos EstadoCasilla.

  • static EstadoCasilla[][] oceano: Es la representación en memoria del mar. Cada celda [fila][columna] contiene una referencia a una de las constantes del Enum (AGUA, BARCO, etc.).
  • Cálculo dinámico de victoria: La constante IMPACTOS_NECESARIOS no se fija a mano (ej: 17). Se calcula llamando a una función que suma las longitudes de todos los barcos definidos. Esto hace el código extremadamente robusto: si mañana añadimos un nuevo barco al enum, la condición de victoria se recalcula sola sin tocar el resto del código.
    // CONSTANTES Y ESTADO GLOBAL

    static final int DIMENSION = 10; // Tamaño del tablero 10x10
    static final int NO_ENCONTRADO = -1; // Centinela para búsquedas fallidas

    // Configuración de dificultad
    static final int MUNICION_MAXIMA = 50;
    // Cálculo dinámico de la condición de victoria
    static final int IMPACTOS_NECESARIOS = calcularPuntosTotales();

    // La Matriz Principal (El "Tablero")
    static EstadoCasilla[][] oceano = new EstadoCasilla[DIMENSION][DIMENSION];
    
    // Herramientas de entrada y aleatoriedad
    static Scanner teclado = new Scanner(System.in);
    static Random radar = new Random();

El ciclo de vida del juego

El método main se encarga de la ejecución temporal del programa. Podemos distinguir tres fases claras:

  1. Fase de inicialización:
    • Limpia el tablero (inicializarOceano).
    • Coloca los barcos sin que el jugador sepa dónde están (colocarFlotaCompleta).
  2. Fase de ejecución (bucle while):
    • Renderizado: Llama a imprimirOceano(false). El parámetro false activa la «niebla de guerra», ocultando los barcos.
    • Input: Solicita coordenadas y valida que no se repitan.
    • Lógica: Actualiza el estado de la matriz (de AGUA/BARCO a FALLO/TOCADO).
  3. Fase de clausura:
    • Al terminar (victoria o derrota), llama a imprimirOceano(true). El parámetro true desactiva la niebla y revela la ubicación de todos los barcos.
    public static void main(String[] args) {
        System.out.println("--- HUNDIR LA FLOTA ---");
        System.out.println("Tablero: " + DIMENSION + "x" + DIMENSION);
        System.out.println("Objetivo: " + IMPACTOS_NECESARIOS + " impactos.");
        System.out.println("Munición: " + MUNICION_MAXIMA + " misiles.");

        // 1. Fase de Preparación
        inicializarOceano();
        colocarFlotaCompleta();

        // Variables de control de flujo
        int aciertos = 0;
        int misilesRestantes = MUNICION_MAXIMA;
        boolean juegoTerminado = false;

        // 2. Bucle Principal (Game Loop)
        while (!juegoTerminado) {
            // Renderizamos con Niebla de Guerra (false)
            imprimirOceano(false); 

            System.out.println("------------------------------------------------");
            System.out.println("Misiles: " + misilesRestantes + " | Aciertos: " + aciertos + "/" + IMPACTOS_NECESARIOS);

            // Turno del jugador
            boolean impacto = realizarDisparo();
            misilesRestantes--;

            // Feedback visual inmediato
            if (impacto) {
                aciertos++;
                System.out.println(">>> ¡IMPACTO CONFIRMADO! <<<");
            } else {
                System.out.println(">>> Agua. Sin rastro del enemigo. <<<");
            }

            // 3. Comprobación de condiciones de fin
            if (aciertos == IMPACTOS_NECESARIOS) {
                juegoTerminado = true;
                imprimirOceano(true); // Revelamos el mapa (Cheat mode activado legalmente)
                System.out.println("\n¡VICTORIA! Has desmantelado la flota enemiga.");

            } else if (misilesRestantes == 0) {
                juegoTerminado = true;
                imprimirOceano(true);
                System.out.println("\n¡MUNICIÓN AGOTADA! Retirada táctica.");
            }
        }
    }

La meta del juego: calcularPuntosTotales()

Esta función es muy inteligente porque no usa un número «fijo» (como decir que se gana con 10 puntos). En su lugar, pregunta a los barcos cuánto miden.

  • TipoBarco.values(): Esto obtiene una lista de todos los tipos de barcos que has definido (Portaaviones, Submarino, etc.).
  • El bucle for: Recorre cada barco, mira su longitud (getLongitud()) y la suma a la variable suma.
  • ¿Por qué es útil?: Si mañana decides añadir un barco más o cambiar el tamaño de uno, no tienes que tocar el código de victoria; el programa calculará automáticamente que ahora hacen falta, por ejemplo, 17 puntos en lugar de 15.
    /**
     * Calcula dinámicamente cuántos puntos hacen falta para ganar.
     */
    static int calcularPuntosTotales() {
        int suma = 0;
        for (TipoBarco barco : TipoBarco.values()) {
            suma += barco.getLongitud();
        }
        return suma;
    }

El turno del jugador: realizarDisparo()

Esta función debe validar los datos introducidos por el usuario y actualizar el mapa. Se divide en dos bloques:

El bucle de validación (while)

El objetivo de este bloque es que el jugador no pierda el turno por un error en la introducción de datos o en la elección de la casilla donde quiere efectuar el disparo.

  1. Entrada de datos: Pide fila y columna.
  2. Comprobación de repetición: Mira en la matriz oceano[fila][col].
    • Si el valor es TOCADO o FALLO, significa que ya hubo un proyectil ahí.
    • Importante: Mientras el disparo no sea «nuevo», disparoValido sigue siendo false y el bucle se repite indefinidamente. El jugador no saldrá de aquí hasta que dé una coordenada válida donde no se haya disparado todavía.

Resolución del impacto (if-else)

Una vez que tenemos una coordenada válida, el programa decide qué hay debajo:

  • Si hay un BARCO:
    • Cambia el estado a TOCADO (para que el dibujo del mapa cambie).
    • return true: Esto le dice al resto del programa: «¡Oye, suma un punto al marcador!».
  • Si no hay barco (es decir, hay agua):
    • Cambia el estado a FALLO.
    • return false: El contador de impactos no cambia.

Conceptos clave de Java en este código

  • Enums (EstadoCasilla y TipoBarco): El código no usa números sueltos (como 0 o 1), sino nombres claros. Es mucho más fácil leer EstadoCasilla.TOCADO que leer un 2 y tener que recordar qué significaba.
  • Booleans como control: El uso de disparoValido actúa como un «cerrojo» para asegurar que la gestión del impacto se realice con datos correctos.
    /**
     * Procesa el turno de disparo.
     * Devuelve true si acertamos a un barco, false si damos en agua.
     */
    static boolean realizarDisparo() {
        boolean disparoValido = false;
        int fila = NO_ENCONTRADO;
        int col = NO_ENCONTRADO;

        // Bucle de validación de entrada lógica
        while (!disparoValido) {
            fila = pedirCoordenada("Fila (0-" + (DIMENSION - 1) + "): ");
            col = pedirCoordenada("Columna (0-" + (DIMENSION - 1) + "): ");

            // Evitar disparar dos veces al mismo sitio
            if (oceano[fila][col] == EstadoCasilla.TOCADO || oceano[fila][col] == EstadoCasilla.FALLO) {
                System.out.println("Ya has disparado en esa zona. Elige otra.");
            } else {
                disparoValido = true;
            }
        }

        // Resolución del impacto en la matriz
        if (oceano[fila][col] == EstadoCasilla.BARCO) {
            oceano[fila][col] = EstadoCasilla.TOCADO;
            return true;
        } else {
            oceano[fila][col] = EstadoCasilla.FALLO;
            return false;
        }
    }

Distribución de barcos: geometría y «regla del aire»

Esta es la sección más compleja algorítmicamente. El objetivo es colocar barcos aleatoriamente pero cumpliendo dos reglas estrictas:

  1. El barco no debe salirse del tablero.
  2. Regla del aire: El barco no puede tocar a otro, ni siquiera en diagonal. Debe haber al menos una casilla de agua de separación.

El algoritmo de «bounding box» (caja delimitadora)

En la función esPosicionValida, calculamos un rectángulo de seguridad. Por ejemplo, si el barco va de la fila F a la fila F+3 (tamaño 4), revisamos desde F-1 hasta F+4.

Para evitar errores de «índice fuera de rango» (por ejemplo, si intentáramos revisar la fila -1), utilizamos las funciones matemáticas Math.max(0, ...) y Math.min(DIMENSION-1, ...). Esto «recorta» el área de búsqueda a los límites reales del tablero.

    // ALGORITMOS DE COLOCACIÓN

    static void colocarFlotaCompleta() {
        for (TipoBarco barco : TipoBarco.values()) {
            colocarBarcoAleatorio(barco);
        }
    }

    /**
     * Algoritmo de Fuerza Bruta (Trial & Error).
     * Intenta coordenadas al azar hasta encontrar una válida.
     */
    static void colocarBarcoAleatorio(TipoBarco barco) {
        boolean colocado = false;

        while (!colocado) {
            int fila = radar.nextInt(DIMENSION);
            int col = radar.nextInt(DIMENSION);
            boolean horizontal = radar.nextBoolean();

            if (esPosicionValida(fila, col, barco.getLongitud(), horizontal)) {
                pintarBarcoEnMatriz(fila, col, barco.getLongitud(), horizontal);
                colocado = true;
            }
        }
    }

    /**
     * Valida si un barco cabe y respeta el perímetro de seguridad.
     */
    static boolean esPosicionValida(int f, int c, int longitud, boolean horizontal) {
        // 1. Determinar dimensiones del barco
        int anchoBarco = horizontal ? longitud : 1;
        int altoBarco  = horizontal ? 1 : longitud;

        // 2. Verificar límites del tablero
        if (f + altoBarco > DIMENSION || c + anchoBarco > DIMENSION) {
            return false;
        }

        // 3. Definir el "Marco de Seguridad" (Bounding Box)
        // Usamos Math.max y min para no salirnos de los índices 0-9
        int filaInicio = Math.max(0, f - 1);
        int colInicio  = Math.max(0, c - 1);

        int filaFin = Math.min(DIMENSION - 1, f + altoBarco);
        int colFin  = Math.min(DIMENSION - 1, c + anchoBarco);

        // 4. Escaneo de Área
        for (int i = filaInicio; i <= filaFin; i++) {
            for (int j = colInicio; j <= colFin; j++) {
                // Si encontramos CUALQUIER COSA que no sea agua pura, la posición es inválida
                if (oceano[i][j] != EstadoCasilla.AGUA) {
                    return false; 
                }
            }
        }
        return true; // Zona despejada
    }

    static void pintarBarcoEnMatriz(int f, int c, int longitud, boolean horizontal) {
        for (int i = 0; i < longitud; i++) {
            if (horizontal) {
                oceano[f][c + i] = EstadoCasilla.BARCO;
            } else {
                oceano[f + i][c] = EstadoCasilla.BARCO;
            }
        }
    }

Renderizado y utilidades

Finalmente, necesitamos dibujar el tablero. La función imprimirOceano aplica el concepto de niebla de guerra.

  • Recorre la matriz celda por celda.
  • Si la celda contiene un BARCO y estamos en modo juego (revelarTodo == false), el programa miente al usuario e imprime el símbolo de AGUA.
  • Esto demuestra cómo separar los datos (lo que hay en memoria) de la vista (lo que ve el usuario).

También incluimos pedirCoordenada con hasNextInt() para evitar que el programa falle si el usuario introduce letras.

    // UTILIDADES Y VISTA

    static void inicializarOceano() {
        for (int f = 0; f < DIMENSION; f++) {
            for (int c = 0; c < DIMENSION; c++) {
                oceano[f][c] = EstadoCasilla.AGUA;
            }
        }
    }

    /**
     * Dibuja el tablero aplicando la lógica de ocultación.
     * @param revelarTodo Si es true, muestra los barcos (Game Over).
     */
    static void imprimirOceano(boolean revelarTodo) {
        System.out.println();

        // Eje de coordenadas X (Columnas)
        System.out.print("   ");
        for (int c = 0; c < DIMENSION; c++) {
            System.out.print(c + " ");
        }
        System.out.println();

        for (int f = 0; f < DIMENSION; f++) {
            System.out.print(f + "| "); // Eje de coordenadas Y (Filas)
            for (int c = 0; c < DIMENSION; c++) {
                EstadoCasilla actual = oceano[f][c];

                // LÓGICA DE NIEBLA DE GUERRA
                if (actual == EstadoCasilla.BARCO && !revelarTodo) {
                    // Si hay barco pero el juego sigue, ocultamos con agua
                    System.out.print(EstadoCasilla.AGUA + " ");
                } else {
                    // En cualquier otro caso, mostramos la realidad
                    System.out.print(actual + " ");
                }
            }
            System.out.println("|");
        }
    }

    /**
     * Lectura segura de enteros desde teclado.
     */
    static int pedirCoordenada(String mensaje) {
        int valor = NO_ENCONTRADO;
        boolean valido = false;

        while (!valido) {
            System.out.print(mensaje);
            if (teclado.hasNextInt()) {
                valor = teclado.nextInt();
                if (valor >= 0 && valor < DIMENSION) {
                    valido = true;
                } else {
                    System.out.println("Error: El número debe estar entre 0 y " + (DIMENSION - 1));
                }
            } else {
                teclado.next(); // Limpiar buffer de entrada errónea
                System.out.println("Error: Debes introducir un número entero.");
            }
        }
        return valor;
    }
}

Todo el código

import java.util.Random;
import java.util.Scanner;

/**
 * Juego de "Hundir la Flota" (Versión un jugador).
 * El objetivo es encontrar todos los barcos enemigos ocultos en el tablero
 * antes de que se agoten los intentos (misiles) disponibles.
 *
 * Este ejemplo refuerza el uso de matrices para ocultar información (niebla de guerra)
 * y la gestión de estados mediante Enums.
 */
public class Main {

    // --- ENUMS: CONFIGURACIÓN Y ESTÉTICA ---

    /**
     * Define los tipos de barcos y sus longitudes.
     */
    enum TipoBarco {
        PORTAAVIONES(5),
        ACORAZADO(4),
        CRUCERO(3),
        SUBMARINO(3),
        DESTRUCTOR(2);

        private final int longitud;

        TipoBarco(int longitud) {
            this.longitud = longitud;
        }

        public int getLongitud() {
            return longitud;
        }
    }

    /**
     * Enum que define los colores usando formato RGB (Red, Green, Blue).
     * Esto permite usar cualquier color de los 16 millones disponibles.
     */
    enum EstadoCasilla {
        // Símbolo y color en formato RGB
        AGUA     ( "~",      0, 180, 255),   // Azul cielo (agua)
        BARCO    ( "B",    160,  32, 240),   // Púrpura (barco sin impacto)
        TOCADO   ( "X",    255,   0,   0),   // Rojo puro (impacto)
        FALLO    ( "o",    180, 180, 180);   // Gris claro (fallo)

        private final String simbolo;
        private final String codigoColor;
        private static final String RESET = "\u001B[0m";

        /**
         * Constructor que acepta valores RGB (0-255).
         * Convierte los números a la secuencia ANSI TrueColor automáticamente.
         */
        EstadoCasilla(String simbolo, int r, int g, int b) {
            this.simbolo = simbolo;
            // La secuencia mágica para RGB es: \u001B[38;2;R;G;Bm
            this.codigoColor = String.format("\u001B[38;2;%d;%d;%dm", r, g, b);
        }

        @Override
        public String toString() {
            return codigoColor + simbolo + RESET;
        }
    }

    // --- CONSTANTES Y ESTADO GLOBAL ---

    static final int DIMENSION = 10;
    static final int NO_ENCONTRADO = -1;

    // Configuración de dificultad
    static final int MUNICION_MAXIMA = 50;
    static final int IMPACTOS_NECESARIOS = calcularPuntosTotales();

    // Matriz del tablero y herramientas
    static EstadoCasilla[][] oceano = new EstadoCasilla[DIMENSION][DIMENSION];
    static Scanner teclado = new Scanner(System.in);
    static Random radar = new Random();

    /**
     * Hilo principal de ejecución.
     * Gestiona el bucle de juego, el control de turnos y las condiciones de victoria/derrota.
     * @param args Argumentos de consola (no utilizados).
     */
    public static void main(String[] args) {
        System.out.println("--- HUNDIR LA FLOTA ---");
        System.out.println("Tablero: " + DIMENSION + "x" + DIMENSION);
        System.out.println("Regla especial: Los barcos no pueden tocarse entre sí.");
        System.out.println("Objetivo: " + IMPACTOS_NECESARIOS + " impactos.");
        System.out.println("Munición: " + MUNICION_MAXIMA + " misiles.");

        // 1. Preparación
        inicializarOceano();
        colocarFlotaCompleta();

        // 2. Variables de estado
        int aciertos = 0;
        int misilesRestantes = MUNICION_MAXIMA;
        boolean juegoTerminado = false;

        // 3. Bucle Principal
        while (!juegoTerminado) {
            imprimirOceano(false); // false = Modo Niebla de Guerra

            System.out.println("------------------------------------------------");
            System.out.println("Misiles: " + misilesRestantes + " | Aciertos: " + aciertos + "/" + IMPACTOS_NECESARIOS);

            // Turno de juego
            boolean impacto = realizarDisparo();
            misilesRestantes--;

            // Feedback inmediato
            if (impacto) {
                aciertos++;
                System.out.println(">>> ¡IMPACTO CONFIRMADO! <<<");
            } else {
                System.out.println(">>> Agua. Sin rastro del enemigo. <<<");
            }

            // Comprobación de fin de partida
            if (aciertos == IMPACTOS_NECESARIOS) {
                juegoTerminado = true;
                imprimirOceano(true); // Revelamos el mapa
                System.out.println("\n¡VICTORIA! Has desmantelado la flota enemiga.");

            } else if (misilesRestantes == 0) {
                juegoTerminado = true;
                imprimirOceano(true);
                System.out.println("\n¡MUNICIÓN AGOTADA! Retirada táctica.");
            }
        }
    }

    // --- LÓGICA DE JUEGO ---

    /**
     * Calcula dinámicamente cuántos aciertos hacen falta para ganar.
     * @return Total de casillas ocupadas por barcos.
     */
    static int calcularPuntosTotales() {
        int suma = 0;
        for (TipoBarco barco : TipoBarco.values()) {
            suma += barco.getLongitud();
        }
        return suma;
    }

    /**
     * Gestiona la interacción con el usuario para realizar un disparo.
     * Verifica que la coordenada sea válida y no se haya disparado antes allí.
     * @return true si el disparo acierta en un barco, false si falla.
     */
    static boolean realizarDisparo() {
        boolean disparoValido = false;
        int fila = NO_ENCONTRADO;
        int col = NO_ENCONTRADO;

        while (!disparoValido) {
            fila = pedirCoordenada("Fila (0-" + (DIMENSION - 1) + "): ");
            col = pedirCoordenada("Columna (0-" + (DIMENSION - 1) + "): ");

            if (oceano[fila][col] == EstadoCasilla.TOCADO || oceano[fila][col] == EstadoCasilla.FALLO) {
                System.out.println("Ya has disparado en esa zona. Elige otra.");
            } else {
                disparoValido = true;
            }
        }

        if (oceano[fila][col] == EstadoCasilla.BARCO) {
            oceano[fila][col] = EstadoCasilla.TOCADO;
            return true;
        } else {
            oceano[fila][col] = EstadoCasilla.FALLO;
            return false;
        }
    }

    // --- ALGORITMOS DE COLOCACIÓN (IA) ---

    /**
     * Recorre el catálogo de barcos y delega la colocación de cada uno.
     */
    static void colocarFlotaCompleta() {
        for (TipoBarco barco : TipoBarco.values()) {
            colocarBarcoAleatorio(barco);
        }
    }

    /**
     * Intenta colocar un barco en una posición aleatoria.
     * Si la posición elegida no es válida (choca, se toca con otro o se sale), repite el intento.
     * @param barco El tipo de barco a colocar.
     */
    static void colocarBarcoAleatorio(TipoBarco barco) {
        boolean colocado = false;

        while (!colocado) {
            int fila = radar.nextInt(DIMENSION);
            int col = radar.nextInt(DIMENSION);
            boolean horizontal = radar.nextBoolean();

            if (esPosicionValida(fila, col, barco.getLongitud(), horizontal)) {
                pintarBarcoEnMatriz(fila, col, barco.getLongitud(), horizontal);
                colocado = true;
            }
        }
    }

    /**
     * Verifica si el barco cabe y cumple la "Regla del Aire".
     * La regla del aire implica que no solo las casillas del barco deben estar libres,
     * sino también todas las casillas adyacentes (incluyendo diagonales).
     * @param f Fila inicial.
     * @param c Columna inicial.
     * @param longitud Tamaño del barco.
     * @param horizontal Orientación (true = horizontal, false = vertical).
     * @return true si el barco y su perímetro están libres.
     */
    static boolean esPosicionValida(int f, int c, int longitud, boolean horizontal) {
        // 1. Calculamos las dimensiones que ocupará el barco
        int anchoBarco = horizontal ? longitud : 1;
        int altoBarco  = horizontal ? 1 : longitud;

        // 2. Validar límites del tablero (Si se sale, devolvemos false)
        if (f + altoBarco > DIMENSION || c + anchoBarco > DIMENSION) {
            return false;
        }

        // 3. Definir el "Marco de Seguridad" (Barco + 1 casilla alrededor)
        // Usamos Math.max/min para no salirnos de los bordes (0 y 9)
        int filaInicio = Math.max(0, f - 1);
        int colInicio  = Math.max(0, c - 1);

        int filaFin = Math.min(DIMENSION - 1, f + altoBarco);
        int colFin  = Math.min(DIMENSION - 1, c + anchoBarco);

        // 4. Escanear esa área buscando obstáculos
        for (int i = filaInicio; i <= filaFin; i++) {
            for (int j = colInicio; j <= colFin; j++) {
                if (oceano[i][j] != EstadoCasilla.AGUA) {
                    return false; // Colisión detectada (barco o vecino)
                }
            }
        }

        return true; // Todo limpio
    }

    /**
     * Escribe el barco en la matriz una vez validada la posición.
     */
    static void pintarBarcoEnMatriz(int f, int c, int longitud, boolean horizontal) {
        for (int i = 0; i < longitud; i++) {
            if (horizontal) {
                oceano[f][c + i] = EstadoCasilla.BARCO;
            } else {
                oceano[f + i][c] = EstadoCasilla.BARCO;
            }
        }
    }

    // --- UTILIDADES Y VISTA ---

    /**
     * Limpia el tablero llenándolo de agua.
     */
    static void inicializarOceano() {
        for (int f = 0; f < DIMENSION; f++) {
            for (int c = 0; c < DIMENSION; c++) {
                oceano[f][c] = EstadoCasilla.AGUA;
            }
        }
    }

    /**
     * Dibuja el tablero en consola.
     * Utiliza la lógica de "Niebla de Guerra".
     * @param revelarTodo true para mostrar la ubicación de los barcos (Game Over).
     */
    static void imprimirOceano(boolean revelarTodo) {
        System.out.println();

        // Cabecera de columnas
        System.out.print("   ");
        for (int c = 0; c < DIMENSION; c++) {
            System.out.print(c + " ");
        }
        System.out.println();

        for (int f = 0; f < DIMENSION; f++) {
            System.out.print(f + "| "); // Índice de fila
            for (int c = 0; c < DIMENSION; c++) {
                EstadoCasilla actual = oceano[f][c];

                // Si es un barco y estamos jugando, lo mostramos como agua
                if (actual == EstadoCasilla.BARCO && !revelarTodo) {
                    System.out.print(EstadoCasilla.AGUA + " ");
                } else {
                    System.out.print(actual + " ");
                }
            }
            System.out.println("|");
        }
    }

    /**
     * Pide un número entero al usuario de forma segura.
     * @param mensaje Texto a mostrar.
     * @return Un entero validado dentro del rango del tablero.
     */
    static int pedirCoordenada(String mensaje) {
        int valor = NO_ENCONTRADO;
        boolean valido = false;

        while (!valido) {
            System.out.print(mensaje);
            if (teclado.hasNextInt()) {
                valor = teclado.nextInt();
                if (valor >= 0 && valor < DIMENSION) {
                    valido = true;
                } else {
                    System.out.println("Error: El número debe estar entre 0 y " + (DIMENSION - 1));
                }
            } else {
                teclado.next(); // Limpiar el buffer
                System.out.println("Error: Debes introducir un número entero.");
            }
        }
        return valor;
    }
}

Programando el juego Conecta 4 en Java paso a paso

Introducción

El desarrollo de videojuegos de consola es una de las herramientas pedagógicas más recomendadas para comprender la programación estructurada. En esta unidad, no nos limitaremos a «copiar y pegar» código; realizaremos una disección técnica completa de una implementación del clásico Conecta 4.

Analizaremos el uso de enumerados (enums) para la seguridad de tipos, la gestión de la memoria en arrays bidimensionales, la validación robusta de entradas de usuario y, finalmente, cómo implementar una Inteligencia Artificial basada en algoritmos de simulación y backtracking.

En este enlace puedes encontrar una presentación explicando el código, y a continuación dispones de una infografía y un vídeo explicativo.

Estructuras de Datos: El uso de Enum frente a enteros

En programas más antiguos o muy simples, es común ver el uso de números enteros para representar estados (ej: 0 para vacío, 1 para jugador). Sin embargo, esto es una mala práctica conocida como «números mágicos», ya que carecen de significado semántico y pueden llevar a errores si asignamos un número no válido (ej: tablero[0][0] = 99).

Para solucionar esto, utilizamos tipos enumerados (enum).

Enumerado Dificultad

Este enumerado define los niveles de inteligencia de la máquina. Observad que no es una lista simple; cada elemento (FACIL, MEDIO, DIFICIL) invoca a un constructor que almacena una descripción de texto.

Al sobrescribir el método toString(), permitimos que, al imprimir la dificultad por pantalla, se muestre automáticamente el texto amigable («Fácil (Aleatorio)») en lugar del nombre técnico de la constante.

    enum Dificultad {
        FACIL("Fácil (Aleatorio)"),
        MEDIO("Medio (Defensivo)"),
        DIFICIL("Difícil (Inteligente)");

        private final String descripcion;

        Dificultad(String descripcion) {
            this.descripcion = descripcion;
        }

        @Override
        public String toString() {
            return descripcion;
        }
    }

Enumerado Ficha

Define los tres estados posibles de una celda en el tablero.

Debemos destacar que con este código llevamos a cabo separación de responsabilidades. Este enumerado vincula la lógica del juego (JUGADOR, MAQUINA) con su representación visual (X, O). Si mañana quisiéramos cambiar la X por un color o un emoji, solo tendríamos que modificar la cadena de texto en este enumerado, sin tocar el resto del código.

    enum Ficha {
        VACIO("-"),
        JUGADOR("X"),
        MAQUINA("O");

        private final String simbolo;

        Ficha(String simbolo) {
            this.simbolo = simbolo;
        }

        @Override
        public String toString() {
            return simbolo;
        }
    }

Configuración del entorno y constantes

Antes de iniciar la lógica, debemos preparar el «terreno de juego». En Java, dentro del paradigma estructurado en una clase principal, utilizamos variables y constantes static.

  • Constantes (final): Definimos FILAS y COLUMNAS para evitar escribir 6 y 7 repetidamente. Esto facilita la escalabilidad: si queremos cambiar el tamaño del tablero a 10×10, solo modificamos estas dos líneas.
  • La constante NO_ENCONTRADO: Asignamos el valor -1 a una constante con nombre descriptivo. En programación, esto mejora la legibilidad. Es mucho más claro leer if (resultado == NO_ENCONTRADO) que if (resultado == -1).
  • El tablero: Declaramos una matriz de tipo Ficha. Inicialmente, Java llenará esto con null, por lo que deberemos inicializarlo más adelante.
    // Dimensiones del tablero
    static final int FILAS = 6;
    static final int COLUMNAS = 7;

    // Constante para indicar que una búsqueda no ha tenido éxito.
    static final int NO_ENCONTRADO = -1;

    // Estado global del juego
    static Ficha[][] tablero = new Ficha[FILAS][COLUMNAS];
    static Scanner teclado = new Scanner(System.in);
    static Random azar = new Random();

El flujo principal (main) y validación de entrada

El método main actúa como director de orquesta. Su función es inicializar el tablero, gestionar la selección de dificultad y ejecutar el bucle principal del juego (Game Loop).

Generación dinámica de menús

Fijaos en el bucle for que imprime el menú. No escribimos las opciones a mano. Usamos Dificultad.values() para iterar sobre los elementos del enumerado. Esto significa que el menú se autogenera basándose en la definición del enum:

        // Generamos el menú dinámicamente
        for (Dificultad d : Dificultad.values()) {
            System.out.println((d.ordinal() + 1) + ". " + d);
        }

Validación de entrada de datos (Scanner)

Uno de los puntos críticos es leer un número del teclado. Si el usuario introduce una letra, el método nextInt() lanzaría una excepción InputMismatchException y el programa terminaría abruptamente.

Para evitarlo, implementamos un patrón de comprobación:

  1. teclado.hasNextInt(): Preguntamos al buffer de entrada si lo siguiente es un entero.
  2. Si es : Leemos el dato y comprobamos que esté en el rango correcto (1-3).
  3. Si es No: Es vital ejecutar teclado.next(). Esta instrucción «consume» y elimina la entrada errónea (la letra) del buffer. Si no hiciéramos esto, el bucle while leería la misma letra infinitamente, bloqueando el programa en el bucle.

Código del método principal

    public static void main(String[] args) {
        inicializarTablero();

        System.out.println("--- CUATRO EN RAYA ---");
        System.out.println("Elige dificultad:");

        // Generamos el menú dinámicamente
        for (Dificultad d : Dificultad.values()) {
            System.out.println((d.ordinal() + 1) + ". " + d);
        }

        // Selección y validación de la dificultad
        Dificultad dificultadSeleccionada = null;

        while (dificultadSeleccionada == null) {
            System.out.print("Opción: ");
            if (teclado.hasNextInt()) {
                int opcion = teclado.nextInt();
                // Verificamos rango válido (1 a N)
                if (opcion > 0 && opcion <= Dificultad.values().length) {
                    dificultadSeleccionada = Dificultad.values()[opcion - 1];
                } else {
                    System.out.println("Número no válido. Elige entre 1 y " + Dificultad.values().length);
                }
            } else {
                teclado.next(); // IMPORTANTE: Consumir entrada incorrecta
                System.out.println("Por favor, introduce un número entero.");
            }
        }

        System.out.println("Has elegido: " + dificultadSeleccionada);

        // Bucle principal del juego
        boolean turnoJugador = true;
        boolean juegoTerminado = false;

        while (!juegoTerminado) {
            imprimirTablero();

            if (turnoJugador) {
                hacerMovimientoJugador();
            } else {
                hacerMovimientoMaquina(dificultadSeleccionada);
            }

            // Verificación de estado tras el movimiento
            Ficha fichaActual = turnoJugador ? Ficha.JUGADOR : Ficha.MAQUINA;

            if (comprobarVictoria(fichaActual)) {
                imprimirTablero();
                System.out.println(turnoJugador ? "¡ENHORABUENA! Has ganado." : "FIN. Ha ganado el ordenador.");
                juegoTerminado = true;
            } else if (tableroLleno()) {
                imprimirTablero();
                System.out.println("¡EMPATE! No quedan casillas libres.");
                juegoTerminado = true;
            }

            // Alternar turno
            turnoJugador = !turnoJugador;
        }
    }

Lógica de interacción del jugador

Esta función encapsula la interacción humana. Se asegura de no permitir avanzar al juego hasta que el jugador haya introducido una columna que cumpla dos condiciones:

  1. Ser un número entero dentro de los límites (0-6).
  2. Ser una columna con espacio disponible (no llena hasta el tope).

Nuevamente, utilizamos el patrón hasNextInt() para prevenir errores de tipo de dato.

    static void hacerMovimientoJugador() {
        boolean movimientoValido = false;
        int c;

        while (!movimientoValido) {
            System.out.print("Tu turno (Columna 0-" + (COLUMNAS - 1) + "): ");

            if (teclado.hasNextInt()) {
                c = teclado.nextInt();
                if (columnaValida(c)) {
                    colocarFicha(c, Ficha.JUGADOR);
                    movimientoValido = true;
                } else {
                    System.out.println("Movimiento no válido (columna llena o fuera de rango).");
                }
            } else {
                teclado.next();
                System.out.println("Por favor, introduce un número entero.");
            }
        }
    }

Algoritmos de Decisión

Aquí reside la lógica más avanzada del programa. No pretendemos jugar simplemente al azar (a menos que esté en modo fácil), si no que debemos intentar tomar decisiones basadas en prioridades.

Sistema de prioridades en cascada

La función hacerMovimientoMaquina evalúa la situación en orden de importancia:

  1. Ataque (modo difícil): ¿Puedo ganar en este turno? Si la respuesta es sí, lo hace inmediatamente.
  2. Defensa (modo medio/difícil): ¿Puede ganar el rival en su siguiente turno? Si la respuesta es sí, la máquina debe bloquear esa columna obligatoriamente.
  3. Movimiento aleatorio: Si no hay victoria inminente ni amenaza de derrota, juega en cualquier columna válida al azar.

Algoritmo de simulación (backtracking simplificado)

La función buscarMovimientoGanador implementa una técnica muy habitual en estos casos: la simulación. El ordenador «imagina» qué pasaría si colocara una ficha en cada columna:

  1. Hacer: Coloca una ficha temporalmente (tablero[f][c] = ficha).
  2. Verificar: Llama a comprobarVictoria para ver si esa jugada resulta en éxito.
  3. Deshacer (backtracking): Es fundamental retirar la ficha (tablero[f][c] = Ficha.VACIO) antes de continuar el bucle. Si no hiciéramos esto, el tablero se llenaría de fichas de prueba y la partida se corrompería.

Código de movimientos

    static void hacerMovimientoMaquina(Dificultad dificultad) {
        System.out.println("Turno del ordenador...");

        int columnaElegida = NO_ENCONTRADO;

        // 1. PRIORIDAD DE ATAQUE
        if (dificultad == Dificultad.DIFICIL) {
            columnaElegida = buscarMovimientoGanador(Ficha.MAQUINA);
        }

        // 2. PRIORIDAD DE DEFENSA
        if (columnaElegida == NO_ENCONTRADO && (dificultad == Dificultad.MEDIO || dificultad == Dificultad.DIFICIL)) {
            columnaElegida = buscarMovimientoGanador(Ficha.JUGADOR);
        }

        // 3. MOVIMIENTO POR DEFECTO (ALEATORIO)
        if (columnaElegida == NO_ENCONTRADO) {
            do {
                columnaElegida = azar.nextInt(COLUMNAS);
            } while (!columnaValida(columnaElegida));
        }

        colocarFicha(columnaElegida, Ficha.MAQUINA);
    }

    static int buscarMovimientoGanador(Ficha ficha) {
        int columnaGanadora = NO_ENCONTRADO;

        for (int c = 0; c < COLUMNAS && columnaGanadora == NO_ENCONTRADO; c++) {
            if (columnaValida(c)) {
                int f = obtenerFilaLibre(c);

                // Simular jugada
                tablero[f][c] = ficha;

                // Comprobar resultado
                if (comprobarVictoria(ficha)) {
                    columnaGanadora = c;
                }

                // Deshacer jugada (Backtracking)
                tablero[f][c] = Ficha.VACIO;
            }
        }
        return columnaGanadora;
    }

Física del tablero: gravedad y renderizado

Estas funciones auxiliares manejan la lógica interna de la matriz.

  • inicializarTablero: Recorre la matriz estableciendo Ficha.VACIO. Es necesario porque al crear el array, Java lo llena con null.
  • imprimirTablero: Recorre la matriz e imprime el método .toString() de cada ficha (definido en el enum).
  • columnaValida: Verifica si la columna está dentro de los límites y si la fila superior (0) está vacía.
  • obtenerFilaLibre: Simulación de gravedad. A diferencia de una lectura normal, recorremos la columna de abajo hacia arriba (desde FILAS - 1 hasta 0). La primera casilla vacía encontrada es donde la ficha se «posará».
    static void inicializarTablero() {
        for (int f = 0; f < FILAS; f++) {
            for (int c = 0; c < COLUMNAS; c++) {
                tablero[f][c] = Ficha.VACIO;
            }
        }
    }

    static void imprimirTablero() {
        System.out.println();
        System.out.print(" ");
        for(int c = 0; c < COLUMNAS; c++) System.out.print(c + " ");
        System.out.println();
        System.out.println("---------------");
        for (int f = 0; f < FILAS; f++) {
            System.out.print("|");
            for (int c = 0; c < COLUMNAS; c++) {
                System.out.print(tablero[f][c] + "|");
            }
            System.out.println();
        }
        System.out.println("---------------");
    }

    static boolean columnaValida(int c) {
        return c >= 0 && c < COLUMNAS && tablero[0][c] == Ficha.VACIO;
    }

    static int obtenerFilaLibre(int c) {
        int filaEncontrada = NO_ENCONTRADO;
        // Bucle inverso para simular gravedad
        for (int f = FILAS - 1; f >= 0 && filaEncontrada == NO_ENCONTRADO; f--) {
            if (tablero[f][c] == Ficha.VACIO) {
                filaEncontrada = f;
            }
        }
        return filaEncontrada;
    }

    static void colocarFicha(int c, Ficha ficha) {
        int f = obtenerFilaLibre(c);
        if (f != NO_ENCONTRADO) {
            tablero[f][c] = ficha;
        }
    }

    static boolean tableroLleno() {
        for (int c = 0; c < COLUMNAS; c++) {
            if (tablero[0][c] == Ficha.VACIO) {
                return false;
            }
        }
        return true;
    }

Verificación de victoria

Esta es la función algorítmicamente más densa. Para detectar una victoria, debemos buscar 4 fichas consecutivas idénticas en cuatro direcciones: Horizontal, Vertical, Diagonal ascendente y Diagonal descendente.

En este paso es habitual cometer el error de no comprobar bien los límites del Array. Si intentamos buscar 4 fichas a la derecha estando en la última columna, obtendremos un error ArrayIndexOutOfBoundsException. Por ello, ajustamos los límites de los bucles for:

  • Horizontal: El bucle de columnas solo llega hasta COLUMNAS - 3.
  • Vertical: El bucle de filas solo llega hasta FILAS - 3.
  • Diagonales: Combinan ambas restricciones.
    static boolean comprobarVictoria(Ficha ficha) {
        // Horizontal (-)
        for (int f = 0; f < FILAS; f++) {
            for (int c = 0; c < COLUMNAS - 3; c++) {
                if (tablero[f][c] == ficha &&
                    tablero[f][c+1] == ficha &&
                    tablero[f][c+2] == ficha &&
                    tablero[f][c+3] == ficha) return true;
            }
        }

        // Vertical (|)
        for (int f = 0; f < FILAS - 3; f++) {
            for (int c = 0; c < COLUMNAS; c++) {
                if (tablero[f][c] == ficha &&
                    tablero[f+1][c] == ficha &&
                    tablero[f+2][c] == ficha &&
                    tablero[f+3][c] == ficha) return true;
            }
        }

        // Diagonal Ascendente (/)
        // Empieza en fila 3 porque necesita espacio hacia arriba
        for (int f = 3; f < FILAS; f++) {
            for (int c = 0; c < COLUMNAS - 3; c++) {
                if (tablero[f][c] == ficha &&
                    tablero[f-1][c+1] == ficha &&
                    tablero[f-2][c+2] == ficha &&
                    tablero[f-3][c+3] == ficha) return true;
            }
        }

        // Diagonal Descendente (\)
        for (int f = 0; f < FILAS - 3; f++) {
            for (int c = 0; c < COLUMNAS - 3; c++) {
                if (tablero[f][c] == ficha &&
                    tablero[f+1][c+1] == ficha &&
                    tablero[f+2][c+2] == ficha &&
                    tablero[f+3][c+3] == ficha) return true;
            }
        }

        return false;
    }

Todo el código

import java.util.Random;
import java.util.Scanner;

/**
 * Juego de Cuatro en Raya para consola.
 * Este programa implementa una versión del clásico juego utilizando programación estructurada.
 * Se hace uso de Enums para gestionar estados y dificultades.
 * Se evita el uso de "break" para mantener un flujo de control limpio y predecible.
 */
public class Main {

    /**
     * Define los niveles de dificultad del juego.
     * Cada nivel contiene su propia descripción textual para facilitar la generación de menús.
     */
    enum Dificultad {
        FACIL("Fácil (Aleatorio)"),
        MEDIO("Medio (Defensivo)"),
        DIFICIL("Difícil (Inteligente)");

        private final String descripcion;

        Dificultad(String descripcion) {
            this.descripcion = descripcion;
        }

        @Override
        public String toString() {
            return descripcion;
        }
    }

    /**
     * Representa los posibles estados de una casilla en el tablero.
     * Encapsula el símbolo visual asociado a cada estado para separar lógica de presentación.
     */
    enum Ficha {
        VACIO("-"),
        JUGADOR("X"),
        MAQUINA("O");

        private final String simbolo;

        Ficha(String simbolo) {
            this.simbolo = simbolo;
        }

        @Override
        public String toString() {
            return simbolo;
        }
    }

    // --- CONFIGURACIÓN DEL JUEGO ---

    // Dimensiones del tablero
    static final int FILAS = 6;
    static final int COLUMNAS = 7;

    // Constante para indicar que una búsqueda no ha tenido éxito.
    // Usamos esto en lugar de -1 directamente para mejorar la legibilidad.
    static final int NO_ENCONTRADO = -1;

    // Estado global del juego
    static Ficha[][] tablero = new Ficha[FILAS][COLUMNAS];
    static Scanner teclado = new Scanner(System.in);
    static Random azar = new Random();

    /**
     * Punto de entrada principal del programa.
     * Orquesta el flujo de la aplicación: configuración inicial, bucle de juego y finalización.
     * @param args Argumentos de consola (no utilizados).
     */
    public static void main(String[] args) {
        inicializarTablero();

        System.out.println("--- CUATRO EN RAYA ---");
        System.out.println("Elige dificultad:");

        // Generamos el menú dinámicamente basándonos en los valores del Enum.
        // Esto permite añadir nuevas dificultades en el futuro sin modificar este código.
        for (Dificultad d : Dificultad.values()) {
            System.out.println((d.ordinal() + 1) + ". " + d);
        }

        // Selección y validación de la dificultad
        Dificultad dificultadSeleccionada = null;

        while (dificultadSeleccionada == null) {
            System.out.print("Opción: ");
            if (teclado.hasNextInt()) {
                int opcion = teclado.nextInt();
                // Verificamos rango válido (1 a N)
                if (opcion > 0 && opcion <= Dificultad.values().length) {
                    dificultadSeleccionada = Dificultad.values()[opcion - 1];
                } else {
                    System.out.println("Número no válido. Elige entre 1 y " + Dificultad.values().length);
                }
            } else {
                teclado.next(); // Consumir entrada incorrecta para evitar bucle infinito
                System.out.println("Por favor, introduce un número entero.");
            }
        }

        System.out.println("Has elegido: " + dificultadSeleccionada);

        // Bucle principal del juego
        boolean turnoJugador = true;
        boolean juegoTerminado = false;

        while (!juegoTerminado) {
            imprimirTablero();

            if (turnoJugador) {
                hacerMovimientoJugador();
            } else {
                hacerMovimientoMaquina(dificultadSeleccionada);
            }

            // Determinamos qué ficha se acaba de colocar para verificar victoria
            Ficha fichaActual = turnoJugador ? Ficha.JUGADOR : Ficha.MAQUINA;

            if (comprobarVictoria(fichaActual)) {
                imprimirTablero();
                System.out.println(turnoJugador ? "¡ENHORABUENA! Has ganado." : "FIN. Ha ganado el ordenador.");
                juegoTerminado = true;
            } else if (tableroLleno()) {
                imprimirTablero();
                System.out.println("¡EMPATE! No quedan casillas libres.");
                juegoTerminado = true;
            }

            // Alternar turno
            turnoJugador = !turnoJugador;
        }
    }

    // --- LÓGICA DE TURNOS ---

    /**
     * Gestiona el turno del jugador humano.
     * Solicita una columna repetidamente hasta que el usuario introduce una válida.
     */
    static void hacerMovimientoJugador() {
        boolean movimientoValido = false;
        int c;

        // Bucle controlado por bandera (flag) para evitar el uso de 'break'
        while (!movimientoValido) {
            // Usamos las constantes para que el texto coincida siempre con el tamaño real del tablero
            System.out.print("Tu turno (Columna 0-" + (COLUMNAS - 1) + "): ");

            if (teclado.hasNextInt()) {
                c = teclado.nextInt();
                if (columnaValida(c)) {
                    colocarFicha(c, Ficha.JUGADOR);
                    movimientoValido = true;
                } else {
                    System.out.println("Movimiento no válido (columna llena o fuera de rango).");
                }
            } else {
                teclado.next();
                System.out.println("Por favor, introduce un número entero.");
            }
        }
    }

    /**
     * Ejecuta la lógica de la Inteligencia Artificial.
     * Decide el movimiento basándose en un sistema de prioridades en cascada.
     * @param dificultad Nivel de inteligencia seleccionado.
     */
    static void hacerMovimientoMaquina(Dificultad dificultad) {
        System.out.println("Turno del ordenador...");

        int columnaElegida = NO_ENCONTRADO;

        // PRIORIDAD DE ATAQUE: Si puedo ganar, lo hago. (Solo en modo Difícil)
        if (dificultad == Dificultad.DIFICIL) {
            columnaElegida = buscarMovimientoGanador(Ficha.MAQUINA);
        }

        // PRIORIDAD DE DEFENSA: Si no tengo ataque, bloqueo al rival si va a ganar.
        // (Disponible en Medio y Difícil)
        if (columnaElegida == NO_ENCONTRADO && (dificultad == Dificultad.MEDIO || dificultad == Dificultad.DIFICIL)) {
            columnaElegida = buscarMovimientoGanador(Ficha.JUGADOR);
        }

        // MOVIMIENTO POR DEFECTO: Si no hay estrategia, elijo al azar.
        if (columnaElegida == NO_ENCONTRADO) {
            do {
                columnaElegida = azar.nextInt(COLUMNAS);
            } while (!columnaValida(columnaElegida));
        }

        colocarFicha(columnaElegida, Ficha.MAQUINA);
    }

    /**
     * Simula colocar una ficha en cada columna disponible para ver si produce una victoria.
     * Utiliza la técnica de "Backtracking" (colocar, comprobar, retirar).
     * @param ficha La ficha para la cual buscamos la victoria (propia o del rival).
     * @return El índice de la columna ganadora o NO_ENCONTRADO si no existe.
     */
    static int buscarMovimientoGanador(Ficha ficha) {
        int columnaGanadora = NO_ENCONTRADO;

        // Recorremos las columnas. La condición 'columnaGanadora == NO_ENCONTRADO'
        // detiene el bucle eficientemente en cuanto encontramos una solución.
        for (int c = 0; c < COLUMNAS && columnaGanadora == NO_ENCONTRADO; c++) {
            if (columnaValida(c)) {
                int f = obtenerFilaLibre(c);

                // Simular jugada
                tablero[f][c] = ficha;

                // Comprobar resultado
                if (comprobarVictoria(ficha)) {
                    columnaGanadora = c;
                }

                // Deshacer jugada (dejar el tablero como estaba)
                tablero[f][c] = Ficha.VACIO;
            }
        }
        return columnaGanadora;
    }

    // --- FUNCIONES DEL TABLERO ---

    /**
     * Inicializa el tablero llenando todas las celdas con símbolo VACIO.
     */
    static void inicializarTablero() {
        for (int f = 0; f < FILAS; f++) {
            for (int c = 0; c < COLUMNAS; c++) {
                tablero[f][c] = Ficha.VACIO;
            }
        }
    }

    /**
     * Dibuja el estado actual del tablero en la consola.
     * Utiliza el método toString() de cada Ficha para la representación visual.
     */
    static void imprimirTablero() {
        System.out.println();

        // Cabecera con números de columna
        System.out.print(" ");
        for(int c = 0; c < COLUMNAS; c++) {
            System.out.print(c + " ");
        }
        System.out.println();

        System.out.println("---------------");
        for (int f = 0; f < FILAS; f++) {
            System.out.print("|");
            for (int c = 0; c < COLUMNAS; c++) {
                System.out.print(tablero[f][c] + "|");
            }
            System.out.println();
        }
        System.out.println("---------------");
    }

    /**
     * Verifica si es legal colocar una ficha en la columna indicada.
     * @param c Índice de la columna.
     * @return true si la columna está dentro del rango y tiene espacio libre arriba.
     */
    static boolean columnaValida(int c) {
        return c >= 0 && c < COLUMNAS && tablero[0][c] == Ficha.VACIO;
    }

    /**
     * Busca la primera posición libre en una columna, empezando desde abajo.
     * Simula el efecto de la gravedad.
     * @param c Columna donde buscar.
     * @return El índice de la fila libre o NO_ENCONTRADO si la columna está llena.
     */
    static int obtenerFilaLibre(int c) {
        int filaEncontrada = NO_ENCONTRADO;

        // Recorremos de abajo hacia arriba. Paramos al encontrar el primer hueco.
        for (int f = FILAS - 1; f >= 0 && filaEncontrada == NO_ENCONTRADO; f--) {
            if (tablero[f][c] == Ficha.VACIO) {
                filaEncontrada = f;
            }
        }
        return filaEncontrada;
    }

    /**
     * Coloca una ficha de manera definitiva en el tablero.
     * Se asume que la validación de la columna se ha hecho previamente.
     * @param c Columna seleccionada.
     * @param ficha Tipo de ficha a colocar.
     */
    static void colocarFicha(int c, Ficha ficha) {
        int f = obtenerFilaLibre(c);

        if (f != NO_ENCONTRADO) {
            tablero[f][c] = ficha;
        }
    }

    /**
     * Comprueba si el tablero está lleno (condición de empate).
     * Basta con verificar si quedan huecos en la fila superior de alguna columna.
     * @return true si el tablero está completo y no caben más fichas.
     */
    static boolean tableroLleno() {
        // Si encontramos al menos una casilla vacía arriba, el tablero NO está lleno.
        for (int c = 0; c < COLUMNAS; c++) {
            // El 'return' hace que salgamos de la función inmediatamente.
            if (tablero[0][c] == Ficha.VACIO) {
                return false;
            }
        }
        // Si el bucle termina sin haber ejecutado el return anterior,
        // significa que no había huecos: el tablero está lleno.
        return true;
    }

    /**
     * Algoritmo de comprobación de victoria.
     * Verifica secuencias de 4 fichas iguales en horizontal, vertical y diagonales.
     * @param ficha La ficha que acaba de jugar (JUGADOR o MAQUINA).
     * @return true si hay 4 en raya.
     */
    static boolean comprobarVictoria(Ficha ficha) {
        // Horizontal (-)
        for (int f = 0; f < FILAS; f++) {
            for (int c = 0; c < COLUMNAS - 3; c++) {
                if (tablero[f][c] == ficha &&
                    tablero[f][c+1] == ficha &&
                    tablero[f][c+2] == ficha &&
                    tablero[f][c+3] == ficha) return true;
            }
        }

        // Vertical (|)
        for (int f = 0; f < FILAS - 3; f++) {
            for (int c = 0; c < COLUMNAS; c++) {
                if (tablero[f][c] == ficha &&
                    tablero[f+1][c] == ficha &&
                    tablero[f+2][c] == ficha &&
                    tablero[f+3][c] == ficha) return true;
            }
        }

        // Diagonal Ascendente (/)
        for (int f = 3; f < FILAS; f++) {
            for (int c = 0; c < COLUMNAS - 3; c++) {
                if (tablero[f][c] == ficha &&
                    tablero[f-1][c+1] == ficha &&
                    tablero[f-2][c+2] == ficha &&
                    tablero[f-3][c+3] == ficha) return true;
            }
        }

        // Diagonal Descendente (\)
        for (int f = 0; f < FILAS - 3; f++) {
            for (int c = 0; c < COLUMNAS - 3; c++) {
                if (tablero[f][c] == ficha &&
                    tablero[f+1][c+1] == ficha &&
                    tablero[f+2][c+2] == ficha &&
                    tablero[f+3][c+3] == ficha) return true;
            }
        }

        return false;
    }
}

Laberinto, un juego arcade con música, nave, obstáculos, colisiones, enemigos, niveles, imagen de fondo y game over, hecho con Godot

Las imágenes y los ficheros de audio

Para nuestro juego necesitaremos varias imágenes y ficheros de audio. Para poder incluirlas en nuestro proyecto, primero debemos descargarlas en nuestro ordenador, y a continuación las arrastraremos a la zona de «Sistema de Archivos» de Godot. Las tienes disponibles en este archivo ZIP.

Pasos a seguir

Esqueleto del juego (todas las funciones vacías)

extends Node2D

# ==============================================================================
# CONFIGURACIÓN Y CONSTANTES
# ==============================================================================

# --- TAMAÑOS Y AJUSTES ---
const TAM_CELDA = 100    # Píxeles por bloque
const MARGEN    = 5      # Píxeles de separación interna

# --- VELOCIDADES ---
const VEL_JUGADOR = 350
const VEL_ENEMIGO = 150

# --- CARGA DE IMÁGENES ---
const TEX_FONDO     = preload("res://assets/fondo.png")
const TEX_JUGADOR   = preload("res://assets/jugador.png")
const TEX_ENEMIGO   = preload("res://assets/enemigo.png")
const TEX_PARED     = preload("res://assets/bloque.png")
const TEX_META      = preload("res://assets/meta.png")
const TEX_CERRAR    = preload("res://assets/boton_cerrar.png")
const TEX_REINICIAR = preload("res://assets/boton_reiniciar.png")
const TEX_GAMEOVER  = preload("res://assets/game_over.png")
const TEX_SIGUIENTE = preload("res://assets/siguiente_nivel.png")

# --- CARGA DE AUDIO ---
const AUDIO_FONDO   = preload("res://assets/musica_fondo.mp3")
const AUDIO_MUERTE  = preload("res://assets/sfx_muerte.mp3")

# ==============================================================================
# VARIABLES GLOBALES
# ==============================================================================

var pantalla: Vector2
var tam_tablero: Vector2
var celda_dim: Vector2

var paredes: Array[Rect2] = []
var enemigos: Array[Dictionary] = []
var jugador: Rect2
var meta: Rect2

var nivel_actual: int = 1
var game_over: bool = false
var siguiente_nivel: bool = false

var btn_cerrar: Rect2
var btn_reiniciar: Rect2

var music_player: AudioStreamPlayer
var sfx_player: AudioStreamPlayer

# ==============================================================================
# 1. HERRAMIENTAS BÁSICAS
# ==============================================================================

func _crear_rect(x_grid, y_grid, margen_interno):
	# Ayuda a convertir coordenadas de la cuadrícula (ej: 2,3) a píxeles reales en pantalla.
	pass

func _es_zona_segura(pos: Vector2):
	# Devuelve true si la coordenada es inicio, fin o esquinas clave.
	pass

func _colisiona_con_paredes(rect: Rect2):
	# Comprueba si un rectángulo intercepta alguna pared del array.
	pass

func _reproducir_sonido(stream):
	# Función auxiliar para reproducir un efecto de sonido puntual.
	pass

func _es_posicion_valida(rect: Rect2):
	# Valida si un objeto está dentro de la pantalla y libre de paredes.
	pass

# ==============================================================================
# 2. GENERACIÓN DE DATOS (Lógica de Nivel)
# ==============================================================================

func _generar_paredes(cantidad):
	# Coloca obstáculos aleatorios asegurándose de no bloquear el inicio o fin.
	pass

func _generar_enemigos(cantidad):
	# Genera enemigos solo en la mitad inferior para dar tiempo al jugador.
	pass

func _iniciar_nivel():
	# Resetea las variables y genera un nuevo mapa limpio.
	# Calcula la dificultad basada en el nivel actual.
	pass

# ==============================================================================
# 3. CONFIGURACIÓN Y VISUALIZACIÓN
# ==============================================================================

func _ready():
	# Inicialización básica: Configura el tamaño de pantalla, la cuadrícula y el audio.
	# Se ejecuta una única vez al arrancar la escena.
	pass

func _draw():
	# Dibuja texturas, enemigos, jugador e interfaz en pantalla.
	# IMPORTANTE: El orden importa. Lo primero que se dibuja queda al fondo.
	pass

# ==============================================================================
# 4. MOVIMIENTO Y FÍSICAS
# ==============================================================================

func _mover_jugador(delta):
	# Calcula el movimiento del jugador y evita que atraviese paredes.
	pass

func _mover_enemigos(delta):
	# Mueve enemigos verticalmente y los hace rebotar al chocar.
	pass

func _comprobar_colisiones():
	# Verifica condiciones de victoria o derrota por colisión.
	pass

# ==============================================================================
# 5. BUCLE PRINCIPAL E INPUT
# ==============================================================================

func _process(delta):
	# Bucle principal: Gestiona movimiento y colisiones en cada frame.
	pass

func _input(event):
	# Gestiona clics del ratón para botones y reinicio de juego.
	pass

crear_rect()

func _crear_rect(x_grid, y_grid, margen_interno) -> Rect2:
	# Ayuda a convertir coordenadas de la cuadrícula (ej: 2,3) a píxeles reales en pantalla.
	
	# Retorna un Rect2. La posición X es (columna * ancho_celda) + margen.
	# El tamaño es (ancho_celda - doble margen) para que quede centrado.
	return Rect2(
		x_grid * celda_dim.x + margen_interno,
		y_grid * celda_dim.y + margen_interno,
		celda_dim.x - margen_interno * 2,
		celda_dim.y - margen_interno * 2
	)

es_zona_segura()

func _es_zona_segura(pos: Vector2) -> bool:
	# Devuelve true si la coordenada es inicio, fin o esquinas clave.
	
	if pos == Vector2(0, 0): return true # Esquina Jugador
	if pos == Vector2(tam_tablero.x - 1, tam_tablero.y - 1): return true # Esquina Meta
	# Protegemos las otras dos esquinas para evitar bloqueos imposibles en mapas pequeños
	if pos == Vector2(tam_tablero.x - 1, 0): return true
	if pos == Vector2(0, tam_tablero.y - 1): return true
	return false

colisiona_con_paredes()

func _colisiona_con_paredes(rect: Rect2) -> bool:
	# Comprueba si un rectángulo intercepta alguna pared del array.
	
	for pared in paredes:
		# Usamos .grow(-1) para reducir un píxel el hitbox de chequeo.
		# Esto evita que detecte colisión si solo se están rozando lado con lado.
		if rect.grow(-1).intersects(pared): return true
	return false

reproducir_sonido()

func _reproducir_sonido(stream):
	# Función auxiliar para reproducir un efecto de sonido puntual.
	sfx_player.stream = stream
	sfx_player.play()

es_posicion_valida()

func _es_posicion_valida(rect: Rect2) -> bool:
	# Valida si un objeto está dentro de la pantalla y libre de paredes.
	
	# 'encloses' devuelve true si el rect está TOTALMENTE dentro de la pantalla
	if not get_viewport_rect().encloses(rect): return false
	
	# Si choca con pared, devuelve false (posición no válida)
	return not _colisiona_con_paredes(rect)

generar_paredes()

func _generar_paredes(cantidad):
	# Coloca obstáculos aleatorios asegurándose de no bloquear el inicio o fin.
	
	var intentos = 0
	# Usamos un while con límite de intentos para evitar bucles infinitos si no hay sitio
	while paredes.size() < cantidad and intentos < 1000:
		intentos += 1
		
		# Elegimos una columna y fila al azar
		var pos = Vector2(randi() % int(tam_tablero.x), randi() % int(tam_tablero.y))
		
		# Si la posición elegida es vital (inicio/fin), la descartamos y probamos otra
		if _es_zona_segura(pos): continue
		
		# Creamos el rectángulo provisional
		var nueva_pared = _crear_rect(pos.x, pos.y, 0)
		
		# Verificamos que no caiga encima de otra pared ya existente
		if not _colisiona_con_paredes(nueva_pared):
			paredes.append(nueva_pared)

generar_enemigos()

func _generar_enemigos(cantidad):
	# Genera enemigos solo en la mitad inferior para dar tiempo al jugador.
	
	var intentos = 0
	var fila_minima = int(tam_tablero.y / 2) # Calculamos la mitad del tablero
	
	while enemigos.size() < cantidad and intentos < 1000:
		intentos += 1
		
		# Coordenada X aleatoria (cualquier columna)
		var cx = randi() % int(tam_tablero.x)
		# Coordenada Y aleatoria (solo desde la mitad hacia abajo)
		var cy = randi_range(fila_minima, int(tam_tablero.y) - 1)
		
		if _es_zona_segura(Vector2(cx, cy)): continue
		
		var rect_temp = _crear_rect(cx, cy, 0)
		
		# Evitamos que un enemigo nazca dentro de una pared
		if _colisiona_con_paredes(rect_temp): continue
		
		# Añadimos el enemigo como un diccionario con sus propiedades
		enemigos.append({
			"rect": rect_temp.grow(-15), # Hacemos la hitbox más pequeña para perdonar roces
			"dir": 1 if randf() > 0.5 else -1, # 50% probabilidad de ir arriba o abajo
			"velocidad": VEL_ENEMIGO
		})

iniciar_nivel()

func _iniciar_nivel():
	# Resetea las variables y genera un nuevo mapa limpio.
	# Calcula la dificultad basada en el nivel actual.
	
	# Limpiamos los arrays para borrar el nivel anterior
	paredes.clear()
	enemigos.clear()
	
	# Reiniciamos estados lógicos
	game_over = false
	siguiente_nivel = false
	
	# Creamos al jugador en la posición (0,0) (Arriba-Izquierda)
	jugador = _crear_rect(0, 0, MARGEN)
	
	# Creamos la meta en la última celda disponible (Abajo-Derecha)
	meta = _crear_rect(tam_tablero.x - 1, tam_tablero.y - 1, 0)
	
	# Fórmula de dificultad: Más nivel = más paredes y enemigos
	var cant_paredes = 5 + (nivel_actual * 3)
	var cant_enemigos = 2 + (nivel_actual * 1)
	
	# Ejecutamos los bucles de generación
	_generar_paredes(cant_paredes)
	_generar_enemigos(cant_enemigos)

ready()

func _ready():
	# Inicialización básica: Configura el tamaño de pantalla, la cuadrícula y el audio.
	# Se ejecuta una única vez al arrancar la escena.
	
	# Inicializa la semilla aleatoria para que cada partida sea distinta
	randomize()
	
	# Obtenemos el tamaño visible de la ventana del juego
	pantalla = get_viewport_rect().size
	
	# Calculamos cuántas columnas y filas caben dividiendo el ancho/alto por el tamaño de celda
	var cols = round(pantalla.x / TAM_CELDA)
	var filas = round(pantalla.y / TAM_CELDA)
	tam_tablero = Vector2(cols, filas)
	
	# Recalculamos el tamaño exacto de la celda para que se ajuste perfectamente a la pantalla
	celda_dim = Vector2(pantalla.x / cols, pantalla.y / filas)
	
	# Definimos la geometría de los botones UI
	# Botón cerrar: Esquina superior derecha
	btn_cerrar = Rect2(pantalla.x - celda_dim.x, 0, celda_dim.x, celda_dim.y)
	# Botón reiniciar: Esquina inferior izquierda
	btn_reiniciar = Rect2(0, (tam_tablero.y - 1) * celda_dim.y, celda_dim.x, celda_dim.y)
	
	# Instanciamos y configuramos el nodo de música
	music_player = AudioStreamPlayer.new()
	music_player.stream = AUDIO_FONDO
	music_player.volume_db = -10 # Bajamos un poco el volumen
	add_child(music_player) # Lo añadimos al árbol de nodos
	music_player.play()
	
	# Instanciamos el nodo para efectos de sonido (SFX)
	sfx_player = AudioStreamPlayer.new()
	add_child(sfx_player)
	
	# Llamamos a la función que prepara el primer nivel
	_iniciar_nivel()

draw()

func _draw():
	# Dibuja texturas, enemigos, jugador e interfaz en pantalla.
	# IMPORTANTE: El orden importa. Lo primero que se dibuja queda al fondo.
	
	# 1. Fondo (Capa más baja)
	draw_texture_rect(TEX_FONDO, Rect2(Vector2.ZERO, pantalla), false)
	
	# 2. Objetivos
	draw_texture_rect(TEX_META, meta, false)
	
	# 3. Obstáculos
	for p in paredes:
		draw_texture_rect(TEX_PARED, p, false, Color(1,1,1,0.5)) # Transparencia 0.5
	
	# 4. Entidades (enemigos y jugador)
	for e in enemigos:
		draw_texture_rect(TEX_ENEMIGO, e.rect, false)
	
	draw_texture_rect(TEX_JUGADOR, jugador, false)
	
	# 5. Interfaz de Usuario (Capa superior)
	draw_texture_rect(TEX_CERRAR, btn_cerrar, false)
	draw_texture_rect(TEX_REINICIAR, btn_reiniciar, false)
	
	# Texto informativo
	draw_string(ThemeDB.fallback_font, Vector2(0, 50), "NIVEL " + str(nivel_actual), 
		HORIZONTAL_ALIGNMENT_CENTER, pantalla.x, 40)
	
	# 6. Pantallas Superpuestas (Overlays)
	if game_over:
		# Dibuja game over con un tinte ligeramente transparente (0.8)
		draw_texture_rect(TEX_GAMEOVER, Rect2(Vector2.ZERO, pantalla), false, Color(1,1,1,0.8))
	elif siguiente_nivel:
		draw_texture_rect(TEX_SIGUIENTE, Rect2(Vector2.ZERO, pantalla), false, Color(1,1,1,0.8))

mover_jugador()

func _mover_jugador(delta):
	# Calcula el movimiento del jugador y evita que atraviese paredes.
	
	# Obtiene un vector (-1, 0, 1) según las teclas pulsadas
	var dir = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
	
	# Si no se pulsa nada, salimos para ahorrar cálculos
	if dir == Vector2.ZERO: return

	# Calculamos cuánto desplazarse en este frame (Dirección * Velocidad * Tiempo)
	var velocidad = dir * VEL_JUGADOR * delta
	
	# --- MOVIMIENTO EJE X ---
	# Creamos una copia temporal del jugador movida en X
	var test_x = jugador
	test_x.position.x += velocidad.x
	
	# Si la copia no choca, movemos al jugador real
	if _es_posicion_valida(test_x): jugador.position.x = test_x.position.x
	
	# --- MOVIMIENTO EJE Y ---
	# Hacemos lo mismo para el eje Y por separado. 
	# (Hacerlo separado permite deslizarse por las paredes en lugar de atascarse)
	var test_y = jugador
	test_y.position.y += velocidad.y
	
	if _es_posicion_valida(test_y): jugador.position.y = test_y.position.y

mover_enemigos()

func _mover_enemigos(delta):
	# Mueve enemigos verticalmente y los hace rebotar al chocar.
	
	for e in enemigos:
		# Calculamos el paso vertical
		var paso = e.dir * e.velocidad * delta
		e.rect.position.y += paso
		
		# Verificamos si se salió de la pantalla (Arriba < 0, Abajo > pantalla)
		var choca_borde = e.rect.position.y < 0 or e.rect.end.y > pantalla.y
		
		# Si toca borde o pared...
		if choca_borde or _colisiona_con_paredes(e.rect):
			e.rect.position.y -= paso # Revertimos el movimiento para que no se quede pegado
			e.dir *= -1               # Invertimos la dirección (1 a -1, o viceversa)

comprobar_colisiones()

func _comprobar_colisiones():
	# Verifica condiciones de victoria o derrota por colisión.
	
	# 1. VICTORIA: ¿El jugador toca la meta?
	if jugador.intersects(meta):
		siguiente_nivel = true
		_reproducir_sonido(AUDIO_FONDO)
	
	# 2. DERROTA: ¿El jugador toca algún enemigo?
	for e in enemigos:
		# Reducimos un poco el hitbox del enemigo (grow -4) para ser benévolos con el jugador
		if jugador.intersects(e.rect.grow(-4)):
			game_over = true
			music_player.stop()              # Silenciamos música
			_reproducir_sonido(AUDIO_MUERTE) # Sonido de choque
			return

process()

func _process(delta):
	# Bucle principal: Gestiona movimiento y colisiones en cada frame.
	
	# Si el juego acabó, salimos de la función inmediatamente (no procesamos nada más)
	if game_over or siguiente_nivel: return
	
	# Actualizamos la lógica
	_mover_jugador(delta)
	_mover_enemigos(delta)
	_comprobar_colisiones()
	
	# Solicitamos redibujar la pantalla (llama a _draw automáticamente)
	queue_redraw()

input()

func _input(event):
	# Gestiona clics del ratón para botones y reinicio de juego.
	
	# Solo nos interesan los eventos que sean Clics de ratón y que estén Presionados
	if not (event is InputEventMouseButton and event.pressed): return

	# Chequeo de botones de UI
	if btn_cerrar.has_point(event.position):
		get_tree().quit() # Cierra el juego
		return

	if btn_reiniciar.has_point(event.position):
		_iniciar_nivel() # Reinicia el nivel actual
		return

	# Lógica para continuar tras ganar o perder
	if game_over:
		music_player.play()
		_iniciar_nivel() # Reintenta el mismo nivel
	elif siguiente_nivel:
		nivel_actual += 1 # Sube la dificultad
		_iniciar_nivel()

El resultado

Desde Godot podemos exportar este mismo proyecto para poder jugar en cualquier navegador. Puedes ver el resultado y jugar directamente mediante el siguiente enlace: