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 laXpor 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): DefinimosFILASyCOLUMNASpara evitar escribir6y7repetidamente. 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-1a una constante con nombre descriptivo. En programación, esto mejora la legibilidad. Es mucho más claro leerif (resultado == NO_ENCONTRADO)queif (resultado == -1). - El Tablero: Declaramos una matriz de tipo
Ficha. Inicialmente, Java llenará esto connull, 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:
teclado.hasNextInt(): Preguntamos al buffer de entrada si lo siguiente es un entero.- Si es Sí: Leemos el dato y comprobamos que esté en el rango correcto (1-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 buclewhileleerí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:
- Ser un número entero dentro de los límites (0-6).
- 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:
- Ataque (Modo Difícil): ¿Puedo ganar en este turno? Si la respuesta es sí, lo hace inmediatamente.
- 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.
- 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.
- Hacer: Coloca una ficha temporalmente (
tablero[f][c] = ficha). - Verificar: Llama a
comprobarVictoriapara ver si esa jugada resulta en éxito. - 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 estableciendoFicha.VACIO. Es necesario porque al crear el array, Java lo llena connull.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 (desdeFILAS - 1hasta0). 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;
}