Programando el juego Conecta 4 en Java paso a paso

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 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.

A continuación, presentamos el código desglosado por módulos lógicos.

Estructuras de Datos: El uso de Enum frente a enteros

En programaciones más antiguas o simplistas, 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.

  • Implementación: 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.

Enumerado Ficha

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

  • 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.

Código del bloque de datos

    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;
        }
    }

    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.

Código de configuración

    // 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 del punto 1.

Validación Robusta (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, colgando el programa.

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.

Código de interacción

    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.");
            }
        }
    }

5. Inteligencia Artificial: Algoritmos de Decisión

Aquí reside la lógica más avanzada del programa. La IA no juega simplemente al azar (a menos que esté en modo fácil); toma decisiones basadas en prioridades.

5.1 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.

5.2 Algoritmo de Simulación (Backtracking Simplificado)

La función buscarMovimientoGanador implementa una técnica poderosa: 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 la IA:

Java

    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;
    }

6. Física del Tablero: La 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á».

Código de gestión del tablero:

Java

    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;
    }

7. Verificación de Victoria (Algoritmos de Búsqueda)

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.

El problema de 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.

Código de verificación:

Java

    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;
    }