Es la segunda vez que cuelgo esta animación en el blog. La anterior fue debido a las vacaciones de verano de 2008.

Ahora el problema es que no tengo literalmente tiempo disponible para agregar nuevas entradas. Intentaré responder a los comentarios, pero tampoco prometo nada.

Eso sí, no se trata de un abandono definitivo, sino temporal.

Todo el material del Curso de Programación en C , junto con el resto de entradas, permanecerán publicadas para quien pueda aprovecharlas y mientras los chicos de Profeblog no tengan inconveniente.

Volveremos pronto, o eso espero.

Nos quedamos en la fase 9 con un sabor de boca agridulce: aunque nuestro programa ya era capaz de seleccionar una pieza y realizar un movimiento (es decir, ya podía jugar al ajedrez contra nosotros), era tan mal jugador como el más bisoño de los principiantes. ¿Qué estaba ocurriendo?

Ocurría que habíamos transmitido poca “inteligencia” (y las c0millas son por lo indefinido del término) al programa. Apenas la suficiente para que seleccionase el mejor movimiento mirando únicamente el estado del tablero tal y como quedaría al hacer ese movimiento. Ese trocito de inteligencia lo denominamos “función de evaluación estática”.

Ahora llega el momento de hacer germinar ese granito de inteligencia que dejamos plantado en la fase 9.

Introducción a las técnicas heurísticas

Se denominan técnicas heurísticas a aquellas que no nos aseguran encontrar una solución perfecta a un problema (en nuestro caso, una solución perfecta sería encontrar un movimiento que nos condujera directa e inevitablemente a ganar la partida de ajedrez), pero sí hallar una solución de la cual se puede esperar que esté entre las mejores soluciones posibles.

Con la solución adoptada en la fase 9, y siempre que la función de evaluación sea lo bastante buena, conseguiremos que el ordenador “piense” sus jugadas siguiendo criterios objetivos, pero cometerá continuamente torpezas como, por ejemplo, no dudar en sacrificar una reina para comer un peón. Esto se debe a que, a diferencia de los jugadores humanos, la máquina no ve más allá del siguiente movimiento, mientras que un humano intentará predecir la reacción de su adversario a su movimiento. Un buen jugador humano, antes de realizar un movimiento, estará pensando en los siguientes dos, tres, cuatro o más movimientos.

Por supuesto, ningún ser humano puede calcular todos los posibles movimientos de las siguientes dos, tres o cuatro jugadas. Ni mucho menos cómo acabará la partida (entonces, el ajedrez no tendría ninguna gracia). Ello se debe a la enorme cantidad de posibilidades que se abren desde cada posición del tablero. Son tantas que ni siquiera un ordenador potente puede evaluarlas TODAS más allá de tres o cuatro movimientos.

Para que se haga una idea, se calcula que el número de posibles combinaciones de piezas después de los diez primeros movimientos es de alrededor de 165.518.829.100.544.000.000.000.000. Un ordenador rapidísimo, capaz de evaluar, por ejemplo, un millón de posiciones por segundo , necesitaría más de 7 billones de años (¡mucho más que la edad del universo!) para generar todas las combinaciones posibles y decidir cual es la mejor.

Señoras y señores… ¡un fuerte aplauso para Minimax!

Ante la imposibilidad de una evaluación exhaustiva de todas las combinaciones posibles de jugadas a partir de un estado concreto, tenemos que conformarnos con un examen parcial. Una buena estrategia para lograr una buena heurística es la técnica minimax, que se puede usar en otros muchos juegos de dos jugadores para hacer que el ordenador “piense”, y que describimos a continuación.

Supongamos que el ordenador maneja a las piezas negras. Lógicamente, su siguiente movimiento debería ser aquél que haga máxima la función de evaluación. Pero debemos tener en cuenta que el contrincante (piezas blancas) responderá con la jugada que haga máxima la función de evaluación para las blancas, es decir, que la convierta en mínima para las negras.

maximo = -99999
para cada movimiento posible del jugador actual sobre "tablero" hacer
inicio
    // Vamos a tratar de hacer este movimiento, a ver qué pasaría
    copiar tablero en tablero2
    realizar el movimiento sobre tablero2
    // Vamos a ver con qué movimiento respondería el contrario. Supondremos que
    // preferirá el que haga la función de evaluación mínima para nosotros
    minimo = 99999
    para cada movimiento_contrario posible del jugador contrario sobre "tablero2" hacer
    inicio
       copiar tablero2 en tablero3
       realizar movimiento_contrario sobre tablero 3
       puntos = evaluar_estado(tablero3)
       si (puntos < minimo)
         minimo = puntos
       fin
    fin (para)

    // Ya hemos encontrado la mejor respuesta del contrario a nuestro movimiento
    // Después de ella, ¿quedamos en una buena situación? Vamos a comprobarlo
    si (minimo > maximo) // Es el mejor (para nosotros) encontrado hasta ahora
    inicio
       maximo = minimo
       mejor_movimiento = movimiento
    fin (si)
fin (para)

Al final del algoritmo, la variable “mejor_movimiento” debe contener el movimiento seleccionado como el más adecuado (en realidad, debe ser un conjunto de cuatro coordenadas, dos de origen y dos de destino). Así ya no aparece el problema de sacrificar una reina para comer un peón. Aunque eso haga máxima la función de evaluación en el siguiente movimiento, la hará mínima dentro de dos movimientos, porque podemos prever que el contrario preferirá comerse nuestra reina, y, por lo tanto, no elegiremos ese camino.

Naturalmente, pudiera ocurrir que el movimiento elegido de esta forma sea equivocado, en el sentido de que no conduzca a una jugada ganadora. Al fin y al cabo, sólo estamos comprobando la respuesta del contrario a cada posible movimiento nuestro, es decir, sólo estamos prediciendo dos movimientos en el futuro. Quizá mirando cinco o seis movimientos más adelante nos diéramos cuenta de que no es rentable, pero eso es imposible por la enorme cantidad de estados del tablero que habría que evaluar. Por eso esta estrategia no nos asegura la victoria ni mucho menos.

Con el algoritmo anterior conseguimos mirar dos movimientos por anticipado: el nuestro y la respuesta previsible del contrario. Esto se puede generalizar para examinar la próximas n jugadas:

función buscar_mejor_movimiento(tablero)
inicio
   maximo = -9999
   para cada movimiento posible del jugado actual
   inicio
       copiar tablero en tablero2
       realizar movimiento sobre tablero2
       puntos = min(tablero2)
       si (puntos > maximo)
       inicio
         maximo = puntos
         mejor_movimiento = movimiento
       fin (si)
   fin (para)
fin (función)

Esta función iniciaría el proceso de búsqueda, eligiendo el movimiento que hace máximo el valor devuelto por el jugador contrario, cuyo “razonamiento” trata de simularse en la función min(),así:

función min(tablero)
inicio
  si (se ha alcanzado la profundidad deseada)
    minimo = evaluar_estado(tablero)
  si_no
  inicio
    minimo = 99999
    para cada movimiento posible del jugador contrario sobre el tablero
    inicio
       copiar tablero en tablero 2
       realizar ese movimiento en tablero2
       puntos = max(tablero2)
       si (puntos < minimo)
          minimo = puntos
    fin (para)
  fin (si_no)
  devolver (minimo)
fin (funcion)

A su vez, la función max() simulará la forma de pensar del judador actual, que tratará de hacer máxima la evaluación, de este modo:

función max(tablero)
inicio
  si (se ha alcanzado la profundidad deseada)
     maximo = evaluar_estado(tablero)
  si_no
  inicio
     maximo = -99999
     para cada movimiento posible del jugador contrario sobre el tablero
     inicio
       copiar tablero en tablero 2
       realizar ese movimiento en tablero2
       puntos = min(tablero2)
       si (puntos > maximo)
         maximo = puntos
    fin (para )
  fin (si_no)
  devolver (maximo)
fin (funcion)

Como vemos, el jugado actual (que maneja el ordenador) trata de hacer máxima la ventaja que obtiene en cada jugada, al tiempo que trata de hacer mínima la ventaja del jugador contrario. Por esa razón esta técnica se denomina minimax.

En la siguiente figura lo expresamos gráficamente. Supongamos que el juego se encuentra en el estado representado en la raiz del árbol, y que le toca mover al ordenador (negras). Cada  posible movimiento conduce al tablero a un estado diferente en el siguiente nivel del árbol. A su vez, cada uno de estos tableros tiene una colección de posibles movimientos que generan otros estados del tablero.

minimax(clic para agrandar)

Los valores asignados a los estados más bajos (hojas del árbol) se obtienen aplicando la función de evaluación estática. El resto de valores se obtienen mediante la regla minimax. El jugador negro elegirá el primer movimiento (el de la rama izquierda), porque le asegura un valor mínimo de 5. Es decir, es el máximo de los valores mínimos del siguiente nivel.

Aplicación del minimax al juego

El algoritmo minimax puede implementarse de diversas formas. Aquí propondremos una, pero usted puede desarrollar otra si lo prefiere.

La función de evaluación estática no es necesario modificarla; es más, se usará en repetidas ocasiones.

Necesitaremos definir la profundidad máxima de búsqueda (que, como dijimos, no debe ser mayor de 3, 4 o, como mucho, 5 movimientos). También necesitamos dos funciones nuevas:

  • valorar_posicion_contrario(): dada una posición del tablero (pasada como parámetro) realizará la evaluación estática si ya se ha alcanzado la profundidad máxima de búsqueda (que, por tanto, también se pasará como parámetro) y devolverá esa valoración. Si no se ha alcanzado esa profundidad, se generarán todos los posibles movimientos del jugador contrario y se llamará a la función valorar_posición_propia(), quedándonos con el valor mínimo de todos los que nos devuelva esta función.
  • valorar_posicion_propia(): dada una posición del tablero (pasada como parámetro) realizará la evaluación estática si ya se ha alcanzado la profundidad máxima de búsqueda (que, por tanto, también se pasará como parámetro) y devolverá esa valoración. Si no se ha alcanzado esa profundidad, se generarán todos los posibles movimientos del jugador que tiene el turno y se llamará a la función valorar_posición_contrario(), quedándonos con el valor máximo de todos los que nos devuelva esta función.

En definitiva, la función valorar_posición_contrario() se ejecutará en los niveles del árbol correspondientes al jugador contrario (MIN) y valorar_posición_propia() se ejecutará en los niveles del árbol correspondientes al jugador actual (MAX)

Observa que la primera función llama a la segunda y la segunda a la primera, por lo que se produce una recursión doble. El caso base es aquél en el que se alcanza la profundidad máxima preestablecida: entonces se invoca la función de evaluación estática y la recursión termina.

Debe existir una tercera función llamada buscar_mejor_movimiento() o algo similar que se encargue de iniciar el proceso recursivo. Esta función calculará todos los posibles movimientos del jugador actual y, para cada uno de ellos, llamará a valorar_posicion_contrario(), ya que el siguiente movimiento corresponderá al jugador contrario. De todos los valores que nos devuelve esa función, debemos quedarnos con el mayor, considerando que es el mejor movimiento que podemos hacer, ya que maximiza nuestra ventaja y minimiza la del contrario.

Expresado en pseudocódigo:

función buscar_mejor_movimiento(tablero, estado)
inicio
  maximo = -9999
  para todos los movimientos que el jugador actual pueda hacer en “tablero”
  inicio
     copiar tablero en tablero2
     realizar el movimiento en tablero2
     valoracion = valorar_posicion_contrario(tablero2, 1)
     si (valoracion > maximo)
     inicio
       maximo = valoracion
       mejor_movimiento = movimiento_actual
     fin (si)
  fin (para)
fin (función)

función valorar_posicion_contario(tablero, profundidad)
inicio
  si (profundidad == PROFUNDIDAD_MAXIMA)
  inicio
    valoracion = evaluacion_estatica (tablero)
    devolver (valoracion)
  fin (si)
  minimo = 9999
  para todos los movimientos que pueda hacer el jugador contrario en “tablero”
  inicio
     copiar tablero en tablero2
     realizar el movimiento sobre tablero2
     valoracion = valorar_posicion_propia(tablero2, profundidad + 1)
     si (valoracion < minimo)
        minimo = valoracion
  fin (para)
  devolver (minimo)
fin (función)

función valorar_posicion_propia(tablero, profundidad)
inicio
   si (profundidad == PROFUNDIDAD_MAXIMA)
   inicio
      valoracion = evaluacion_estatica (tablero)
      devolver (valoracion)
   fin (si)
   maximo = -9999
   para todos los movimientos que pueda hacer el jugador actual en “tablero”
   inicio
      copiar tablero en tablero2
      realizar el movimiento sobre tablero2
      valoracion = valorar_posicion_contraria(tablero2, profundidad + 1)
      si (valoracion > maximo)
         maximo = valoracion
   fin (para)
   devolver (maximo)
fin (función)

Una mejora: Minimax con poda alfa-beta

La técnica minimax, como hemos dicho, no tiene por qué proporcionarnos el mejor movimiento posible. De hecho, puede provocar un movimiento manifiestamente malo y ante el que cualquier jugador avanzado de ajedrez sonreiría con suficiencia. Aunque, la mayoría de las veces, minimax proporcionará un movimiento razonablemente bueno.

El movimiento será tanto más bueno cuanto más podamos profundizar en el árbol de movimientos. De hecho, se considera que un buen jugador de ajedrez suele prever, aproximadamente, una media de 8 jugadas. Pero, como hemos visto, este árbol se ramifica demasiado y se hace muy pronto incalculable, incluso para los ordenadores más potentes. A este tipo de problemas se les denomina “no computables”, ya que no pueden ser procesados y resueltos en un tiempo razonable.

Pero hay una forma de lograr profundizar más en el árbol, llegando hasta seis, siete o más jugadas en el futuro: utilizando una variación de la técnica minimax denominada minimax con poda alfa-beta.

El minimax con poda se basa en la misma idea que un jugador humano de ajedrez: no es necesario mirar TODOS los estados porque hay algunos que, ya desde el principio y de forma evidente, son malos para el jugador que mueve. Por lo tanto, esas ramas del árbol no es necesario explorarlas porque, por más que descendamos, todos los estados resultarán muy negativos.

Para aplicar este principio a nuestro algoritmo debemos ejecutar la función de evaluación estática en algún nivel intermedio, antes de llegar a las hojas del árbol. Por ejemplo, si estamos explorando 5 jugadas, podemos aplicar la función de evaluación en el nivel 3. En esa jugada, le toca mover al jugador actual (es un nivel de tipo MAX en el algoritmo minimax). Evaluaremos todos los estados del nivel 3 como si fueran los últimos del árbol, y llamaremos al mejor de ellos ALFA.

A partir de entonces sólo seguiremos explorando las ramas del árbol cuyo valor estático en el nivel 3 sea igual ALFA (o, al menos, muy próximo a ALFA). Esto eliminará la gran mayoría de ramas del árbol, por lo que nos será fácil descender hasta el nivel 7 u 8. Si hacemos una segunda poda, podemos explorar niveles muy profundos en un tiempo razonable.

También podemos elegir hacer la poda en un nivel correspondiente al jugador contrario (por ejemplo, el nivel 4, que es de tipo MIN). En ese caso, aplicaremos la función de evaluación estática a todos los estados y elegiremos la menor de todas ellas, llamándola BETA. Podaremos todas las ramas cuyo valor estático sea mayor que ese valor BETA, profundizando en las demás.

Evidentemente, este método tampoco es infalible pues, aunque permite profundizar mucho más en el árbol, es muy posible que en alguna de las podas desechemos una rama que, aunque en ese momento proporciona una valoración pobre, en el futuro nos hubiera conducido a la victoria. A pesar de este riesgo, inherente a todas las técnicas heurísticas (que, recordemos, no pretenden encontrar soluciones infalibles, ya que se usan en problemas para los que es imposible encontrar dichas soluciones), el minimax con poda alfa-beta suele proporcionar mejores resultados que el minimax simple.

Cómo hacer que la máquina “piense” una jugada

Para que el ordenador pueda jugar al ajedrez razonablemente bien, debe empezar por ser capaz de distinguir las situaciones más beneficiosas (para él) de las que no lo son tanto. Por ejemplo, debe saber que tener un peón en el centro del tablero es mucho mejor que tenerlo en un lateral, o que la reina es mucho más valiosa que un caballo.

Para lograrlo existen varios métodos, pero el más simple es la función de evaluación estática. Se trata de una función matemática que, partiendo de las piezas que hay en el tablero y su ubicación, devuelve un valor numérico que determina la calidad de la situación para un jugador dado.

Supongamos que el ordenador juega con las negras. Si, cuando le toca su turno, la función devuelve, por ejemplo, el número 525, quiere decir que la situación actual del tablero es muy beneficiosa para las negras. Si la función devuelve, en cambio, un número más pequeño (próximo a cero), la situación es más o menos igual de buena para las negras y para las blancas. Si devuelve un número muy negativo (por ejemplo, -650), quiere decir que la situación es muy negativa para las negras, es decir, muy positiva para las blancas.

Obviamente, la función de evaluación debe funcionar exactamente al revés para el jugador blanco.

Gracias a la función de evaluación estática, el ordenador puede saber qué movimiento le resultará más beneficioso y, de esta manera, decidirse por un movimiento concreto de entre los muchos posibles.

Una función de evaluación sencilla

A continuación se muestra una función de evaluación sencilla y fácil de implementar. Con una función así, ningún programa de ajedrez se proclamará campeón del mundo, desde luego, pero sí que conseguiremos resultados bastante aceptables.

  • PEÓN
    • Por cada peón propio, sumar 100 puntos.
    • Si el peón está en el centro del tablero, añadir 12 puntos más
    • Añadir 2 puntos por cada casilla que haya avanzado el peón desde su punto de partida
  • CABALLO
    • Cada caballo propio suma 315 puntos
    • Añadir entre 0 y 15 puntos si está cerca del centro del tablero (más cuanto más cerca del centro)
    • Quitar entre 0 y 15 puntos si está lejos del centro (quitar más cuanto más lejos)
  • ALFIL
    • Cada alfil suma 330 puntos
    • Añadir un punto más por cada casilla a la que pueda moverse libremente (es decir, sin que se lo impida otra pieza que esté en medio)
  • TORRE
    • Cada torre suma 500 puntos
    • Añadir un punto más por cada casilla a la que pueda moverse libremente (es decir, sin que se lo impida otra pieza que esté en medio)
  • DAMA
    • La dama representa 940 puntos
    • Como en el caballo, añadir o quitar puntos (de 0 a 10) dependiendo de lo cerca o lejos que esté del centro del tablero

Todos estos puntos se calculan según las piezas del jugador al que le toca mover. Depués, se calculan del mismo modo para las piezas del jugador contrario, y ambas cantidades se restan. El resultado es lo que debe devolver la función de evaluación.

Un posible algoritmo para implementar esta función sería algo así:

total = 0;
para x desde 1 hasta 8
inicio
  para y desde 1 hasta 8
  inicio
    puntos_pieza = 0;
    según (tablero[x][y].pieza)
    inicio
       caso PEON: puntos_pieza = <<calcular puntos de este peón>>
       caso TORRE: puntos_pieza = <<calcular puntos de esta torre>>
       <<añadir el resto de piezas>>
    fin

    si (tablero[x][y].color_pieza == turno) // La pieza es nuestra
      total = total + puntos_pieza; // Sumamos los puntos al total
    si_no // La pieza NO es nuestra
      total = total - puntos_pieza; // Restamos los puntos al total
    fin (para)
  fin (para)
devolver(total);

La función se puede complicar tanto como queramos. En Internet y en muchos libros especializados sobre ajedrez puedes encontrar otras funciones de evaluación que pueden llegar a ser realmente complicadas. Sin embargo, hay que encontrar un compromiso entre exactitud y complejidad, porque si la función es excesivamente compleja tardará mucho tiempo en calcularse, y, por tanto, el programa tardará demasiado tiempo en “pensar” los movimientos (sobre todo cuando, en la fase 10, añadamos búsqueda recursiva a las rutinas de inteligencia)

Aplicación de la función al programa del ajedrez

Veamos ahora cómo puede el ordenador decidir su próximo movimiento.

Dada una posición cualquiera del tablero, el ordenador puede efectuar muchos movimientos diferentes. Se trata de que busque todos los movimientos que puede hacer, y evalúe, para cada uno de ellos, en qué situación le deja. Para la evaluación usará la función de evaluación estática que acabamos de ver, u otra parecida. Al final, escogerá el movimiento que le conduce a una situación en la que el valor devuelto por la función de evaluación es máximo.

Para buscar todos los movimientos, la máquina debe recorrer todas las casillas del tablero buscando piezas de su color. Para cada pieza que encuentre, volverá a recorrer el tablero, y, para cada casilla, probará a mover la pieza a dicha casilla. Usará las funciones de comprobación de movimientos (las hicimos en la fase 4) para comprobar si el movimiento es posible. Si el movimiento es posible, lo evaluará con la función de evaluación, quedándose con el máximo

Expresado algorítmicamente:

maximo = -infinito;
para ox desde 1 hasta 8
  inicio
  para oy desde 1 hasta 8
  inicio
    si (tablero[ox][oy].color_pieza == turno) // Hemos encontrado una pieza nuestra
    inicio
    para dx desde 1 hasta 8
    inicio
      para dy desde 1 hasta 8
      inicio
        si (es posible el movimiento de (ox,oy) a (dx,dy))
        inicio // Hemos encontrado un destino válido
          tablero2 = copiar_tablero(tablero); // Hacemos el movimiento
          realizar_movimiento (tablero2, ox, oy, dx, dy);
          puntos = evaluar_posicion (tablero2); // Evaluamos el resultado
          si (puntos > maximo)  // Es el mejor encontrado hasta ahora
          inicio
             maximo = puntos; // Guardamos el mejor movimiento
             mejor_ox = ox; mejor_oy = oy;
             mejor_dx = dx; mejor_dy = dy;
          fin
         fin (si)
       fin (para dy)
     fin (para dx)
   fin (si)
 fin (para oy)
fin (para ox)

Observe que el algoritmo realiza los movimientos sobre una copia del tablero (llamada aquí “tablero2″). Esto se hace para no modificar el tablero real, ya que estos movimientos no se están haciendo realmente, sino que sólo se están probando para ver cual es el mejor de todos ellos.

Al final del proceso, en el par (mejor_ox, mejor_oy) tendremos el origen del mejor movimiento posible, y en (mejor_dx, mejor_dy), el destino. El ordenador deberá realizar ese movimiento para llevar al tablero a la situación más conveniente para sus intereses.

Pero recuerde que esto no es suficiente para lograr un programa que juegue razonablemente bien al ajedrez. Procediendo de este modo, el ordenador sólo está mirando un movimiento más allá del estado actual del trablero y se comportará exactamente como lo que es: como un jugador con una lamentable cortedad de miras. En la siguiente (y última) fase de desarrollo daremos el salto definitivo para hacer de nuestro programa un jugador de ajedrez competente usando una técnica clásica de Inteligencia Artificia: Minimax.

Eligiendo una biblioteca gráfica

Para crear un interfaz gráfico para nuestro programa existen muchas posibles bibliotecas a las que podemos recurrir, como Allegro, OpenGLSDLGTK+, etc. Cada una tiene sus ventajas e inconvenientes, así como un nivel de aplicación (por ejemplo, GTK+ está más orientada a la programación de aplicaciones en entornos gráficos en general, no de juegos en particular, mientras que Allegro está orientada precisamente a estos últimos)

Ninguna de estas bibliotecas pertenece al estándar ANSI C, como es lógico, así que en todos los casos debemos instalarlas por separado y a continuación enlazarlas con nuestro proyecto.

Nos hemos decantado por la biblioteca SDL, que es multiplataforma (el programa compilará perfectamente en Windows, Linux o MacOS) y está orientada al manejo de gráficos 2D a bajo nivel, precisamente lo que necesitamos en este proyecto.

Sobre la biblioteca SDL ya publicamos un extenso artículo explicando los fundamentos de su utilización (incluyendo su instalación). Es recomendable revisarlo antes de enfrentarse a esta fase del desarrollo.

Añadiendo gráficos al juego

El proceso de adaptación del juego al formato gráfico con SDL va a ser largo, como se puede imaginar (por eso le asignamos a esta tarea dos fases de duración). Se acabaron los printw(), move(), INIT_PAIR() y demás recursos de ncurses.

Si ha tenido la precaución de agrupar todas las entradas y salidas en un único archivo lo tendrá un poco más fácil.

Aquí se propone una guía de actuaciones para afrontar el reto de la conversión de la aplicación a un programa gráfico:

  1. Haga una copia de todos los archivos del programa y guárdela a buen recaudo, por si las moscas. Prepárese para pasar bastante tiempo sin disponer de una versión que funcione.
  2. Prepare todas las imágenes que va a necesitar: como mínimo, una imagen del tablero y, además, una imagen para cada tipo y color de pieza (6 imágenes de las piezas blancas – peón, torre, alfil, caballo, dama y rey – y otras 6 de las negras). Compruebe que los tamaños de todas ellas concuerdan y que el tipo de los archivos es BMP sin compresión. Ponga todos los nombres de los archivos en minúsculas para evitar problemas, y cópielos a su directorio de trabajo. Búsquese también uno o dos archivos de fuentes true type que le gusten y cópielos junto con las imágenes.
  3. Defina una variable GLOBAL de tipo SDL_Surface* para cada sprite que vaya a utilizar en su programa (el tablero, el peón blanco, el peón negro, la torre blanca, la torre negra, etc.). Defina otra variable global del tipo TTF_Font* para cada una de las fuentes de caracteres que vaya a utilizar.
  4. Escriba una función llamada inicializar_SDL() que inicie el sistema gráfico. Invóquela al comienzo de su función main(), antes que cualquier otra cosa. En esta función, y justo después de iniciar la pantalla gráfica, también debe cargar todas las imágenes asignándolas a sus respectivas variables globales de tipo SDL_Surface* que ya debe tener definidas. No se olvide de iniciar también el subsistema SDL_ttf (si toda esta jerga le suena como si le hablase un marciano, es que no ha revisado artículo sobre SDL que hemos mencionado al principio)
  5. Escriba una función finalizar_SDL() que finalice el sistema gráfico. Llámela al final de la función main(), justo antes de terminar. Esta función hará (en estricto orden) las llamadas a TTF_CloseFont() para liberar las fuentes, TTF_Quit(), SDL_FreeSurface() para cada imagen cargada y, por último, SDL_Quit().
  6. Programe una función escribir() que sirva para mostrar un texto en una posición concreta de la pantalla. Esta función tendrá cuatro parámetros: el texto que se desea escribir, la posición en la que se va a escribir (fila y columna) y el color del texto. Compruebe que funciona y proceda entonces a sustituir todos los attron(), move() y printw() de su programa por llamadas a la función escribir(), excepto las llamadas a printw() que servían para dibujar el tablero o las piezas.
  7. Programe una función leer() similar a la anterior, que sirva para leer un texto por teclado y, al mismo tiempo, ir mostrando el eco en la pantalla. Sustituya todas las llamadas a getstr() por llamadas a leer()
  8. Modifique la función de dibujar_tablero() por otra que muestre la imagen BMP del tablero y luego, recorriendo la estructura de datos del tablero, dibuje en las posiciones correctas los sprites de las piezas.
  9. Modifique la función en la que el usuario selecciona la casilla de origen y la de destino de un movimiento, adaptándola a las funciones de lectura del teclado de SDL e inventándose algún modo de marcar las casillas (por ejemplo, puede añadir un nuevo sprite llamado “recuadro”, que sea simplemente un rectángulo que se puede mover sobre el tablero para señalar una casilla)
  10. Revise todos los puntos “vulnerables” de su programa (por ejemplo, el menú), para ver si necesitan algún cambio adicional a los que ya ha realizado.

¿Conservar la versión de texto?

Cuando la versión gráfica del programa esté marchando no querrá ni ver la primitiva versión “ncursera”. Pero es una lástima perder ese trabajo que ya estaba hecho y, oiga, quizás alguien prefiera jugar en modo texto. Hay gente para todo.

No tiene por qué borrar el código de entrada/salida en modo texto, sino que ambas versiones (la de texto y la gráfica) pueden coexistir pacíficamente, y usted puede elegir si prefiere compilar el programa para que funcione en modo texto o en modo gráfico.

Para ello, puede optar por la siguiente solución:

1. Defina una constante llamada, por ejemplo, INTERFAZ_TEXTO para compilar en modo texto, y otra llamada INTEFAZ_GRAFICO para compilar en modo gráfico. Por ejemplo:

#define INTERFAZ_TEXTO

2. Luego, en cada llamada a una función de entrada/salida, compruebe qué constante hay definida antes de invocar la función de texto o la gráfica. Por ejemplo:

#ifdef INTERFAZ_TEXTO
   getstr(txt);
#ifdef INTERFAZ_GRAFICO
   leer(txt, 1, 1);

De este modo, se compilará una u otra instrucción dependiendo de qué constante esté definida, y conseguirá hacer funcionar ambas versiones del programa. Eso sí, su código se complicará un poco más todavía.

En esta fase vamos a llevar a cabo varias tareas pequeñas pero importantes:

  • Reproducir partidas
  • Mostrar una lista con los últimos movimientos
  • Ayuda en línea
  • Detectar los jaques
  • Detectar el final de la partida

Reproducción de partidas

El objetivo de esta funcionalidad es ver en la pantalla el desarrollo de las partidas que tuviéramos guardadas en archivos de disco, o bien reproducir desde el principio la partida que estamos jugando.

Poner en marcha esta opción va a resultar muy sencillo, como veremos.

Se supone que, después de la fase anterior, debemos tener construida una estructura de datos dinámica en la que tendremos guardada la lista de movimientos de la partida actual, tanto si ha sido cargada desde un archivo de disco como si ha sido jugada “en directo” desde su inicio. Esta estructura será una lista enlazada simple o alguna variedad similar.

Para reproducir la partida actual desde el inicio, debemos añadir una opción al menú de opciones general. La selección de esta opción provocará una llamada a una función nueva que podemos llamar reproducir_partida() o algo parecido. Esa función tiene que realizar lo siguiente:

  • Colocar el tablero en su posición inicial (llamando a la función de inicializar tablero que programamos en la fase 2)
  • Leer el primer movimiento de la lista (que corresponderá al jugador blanco). Reflejar ese movimiento en las estructuras de datos del juego (tablero, estado, etc) y redibujar el tablero en la pantalla. Esperar un instante antes de continuar.
  • Leer el segundo movimiento de la lista (que corresponderá al jugador negro) y hacer lo mismo.
  • Repetir los dos pasos anteriores con los movimientos tercero, cuarto, quinto, etc, hasta que se haya recorrido toda la lista de movimientos.

Lista de movimientos

Al jugador de ajedrez suele serle útil repasar los últimos movimientos realizados en la partida. Para eso puede optar por reproducir la partida desde el inicio, como acabamos de ver, pero otra ayuda adicional puede ser mostrar en todo momento los últimos movimientos realizados.

Para ello, dedicaremos una sección del panel lateral de la pantalla, que hasta ahora hemos usado para mostrar los mensajes de usuario.

En la lista de movimientos aparecerán los 7 u 8 últimos movimientos (el número lo decide usted, según el diseño de su pantalla y el espacio de que disponga). Los movimientos aparecen en notación algebraica, es decir, solo hay que extraerlos de la lista de movimientos que has debido construir en la fase 5 para guardar y recuperar partidas de disco.

Lo más conveniente es que escriba una función llamada mostrar_lista_movimientos() o algo similar. Esta función debe ser llamada desde dibujar_pantalla() o sus proximidades (lo importante es que sea llamada antes de que cada jugador vaya a hacer un nuevo movimiento; así la lista siempre estará actualizada). La función borrará la lista anterior y escribirá encima la actual.

Ayuda en línea

En esta fase también debe implementar una sencilla función de ayuda que sea accesible desde cualquier momento del juego pulsando alguna tecla especial (lo más conveniente es F1). Por lo tanto, tendrá que añadir el control de la tecla F1 en las rutinas de lectura de teclado que haya escrito hasta ahora. Como mínimo, debe tener una rutina de este tipo: el lugar donde el usuario seleccione las casillas de origen y de destino del movimiento.

La ayuda puede aparecer a toda pantalla o sólo en el panel derecho, como prefiera. Será un texto en el que se explique brevemente cómo se usa el programa (teclas, opciones del menú y poco más). Lo mejor es que lo codifique todo en una función llamada ayuda() que sea invocada al pulsar la tecla F1.

Al salir de la ayuda (lo que ocurrirá pulsando ESC o alguna otra tecla) regresaremos al mismo punto donde nos habíamos quedado, pero el panel derecho de la pantalla habrá desaparecido. Por lo tanto, debe tener la precaución de volver a dibujar todo lo que hubiera en él (por ejemplo, la lista de movimientos llamado a mostrar_lista_movimientos()) antes de salir de la función ayuda(). Si ha preferido hacer la ayuda a pantalla completa, tendrá que volver a dibujar también el tablero llamando a la función correspondiente.

Como ve, tener el código distribuido en funciones independientes simplifica mucho las cosas. Si no, ¿cómo podría volver a dibujarlo todo antes de salir de la ayuda?

Detección del jaque

Cuando un rey está amenazado por una pieza enemiga se dice que está en jaque. El próximo movimiento de ese jugador tiene que estar orientado obligatoriamente a deshacer esa situación, algo que se puede conseguir de tres modos:

  • moviendo el rey para alejarlo del peligro
  • colocando alguna pieza entre en el rey y el enemigo que lo amenaza
  • tomando la pieza que realiza la amenaza

Sea cual sea la opción escogida por el jugador, el juego debe obligar al jugador a deshacer la situación de jaque. Además, un jugador no puede poner a su rey en jaque voluntariamente, y, por lo tanto, el programa debe ser capaz de detectar esa situación para impedir que ocurra.

Cómo detectar si un rey está en situación de jaque

Supongamos que queremos comprobar si el rey blanco está en jaque (para el negro se haría igual, pero al revés). Supongamos también que dicho rey está en la casilla (rx, ry) del tablero. Habrá que recorrer todo el tablero y, para cada pieza negra encontrada, comprobar si puede hacer un movimiento a (rx, ry)

Esto es muy fácil de comprobar, ya que tenemos las funciones de comprobación del movimiento programadas desde la fase 4. Para comprobar, por ejemplo, si la torre negra situada en (tx, ty) puede moverse a (rx, ry), basta con que llamemos a la función comprobar_movimiento_torre() (o como tú la hayas llamado en tu programa), pasándole como origen del movimiento (tx, ty) y como destino (rx, ry). Si la función determina que ese movimiento es posible, entonces el rey está en situación de jaque.

Debemos programar una función llamada detectar_jaque() o algo similar que realice la detección descrita. Recibirá como parámetros el tablero, el estado y el turno actual (es decir, a qué jugador le toca mover). Devolverá BLANCO si se ha puesto en jaque al rey blanco, NEGRO si se ha puesto en jaque al rey negro o NINGUNO en otro caso.

Cuándo debemos realizar la detección

El momento para invocar la función detectar_jaque() y hacer la detección es siempre después de comprobar el movimiento de un jugador. Supongamos que el movimiento actual pertenece al jugador blanco y que, habiéndolo comprobado con las funciones correspondientes (que programamos en la fase 4) ha resultado ser un movimiento correcto. Antes de darlo definitivamente por bueno debemos llamar a detectar_jaque(), y, dependiendo de lo que nos devuelva, hacer lo siguiente:

  1. Si nos devuelve “BLANCO”, al estar en el turno de las blancas el movimiento debe ser considerado incorrecto, ya que pone en jaque voluntariamente a su propio rey.
  2. Si nos devuelve “NEGRO” quiere decir que nuestro movimiento ha puesto en jaque al rey enemigo, lo cuál debe ser tenido en cuenta a la hora de elaborar la lista de movimientos, ya que el jaque tiene un símbolo especial (ver fase 5). Además, sería conveniente mostrar en la pantalla algún mensaje llamativo que diga algo así como “JAQUE AL REY NEGRO”

Si el turno fuera de las negras, el proceso de comprobación sería igual pero al revés.

Detección del fin de la partida

Una partida puede terminar por tres causas:

  • Si el rey blanco está en situación de jaque mate, es decir, está en jaque y ningún movimiento del jugador blanco puede sacar al rey de esa situación. Entonces, la partida termina y ganan las negras.
  • Si el rey negro está en situación de jaque mate, es decir, está en jaque y ningún movimiento del jugador negro puede sacar al rey de esa situación. Entonces, la partida termina y ganan las blancas.
  • Si un jugador cualquiera, en su turno, no puede mover ninguna pieza y su rey no está en jaque. Entonces, el juego termina en situación de tablas.

Debemos, por lo tanto, programar dos funciones: detectar_jaque_mate() y detectar_tablas(). Estas funciones deben ser invocadas después de cada movimiento de cada jugador, para comprobar si el último movimiento ha producido alguna de estas situaciones y, en tal caso, terminar el juego inmediatamente, mostrando al usuario los mensajes que consideres convenientes.

Cómo detectar el jaque mate

Programaremos una nueva función llamada detectar_jaque_mate(), que usará la función detectar_jaque() que hemos programado antes.

Todo lo que sigue es aplicable al turno del jugador blanco. Para el negro se haría igual, pero dándole la vuelta a los colores (el algoritmo en pseudocódigo de más abajo debería funcionar para ambos jugadores, ya que la variable “turno” puede valer tanto BLANCO como NEGRO)

Para que se produzca jaque mate primero debe haberse producido un jaque. Es decir, la función detectar_jaque_mate() sólo debe ser llamada si antes la función detectar_jaque() ha dado como resultado “BLANCO”.

Dentro de detectar_jaque_mate() haremos una copia del tablero, es decir, copiaremos toda la matriz en otra matriz local. Lo mejor es que escribas una función copiar_tablero(), porque luego la necesitarás en la fase 10.

Usando la copia del tablero, recorreremos todas las casillas buscando las piezas blancas. Para cada pieza blanca que encontremos, volveremos a recorrer todo el tablero buscando posibles casillas de destino, invocando a las funciones de comprobación del movimiento para ver si ese movimiento es posible.

Cuando encontremos un movimiento posible, lo realizaremos sobre la copia del tablero (no sobre el tablero real) y llamaremos a detectar_jaque() con este nuevo tablero. Si detectar_jaque() nos da como resultado algo distinto a “BLANCO”, quiere decir que existe al menos un movimiento que deshace la situación de jaque y, por lo tanto, no es un jaque mate.

En cambio, si probamos todos los movimientos posibles del jugador blanco y todos siguen produciendo una situación de jaque, debemos concluir que el rey blanco está en jaque mate.

Una versión simplificada de este algoritmo en pseudocódigo podría ser la siguiente:

entero función detectar_jaque_mate(t_casilla tablero[9][9], char turno)
{
   t_casilla tablero2[9][9];
   int jaque_mate = 1; // Supondremos que hay jaque mate
   int ox, oy, dx, dy;

   copiar_tablero(tablero, tablero2);

   para ox desde 1 hasta 8 // Recorrer todos los posibles orígenes
   inicio
      para oy desde 1 hasta 8
      inicio
         si (tablero2[ox][oy].color_pieza == turno)
         inicio
            // Recorrer posibles destinos
            para dx desde 1 hasta 8
            inicio
               para dy desde 1 hasta 8
               inicio
                  si (es posible el movimiento desde (ox,oy) hasta (dx, dy) en tablero2)
                  inicio
                     // Realizar el movimiento en tablero2
                     Realizar el movimiento desde (ox,oy) hasta (dx, dy) en tablero2
                     si (detectar_jaque(tablero2, turno) != turno)
                        jaque_mate = 0;   // Este movimiento deshace el jaque
                     copiar_tablero(tablero, tablero2);      // Restaurar tablero2
                  fin (si)
               fin (para)
            fin (para)
         fin (si)
      fin (para)
   fin (para)
   devolver (jaque_mate);
}

Cómo detectar las tablas

La detección de las tablas es más fácil que la del jaque mate. Programaremos una función detectar_tablas() que puede ser invocada después de realizar un movimiento en el tablero, o bien justo antes de realizar un nuevo movimiento. En el primer caso hay que tener la precaución de pasarle a detectar_tablas() el color del jugador al que le va a tocar mover a continuación, y no el del jugador que acaba de mover.

La función detectar_tablas() recorrerá el tablero buscando las piezas del color del jugador al que le toca mover. Para cada pieza encontrada, intentará moverla a todas las demás casillas del tablero, usando para ello las funciones de control de movimiento de la fase 4. Si todos los intentos fracasan, quiere decir que el jugador no puede mover ninguna pieza y, por lo tanto, estamos en situación de tablas. Si al menos un intento tiene éxito, no hay situación de tablas.

Esbozada en forma de pseudocódigo, la función sería algo así:

entero función detectar_tablas(t_casilla tablero[9][9], char turno)
{
   int tablas = 1; // Supondremos que hay tablas
   int ox, oy, dx, dy;
   para ox desde 1 hasta 8 // Recorrer todos los posibles orígenes
   inicio
      para oy desde 1 hasta 8
      inicio
         si (tablero2[ox][oy].color_pieza == turno)
         inicio
            // Recorrer posibles destinos
            para dx desde 1 hasta 8
            inicio
               para dy desde 1 hasta 8
               inicio
                  si (es posible el movimiento desde (ox,oy) hasta (dx, dy))
                     // No hay tablas (se ha encontrado al menos un movimiento correcto)
                     tablas = 0;
               fin (para)
            fin (para)
         fin (si)
      fin (para)
   fin (para)
   devolver (tablas);
}

Por último, recordar que las tablas, el jaque y el jaque mate tienen un símbolo específico en la notación algebraica (ver fase 5), que debe ser añadido a la lista de movimientos si se produce la situación.

En esta fase desarrollaremos el menú de opciones del juego y la posibilidad de guardar las partidas en disco para continuar jugándolas más tarde.

Menú de opciones

El juego tiene dos menús de opciones:

  • El que aparece al inicio del juego, para seleccionar el tipo y el color de los jugadores (este ya lo programamos en la fase 2)
  • El que puede invocarse desde el tablero de juego, pulsando en cualquier momento alguna tecla especial (como ESC, F2, etc)

El que vamos a programar en esta fase es el segundo, que es más complejo.

Composición del menú

El menú debe aparecer al pulsar alguna tecla especial (ESC, F2, “m”, o la tecla que decida) durante el juego. Por lo tanto, hay que añadir el control de esa tecla en los procedimientos de selección de casilla, que es donde se lee el teclado.

El menú puede aparecer a toda pantalla (borrando momentáneamente el tablero) o bien en el lateral (en el espacio reservado a los mensajes del usuario)

Las opciones del menú deben ser las siguientes (el texto, el aspecto y el orden, que cada cual lo elija a su gusto):

  • Salir del programa. El programa terminará inmediatamente.
  • Empezar una partida nueva. Volveremos al principio del juego: elección de jugadores, dibujo del tablero con las piezas en su disposición inicial, etc.
  • Continuar la partida. Volveremos al tablero y continuaremos el juego tal y como lo habíamos dejado.
  • Guardar la partida. El programa nos preguntará un nombre de archivo. A ese nombre le añadiremos la extensión “.PGN” y lo guardaremos en un archivo de disco, con el formato que en el siguiente epígrafe se detalla. Después volveremos a este mismo menú de opciones, para que el usuario decida qué quiere hacer a continuación (salir, continuar, etc)
  • Cargar una partida guardada. El programa nos preguntará un nombre de archivo. Luego buscará un archivo con ese nombre y, si lo encuentra, cargará la partida almacenada en él. Después volverá a este mismo menú de opciones para que el usuario decida qué quiere hacer a continuación.

Selección de opciones

Para seleccionar una opción se puede optar por varios caminos:

  1. Lo más fácil es mostrar un número delante de cada opción y luego pedir al usuario que teclee el número de la opción que quiere seleccionar (ver figuras).
  2. Una versión más elaborada consiste en escribir una marca delante de la primera opción. El usuario podrá mover esa marca de una opción a otra con las teclas del cursor (flecha arriba y flecha abajo), seleccionando una opción al pulsar Enter o Espacio.
  3. Aún quedaría mejor si la opción seleccionada apareciese escrita en vídeo inverso.

Puede empezar programando la versión 1 del menú y, más adelante, cuando todo lo demás funcione, mejorar su aspecto.

Primera versión del menú (la opción se selecciona tecleando el número):

MIAJEDREZ 1.0

MENÚ DE OPCIONES
(1) Continuar partida
(2) Empezar otra partida
(3) Guardar partida
(4) Cargar partida
(5) Salir del programa

Introduzca opción (1-5): _

Versión mejorada del menú (la opción se selecciona moviendo una marca al lado de las opciones, o poniéndolas en video inverso, hasta que se pulse Intro):

   MIAJEDREZ 1.0

   MENÚ DE OPCIONES

>> Continuar partida
   Empezar otra partida
   Guardar partida
   Cargar partida
   Salir del programa

Guardar y cargar partidas

La otra función que vamos a añadir en esta fase es la posibilidad de guardar y cargar partidas en archivos de disco.

Para guardar una partida en un archivo podemos hacer dos cosas: una, guardar el estado actual del tablero; dos, guardar todos los movimientos que se hayan producido en la partida desde el comienzo.

  • Con el primer método, la recuperación de la partida es muy fácil: basta con recuperar el estado del tablero.
  • Con el segundo, tendremos que reproducir todos los movimientos efectuados desde el principio y reflejarlos en el tablero.

Aunque el primer método resulta, sin duda, más sencillo, nosotros vamos a optar por el segundo. La razón estriba en que en la fase 6 vamos a añadir una función (la reproducción de partidas guardadas) que necesitará conocer todos los movimientos de la partida.

Tenemos que decidir cómo vamos a guardar los movimientos para poder recuperarlos después y reproducirlos sin posibilidad de duda. En los siguientes apartados describiremos una forma de hacerlo.

Notación algebraica

Para guardar una partida, por lo tanto, necesitamos haber guardado en alguna estructura de datos todos los movimientos realizados desde el principio. Es el momento de elegir una estructura de datos adecuada para ello y añadirla al diseño de las estructuras que hicimos en la fase 1.

La estructura elegida debería ser dinámica porque, en principio, no sabemos cuantos movimientos va a tener la partida; aunque, para simplificar, también se puede utilizar una estructura estática, siempre que tenga espacio suficiente para almacenar un número elevado de movimientos.

Los movimientos, en ajedrez, suelen representarse con la llamada notación algebraica. Esta notación es muy simple y es conveniente que la use para almacenar en su estructura de datos los movimientos de la partida.

La notación algebraica consiste en lo siguiente:

  • Cada pieza se identifica con una letra: R = rey, D = dama, T = torre, A = alfil, C = caballo, P = peón.
  • Cada casilla se identifica con su letra (en minúscula) y su número. Por ejemplo: f3, d5, h1, etc.
  • Cada movimiento se identifica con la letra de la pieza que se mueve seguida de la casilla de origen y la casilla de destino, separadas por un guión (-). Por ejemplo: Af1-c4 quiere decir que el alfil se ha movido de la casilla f1 a la c4.
  • Cuando la pieza que se mueve es un peón, no se suele poner la P, sobreentendiéndose que, en ausencia de letra, la pieza movida es un peón. Por ejemplo: d2-d3 significa que se mueve el peón de d2 a d3.
  • Cuando una pieza toma a otra (se la “come”), el guión se sustituye por una “x”. Por ejemplo: Cf6xd5 quiere decir que el caballo que había en f6 se mueve a d5 y se come la pieza que allí hubiera.
  • El enroque se representa con O-O (enroque corto) o con O-O-O (enroque largo)
  • Cuando hay jaque se añade un “+” al movimiento. Por ejemplo: Cf6-d5+
  • Cada jugada se antepone del número de la misma. En cada jugada aparecerán dos movimientos: primero el del jugador blanco y luego el del negro. Estos son, por ejemplo, los 6 primeros turnos de una partida:

1. e2-e4    e7-e5
2. Cg1-f3    Cb8-c6
3. Af1-c4    Cg8-f6
4. Cf3-g5    d7-d5
5. e4xd5    Cf6xd5
6. Cg5xf7    …

La notación algebraica reducida es una variación de la notación convencional, consistente en omitir la casilla de origen (excepto cuando el peón come a otra pieza). Por ejemplo, si un movimiento se nota como Cf3, quiere decir que el caballo se ha movido a f3. Pero, ¿qué caballo? Lo normal es que sólo haya un caballo que se pueda mover a f3, pero a veces pueden producirse ambigüedades, que ya veremos como se resuelven. La partida anterior, en esta notación algebraica reducida, se representaría así:

1. e4    e5
2. Cf3    Cc6
3. Ac4    Cf6
4. Cg5    d5
5. e4xd5    Cxd5
6. Cxf7    …

De cualquiera de los modos se puede representar, con pocos símbolos, la partida completa. Elija una de las dos notaciones para almacenar en tu estructura de datos la partida conforme se vaya disputando, aunque es más recomendable la notación algebraica convencional, que carece de ambigüedades.

El formato PGN

PGN (Portable Game Notation) es el nombre de un formato de archivo para guardar partidas de ajedrez muy extendido entre la comunidad informático-ajedrecista. Muchos programas de ajedrez pueden leer y grabar partidas en este formato. En Internet puede encontrar muchos sitios donde descargarte partidas (famosas, históricas o simplemente curiosas) grabadas en archivos PGN.

Los archivos PGN son de texto ASCII, es decir, que pueden abrirse y leerse perfectamente con cualquier editor de texto. Utilizan notación algebraica reducida, así que, con un poco de práctica, pueden interpretarse a mano, es decir, sin necesidad de ordenador.

Debido a lo extendido que está, vamos a intentar que nuestro programa guarde las partidas en formato PGN. Si lo hace bien, no sólo podrá compartir las partidas guardadas por su programa con otros jugadores, sino que podrá descargarse partidas de Internet y cargarlas (y reproducirlas) en su programa.

Descripción del formato PGN

Los archivos PGN ofrecen muchas posibilidades. Aquí sólo nos referiremos a las imprescindibles para guardar y recuperar partidas. Puede encontrar descripciones completas del formato en Internet.
Aquí tiene un ejemplo de archivo PGN. Después comentaremos qué significa cada línea.

[Event "Badalona Open"]
[Site "?"]
[Date "1991.??.??"]
[Round "?"]
[White "J.Vila"]
[Black "Richard Guerrero"]
[Result "0-1"]
1. d4 d5 2. c4 e5 3. dxe5 d4 4. Nf3 Nc6 5. g3 Bg4
6. Nbd2 Bb4 7. Bg2 Qd7 8. a3 Bxd2+ 9. Qxd2 O-O-O
10. b3 f6 11. exf6 Nxf6 12. h3 Bf5 13. g4 Bg6
14. Nh4 Ne4 15. Bxe4 Bxe4 16. f3 Rhf8 17. fxe4 Qe7
18. Ng2 Qxe4 19. Rg1 Ne5 20. Nh4 Rf3
21. Kd1 Rf2 22. Qe1 d3 23. e3 d2
24. Qxf2 dxc1=Q+ 25. Kxc1 Nd3+ 0-1

Los archivos PGN tienen dos secciones: la cabecera, donde aparece información general (nombre del torneo, fecha, nombre de los jugadores, etc) y el cuerpo, donde se almacenan los movimientos de la partida usando notación algebraica reducida.

Cabecera del archivo PGN

En la cabecera encontramos varios campos, cada uno en una línea distinta y encerrados entre dos corchetes, “[" y "]“. Los campos más habituales son:

  • Event: nombre del torneo o evento donde se produjo la partida
  • Site: lugar de la partida. Observa que se puede escribir una “?” si no se conoce algún dato
  • Date: fecha de la partida, en formato AAAA:MM:DD
  • Round: ronda (si se trata de un torneo)
  • White: nombre del jugador blanco
  • Black: nombre del jugador negro
  • Result: resultado de la partida. Puede ser “1-0″ (ganaron las blancas), “0-1″ (ganaron las negras), “1/2-1/2″ (tablas) o ” * ” (partida sin terminar)

Es posible que en algunos archivos PGN aparezcan otros campos en la cabecera. Si es así, nuestro programa puede, simplemente, ignorarlos.

Movimientos

Si observa el ejemplo de notación PGN que hemos escrito más arriba, lo primero que llama la atención es que los nombres de las piezas son diferentes. La razón es que se usan los nombres en inglés, y no en castellano, para identificar las piezas. Así pues, la letra que corresponde a cada pieza es:

  • K = king (rey)
  • Q = queen (reina o dama)
  • R = rook (torre)
  • B = bishop (alfil)
  • N = knight (caballo)
  • P = pawn (peón)

La P, como en español, no se usa. Observe que los movimientos se representan con notación algebraica reducida, en donde no aparece la casilla de origen, salvo cuando el peón se come a otra pieza (en este caso, puede aparecer la casilla de origen o sólo su columna).

Si sigue observando, verá que los movimientos se escriben con su número seguido de un punto, y, a continuación, separados por espacios, cada uno de los movimientos de ese turno, primero el del jugador blanco y luego el del negro. Después hay otro espacio y, tras el, el siguiente movimiento.

El jaque mate, que no aparece en este ejemplo, se representa con el símbolo # en lugar de + (éste se reserva para el jaque normal). Después del último movimiento, si la partida está acabada, aparece el resultado (0-1 en el ejemplo)

Ambigüedades

La notación algebraica reducida, al contrario que la algebraica normal, puede interpretarse, en algunas ocasiones, de varias maneras. La razón es que, al no indicarse la casilla de origen del movimiento, puede ocurrir que la casilla de destino pueda ser ocupada por varias piezas. En caso de ambigüedad, ésta se resuelve utilizando estas tres reglas:

  1. Si la pieza que debe moverse puede distinguirse por la letra de la columna que ocupa, se inserta esa letra en el movimiento, justo después de la letra que identifica la pieza. Por ejemplo: Nbd2 significa que el caballo que se mueve a d2 es el que estaba en la columna b.
  2. Si lo anterior falla, se inserta el número de la fila en vez de la letra de la columna.
  3. Si ambas cosas siguen provocando ambigüedad, se añadirán las dos cosas, o sea, la letra de la columna y el número de la fila de origen, como en la notación algebraica convencional, sólo que después de la letra de la pieza, no antes.

Por ejemplo, imagine que los caballos blancos están ocupando las casillas c3 y g1, y que al jugador blanco, que le toca mover, decide mover un caballo a la casilla e2. Si el movimiento se especifica sólo con “Ne2″, es imposible saber cuál de los dos caballos se ha movido. En cambio, usando el primer criterio para eliminar ambigüedades, se usará la notación “Nce2″ o “Nge2″ para indicar si el caballo que se mueve es el que estaba en la columna c o el de la g.

Otros símbolos

En los archivos PGN pueden aparecer otros símbolos, como interrogaciones, admiraciones, etc. Nosotros no los vamos a usar y, por lo tanto, no los generaremos desde nuestro programa. Al leer archivos PGN bajados de internet, ignoraremos cualquier símbolo que no sea los que hasta aquí hemos expuesto.

Guardar y cargar partidas usando el formato PGN

Una vez conocido y comprendido el formato PGN, lo que hay que hacer para guardar partidas está muy claro:

  1. Almacenar en alguna estructura de datos, y en notación algebraica, todos los movimientos de la partida conforme se vayan produciendo
  2. Desde la opción “guardar partida” del menú de opciones, invocar a una función que pregunte un nombre de archivo y, a continuación, escriba un archivo con formato PGN a partir de los movimientos almacenados en memoria. Este archivo tiene que tener su cabecera y su cuerpo, tal y como hemos descrito, para cumplir con el estándar PGN.

Para cargar las partidas guardadas, el procedimiento será al contrario:

  1. Preguntar un nombre de archivo y comprobar si existe.
  2. Leer los datos del archivo PGN e ir interpretando los movimientos, modificando las estructuras de datos como si los movimientos se estuvieran realizando en realidad.

Evidentemente, lo más difícil de hacer es controlar las posibles ambigüedades. Podemos establecer un plan de acción para dividir este problema en subproblemas:

  • Primero, modificar el programa para que vaya almacenando en memoria los movimientos de la partida.
  • Segundo, escribir el módulo para guardar partidas sin preocuparnos de las ambigüedades.
  • Tercero, escribir el módulo que carga partidas, sin preocuparnos de las ambigüedades. Este paso lo podemos dividir en dos: la lectura del archivo propiamente dicha y la realización de los movimientos que están guardados en el archivo.
  • Cuarto, pensar un modo de controlar las ambigüedades tanto al escribir como al leer.

El siguiente paso lógico tras la fase 3 es controlar todos los movimientos de piezas para asegurarnos que cumplen con las reglas del ajedrez. Además, añadiremos el control del tiempo. Esto empieza a ser serio porque, una vez terminada esta fase, tendremos una primera versión completamente operativa del juego, en la que será posible que dos jugadores humanos jueguen al ajedrez.

Para lograrlo, dividiremos esta fase en 4 pasos:

  1. Controlar la casilla de origen
  2. Controlar la casilla de destino
  3. Controlar si se toma una pieza enemiga
  4. Controlar el tiempo

1º) Controlar la casilla de origen

Este control es muy fácil de realizar. Consiste, simplemente, en comprobar que, en la casilla de origen, existe una pieza propia, es decir, del color del jugador a quien le corresponde mover.

Si el jugador selecciona una casilla en la que hay una pieza enemiga, o bien no hay ninguna pieza, se mostrará un mensaje de error y se obligará al jugador a elegir otra casilla de origen.

2º) Controlar la casilla de destino

Este control es más complicado: consiste en verificar que la pieza que se ha elegido como origen puede efectivamente moverse a la casilla seleccionada como destino, sin infringir ninguna regla del juego. Como cada pieza tiene sus propios movimientos, el algoritmo de control dependerá de la pieza que se intenta mover. Dicho de otra forma: tendrá que escribir una función de control diferente para cada tipo de pieza.

Llevar a cabo este control va a ser de las cosas más difíciles del programa, así que debe pensarlo con mucha calma y, otra vez, dividir la tarea en subtareas:

  1. Primero pensaremos en los movimientos normales de cada tipo de pieza. Más abajo encontrará esbozado un algoritmo para controlar los movimientos de la torre.
  2. Después pensaremos en cómo controlar los movimientos especiales de aquéllas piezas que los tienen (el enroque, la promoción del peón, etc)
  3. Por último, pensaremos en cómo controlar las situaciones de jaque y de tablas, ya que en estos casos los movimientos correctos quedan muy reducidos.

Además, recuerde que las piezas no pueden pasar, al moverse, por encima de otras, excepto el caballo.

Como el asunto es complicado, es mejor que lo dividamos en los tres subproblemas antes mencionados. En primer lugar, por tanto, nos ocuparemos de los movimientos habituales de cada pieza y luego añadiremos los controles necesarios para los movimientos y situaciones excepcionales. Cada uno de los movimientos habituales de cada pieza lo trataremos como un problema individual. Como ve, se trata de usar otra vez la táctica de “divide y vencerás”

A modo de ejemplo, pensemos en cómo podríamos controlar los movimientos habituales de la torre.

Supongamos que el jugador ya ha elegido la casilla de origen del movimiento, que ésta ha sido comprobada y que en su interior hay una torre de su propiedad. Llamaremos a esta casilla (ox, oy), siendo ox la columna de origen y oy la fila de origen.

Supongamos también que el jugador ha elegido la casilla de destino, que identificaremos con las coordenadas (dx, dy). Nuestro objetivo ahora es comprobar si esta casilla de destino es o no correcta. He aquí un posible algoritmo para hacerlo.

1) La primera comprobación consistirá en ver si (ox, oy) y (dx, dy) son dos casillas diferentes:

si (ox == dx) y (oy == dy) entonces MOVIMIENTO INCORRECTO

2) Después habrá que comprobar que la torre se está moviendo de acuerdo a sus posibilidades, es decir, a lo largo de su fila o a lo largo de su columna. Es decir, ox debe ser igual a dx o, si no, oy debe ser igual a dy. Si no se cumple ninguna de las igualdades, el movimiento es incorrecto:

si (ox != dx) y (oy != dy) entonces MOVIMIENTO INCORRECTO

3) Ya sabemos que la casilla de destino está en la misma fila o en la misma columna que la de origen. En principio, es un movimiento correcto, pero antes de darlo por bueno hay que comprobar que en la trayectoria del movimiento no haya ninguna pieza que intercepte a la torre. Para eso, haremos un bucle que recorra la columna (si la torre se mueve a lo largo de su columna) o la fila (si el movimiento es a lo largo de la fila):

si (ox != dx) entonces    // La torre se desplaza a lo largo de la columna
  para i desde ox hasta dx hacer
    si hay una pieza en (i, oy) --> MOVIMIENTO INCORRECTO
si (oy != dy) entonces    // La torre se desplaza a lo largo de la fila
  para i desde oy hasta dy hacer
    si hay una pieza en (ox, i) --> MOVIMIENTO INCORRECTO

En realidad, esto es un poco más complicado, porque hay que hacer la comprobación desde una casilla después del origen (ya que en el origen está situada la misma torre, y este algoritmo interpretaría que se intercepta a sí misma), y dejarla una casilla antes del destino (porque en la casilla destino puede haber otra pieza que va a ser “comida” por la torre)

4) Por último, hay que comprobar si en la casilla destino hay una pieza. Si es una pieza enemiga, va a ser tomada (o “comida”). Si es una pieza propia, el movimiento es incorrecto.

Con esto quedaría comprobada la corrección o incorrección del movimiento habitual de una torre. Si el movimiento resultara ilegal, se debe mostrar un mensaje de error y pedir al jugador que vuelva a elegir un origen y un destino. Algo similar hay que pensar para cada tipo de pieza, ya que cada una tiene su propio movimiento.

Una vez programados estos controles para los movimientos habituales de cada pieza, habría que ocuparse de los movimientos especiales (como el enroque o la salida del peón).

Y más aún: cuando haya programado eso, hay que controlar la situación de jaque y la de tablas: si el rey está amenazado con un jaque, hay que hacer obligatoriamente un movimiento que deshaga el mismo, y cualquier otro movimiento, aunque en circunstancias normales fuera legal, debe ser prohibido. Más complicado puede ser controlar las tablas: cuando no sea posible realizar ningún movimiento, el juego debe terminar. Esto debemos dejarlo para las últimas fases de desarrollo del juego, cuando introduzcamos la inteligencia artificial.

3º) Comprobar si se toma alguna pieza enemiga

Tomar o “comer” una pieza enemiga consiste en ubicar una pieza propia en el lugar del tablero que ocupaba la otra, y eliminar a la enemiga del tablero.

Es posible que tenga que programar algún código adicional para sustituir una pieza por otra en el tablero. También puede emitir algún mensaje informativo al respecto.

Por último, recuerde dos cosas sobre el peón: se mueve de forma diferente cuando se “come” a una pieza enemiga que cuando no lo hace, y tiene un movimiento especial llamado “toma al paso”. El peón, con su aparente insignificancia, le puede dar bastantes quebraderos de cabeza…

4º) Controlar el tiempo

Añadir el control del tiempo será bastante sencillo. Necesitaremos mantener dos contadores de tiempo, uno para el jugador blanco y otro para el negro. Utilizando las funciones estándar para obtener la hora del reloj interno (time(), localtime(), gmtime(), etc) podemos saber cuánto tiempo tarda un jugador en seleccionar su casilla de origen y su casilla de destino.

Una posible manera de hacerlo es mirar qué hora marca el reloj interno cuando un jugador recibe el turno. En el bucle de lectura del teclado (cuando el jugador está pulsando las flechas del cursor), volveremos a leer la hora del reloj interno, una vez en cada pasada. Cuando transcurra un segundo, lo reflejaremos en el reloj del jugador.

En esta fase debe tratar de programar las rutinas para poder mover las piezas.

Primero moverá una pieza el jugador blanco, luego el negro, luego el blanco, y así sucesivamente. Aún no entrarán en juego los relojes ni controlaremos si el movimiento que se realiza con cada pieza cumple con las reglas del ajedrez: eso lo dejaremos para la siguiente fase.

Descompondremos esta fase en dos pasos:

1º) Mover las piezas tecleando las coordenadas

En una primera aproximación, para que cada jugador indique sus movimientos, podemos preguntar en el área de la pantalla dedicada a los mensajes de usuario (a la derecha de la figura) cuál es la casilla de origen del movimiento y cuál la de destino. Para identificar una casilla será necesario que el jugador introduzca su fila y su columna.

El movimiento se debería reflejarse inmediatamente en el tablero invocando a la función encargada de repintar el tablero.

2º) Mover las piezas seleccionando la casilla con las teclas del cursor

Cuando haya conseguido realizar movimientos de este modo, es el momento de mejorar la jugabilidad. Para el jugador es muy incómodo tener que introducir a través del teclado la fila y la columna de origen y de destino. Es mucho mejor que pueda mover una marca a través del tablero para seleccionar las casillas de origen y de destino.

Esa marca puede ser, por ejemplo, una marca que señale la casilla, o un recuadro de color alrededor de la casilla, que se mueva con las flechas del cursor, de manera que al pulsar “Intro” o “Espacio” o algo parecido, la casilla quede seleccionada.

Así, el jugador puede cómodamente seleccionar dos casillas (primero, la de origen; luego, la de destino) para hacer sus movimientos. En la zona reservada al texto (en el panel de la derecha) pueden aparecer las coordenadas de la casilla que haya sido seleccionada.

Piense en ello. No es nada difícil y hará que el juego empiece a parecer algo serio. No pase a la siguiente fase hasta haber terminado ésta.

Si aprecias su estabilidad mental, no empiece esta fase hasta haber completado la primera y estar bastante seguro de lo que se trae entre manos. Sería la forma más fiable de pasar las próximas semanas programando algo que jamás funcionará.

Una vez diseñado el programa, vamos a empezar a implementar los primeros módulos. Lo haremos de manera que podamos ir probando lo que vayamos haciendo.

Inicialización

Los primeros módulos que hay que programar son los que se encarguen de dar el valor inicial a las estructuras de datos del programa (el tablero, los movimientos, etc; cada uno que se las apañe con las estructuras que haya elegido).  Por ejemplo: hay que colocar las piezas en el tablero en su posición inicial.

También programaremos la parte en la que el juego nos pregunta de qué tipo son los jugadores (humanos o máquinas) y qué color tendrá adjudicado cada uno (blanco o negro). Por ahora los dos jugadores tendrán que ser humanos: la posibilidad de que juegue la máquina se añadirá en la fase 8.

La función o funciones que inicialicen estas estructuras tienen que estar pensadas para que, en una fase posterior, la inicialización también se pueda hacer desde un archivo de disco en el que habrá guardadas otras partidas (fase 5) cambiando el menor número posible de cosas.

Interfaz de texto

En esta fase también programaremos los módulos que dibujen el tablero y las piezas en modo texto, diseñando la pantalla de tal modo que quede un área reservada para los mensajes que se necesiten dar al usuario y otra para el reloj.

El aspecto final de la pantalla puede ser algo parecido al de la siguiente figura; observe cómo las piezas se simbolizan con letras (el uso de gráficos no se contempla hasta la fase 7)

Para poder dibujar una pantalla como la anterior necesitamos funciones que nos permitan escribir en cualquier punto de la consola y manejar los colores libremente. ANSI C no dispone de tales funciones, pero existen muchas librerías para ello. Una de las más utilizadas en entornos Linux es Ncurses, que, de hecho, se incluye con la mayoría de las distribuciones.

Para más información sobre ncurses, consulte este artículo.

Antes de comenzar la codificación del programa es conveniente dedicar un tiempo a diseñarlo (lo ideal sería hacer un análisis y diseño completo, pero de ello ya hablaremos algún día). Nos vamos a dedicar a este diseño previo en este primer post dedicado al juego del ajedrez.

  1. Lo primero es, sin duda, leer y comprender detenidamente las reglas del juego si no estamos demasiado familiarizados con el ajedrez, así como lo que se espera que haga el programa. De ello ya hablamos en el artículo anterior.
  2. Diseñar las estructuras de datos que vamos a utilizar.
  3. Diseñar la estructura modular del programa.
  4. Diseñar la estructura de archivos del programa.

Veamos con más detalle los pasos 2, 3 y 4.

Eligiendo las estructuras de datos

La correcta elección de las estructuras de datos es de vital importancia para el posterior desarrollo del programa.

Debe usted elegir las estructuras que estime más convenientes para almacenar toda la información necesaria, y pensar detenidamente en ellas antes de darlas por buenas, porque una modificación de las estructuras de datos después de comenzada la codificación puede obligarle a reescribir una gran cantidad de código.

Para ayudarle en la correcta elección de estructuras, a continuación enumeramos la información más importante que tiene que manejar el programa:

  • El tablero. Debemos almacenar la información de todas las casillas del tablero: su color, su posición, si la ocupa alguna pieza o no.
  • Las piezas. El programa tiene que saber en qué lugar del tablero se encuentra cada pieza.
  • El estado del juego. Hay que almacenar diversa información sobre el estado en el que se encuentra el juego. Por ejemplo, a qué jugador le corresponde el turno, si se ha producido un jaque, si el rey o la torre han sido ya movidos (en cuyo caso se les prohibe hacer el enroque), etc.
  • Los movimientos. Hay que guardar todos los movimientos realizados desde el comienzo del juego por si se desea guardar la partida para continuarla en otro momento.
  • Además, es conveniente que definir constantes para los símbolos que se vayan a utilizar con más frecuencia en tus estructuras de datos. Por ejemplo, si en la estructura donde se almacene el tablero se va a simbolizar el peón con el carácter ‘P’ o el caballo un una ‘C’, es muy recomendable definir dos constantes PEON y CABALLO y asignarles, respectivamente, los valores ‘P’ y ‘C’, de manera que, en adelante, se pueda usar el identificador PEON en lugar de la constante ‘P’, o CABALLO en lugar de ‘C’, lo que hará que el programa sea mucho más legible y fácil de modificar.

Diseñando la estructura modular

Una vez elegidas las estructuras de datos llega el momento de que piense qué módulos va a hacer y cómo se van a llamar unos a otros.

Obviamente, debe existir un módulo principal, que será la función main(). Éste debe llamar a otros; por ejemplo, puede empezar llamando a un módulo inicializar(), que le asigne un valor inicial  a todas las variables.

Luego puede llamar al módulo pintar_tablero(), que dibuje el tablero en la pantalla, y que a su vez llame 64 veces al módulo pintar_casilla(), etc.

De este modo, trate de imaginar qué módulos va a necesitar y cómo se van a llamar unos a otros, y construya un diagrama de descomposición modular para tenerlo siempre presente.

Complete el diagrama describiendo brevemente qué hace cada módulo. Asegúrese de que cada módulo realice una labor sencilla y fácil de programar. Si no es así, descompóngalo en varios módulos más sencillos.

Asegúrese de que cada módulo tiene una y solo una función bien clara y definida. Esto se denomina cohesión interna del módulo y debe ser la mayor posible.

Asegúrese también de que cada módulo influya lo menos posible en el funcionamiento de los demás, es decir, que cada uno haga su labor sin afectar, en lo posible, a la de los otros. Esto se refleja, por ejemplo, en la cantidad de parámetros que los módulos se pasan entre sí cuando se invocan unos a otros. Esta cantidad debe ser la mínima imprescindible. Esto se denomina acoplamiento entre módulos, y hay que reducirlo todo lo posible.

No hay una sola forma correcta de hacer la descomposición. Cada cuál debe encontrar la suya. Pero recuerde: ¡divide y vencerás!

Eligiendo una estructura de archivos

Cuando un programa es largo, como el que nos ocupa, es poco recomendable escribir todas las funciones en el mismo archivo fuente, porque resulta muy complicado moverse dentro de un archivo muy largo y la compilación puede llegar a hacerse terriblemente lenta. Es mejor dividirlo en varios archivos más pequeños.

La división en archivos no debe hacerse a lo loco, sino que, partiendo de la descomposición modular, colocaremos las funciones más relacionadas entre sí juntas en el mismo archivo.

Por ejemplo, es muy, pero muy recomendable que las funciones de entrada / salida estén juntas en un archivo, y que ninguna otra función de otro archivo haga una entrada (es decir, un scanf() o similar) ni una salida (un printf() o similar). Esto facilitará mucho la tarea de localizar los errores de ejecución y la modificación posterior del programa.

Cada archivo fuente, de extensión .c, tendrá asociado un archivo de cabecera de extensión .h. En este archivo de cabecera se incluirán los prototipos de las funciones, de modo que puedan ser llamadas desde cualquier otro lugar del programa.

Por ejemplo, imagine que tenemos estos archivos:

  • ajedrez.c: contiene la función main()
  • interfaz.c  e  interfaz.h: contienen las funciones de entrada/salida
  • movimientos.c  y  movimientos.h: contienen las funciones para realizar los movimientos de las piezas

Imagine que el archivo interfaz.c contiene una función llamada pintar_pieza(), que muestra en la pantalla una pieza del ajedrez colocada en determinada posición del tablero. Suponga que deseamos llamar a esa función desde otra función situada en movimientos.c. Para eso, hay que colocar el prototipo de pintar_pieza() en interfaz.h, y hacer un #include “interfaz.h” en el archivo movimientos.c. Así, cualquier función de movimientos.c puede llamar a las funciones de interfaz.h

Categorías

Licencia

ClustrMaps