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

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

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

Código de gestión del tablero

    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.

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

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