You are on page 1of 40

Oiga, pero… ¿yo puedo escribir un juego

de ajedrez?
(Este artículo forma parte del Curso de Programación en C)

Pues sí, mire. Hace unos años, propuse a un grupo de alumnos que, como trabajo de fin
de curso, realizasen un programa completo de cierta complejidad. Los juegos son,
probablemente, las aplicaciones más completas que pueden desarrollarse durante el
aprendizaje de las técnicas de programación, porque suelen requerir la utilización de
muchos recursos distintos y, sobre todo, porque programar juegos es mucho más
divertido que hacer programas de contabilidad, qué demonios.

Programar un juego de ajedrez presenta una dificultad elevada sin llegar a ser excesiva.
Además, como otros juegos de mesa, tiene un conjunto de reglas muy bien definido que
nos evitará ambigüedades, y permite dividir el desarrollo en diferentes fases pudiendo
disponer casi desde el principio de un prototipo operativo.

En los próximos posts iremos describiremos cómo podemos desarrollar este juego en
diez “sencillos” pasos asequibles para todo el que tenga nociones de programación. Al
terminar, tendremos un juego de ajedrez en modo gráfico capaz de jugar contra nosotros
con un nivel de competencia razonable.

¿Aún no se lo cree? Ya verá, ya. Aquí dejo una captura de pantalla del juego en cuestión
para ir abriendo boca.

Plan de trabajo

Ante todo, un aviso: aquí no encontrará la implementación de un juego de ajedrez. Para


eso ya tiene el GNU Chess, que es un juego extraordinario y cuyo código fuente está
disponible (y no es el único software libre de ajedrez).
Lo que encontrará son una serie de pautas bastante detalladas sobre los pasos que
debería seguir para conseguir por usted mismo programar un juego de ajedrez. Aquí
venimos a jugar al fútbol, no a verlo por televisión. Si quería sentarse cómodamente a
ver un partido, lo siento, este no es su sitio.

Aclarado esto, metámonos en harina.

Hay una frase tremendamente útil a la hora de enfrentarse al desarrollo de un programa


de grandes dimensiones. Es tan importante que lo pondré en negritra: no intente
resolverlo todo a la vez.

El método de trabajo “divide y vencerás” no se llama así casualmente. Dividir el


problema en tareas más sencillas es la única forma de culminar la tarea con éxito.
Incluso el viaje más largo empieza por el primer paso.

En realidad, los grandes proyectos de software se planifican cuidadosamente siguiendo


los métodos propios de cualquier ingeniería, adaptados a las peculiaridades del
desarrollo de software. La ingeniería del software es lo bastante interesante como para
dedicarle otros posts, pero este no es el momento, así que optaremos por una
aproximación menos formal.

Fases de desarrollo

Vamos a proponer un plan de trabajo para ir “montando” el programa poco a poco. Para
ello, dividiremos las tareas en 10 fases o etapas. Sólo se debe pasar a la siguiente etapa
si se ha resuelto satisfactoriamente la anterior. Esto es muy importante porque, si pasa a
una nueva etapa demasiado pronto y después tiene que volver atrás a resolver cosas que
dejó pendientes, es muy posible que tenga que tirar a la basura gran parte del trabajo
realizado.

A continuación, resumimos a grandes rasgos las 10 etapas, que se irán explicando con
mayor detalle en los siguientes posts (a razón de un post por etapa, excepto la 7 y la 8,
que irán juntas en el mismo artículo). Iremos enlazando los posts con este artículo para
poder usarlo como índice.

• Fase 1: Diseño del programa. Las primeras fases del ciclo de vida clásico
(especificación de requisitos, análisis y diseño) escapan al propósito de nuestra
asignatura, pero es imprescindible un mínimo diseño previo antes de afrontar un
programa relativamente complejo como es éste. En esta fase realizaremos este
diseño, de vital importancia antes de lanzarnos a programar.
• Fase 2: Inicialización. Interfaz de texto. En esta fase implementaremos las
estructuras de datos diseñadas en la fase anterior, y les proporcionaremos sus
valores iniciales. También diseñaremos una primera versión de la interfaz del
programa, que será en modo texto.
• Fase 3: Movimientos no controlados. Programaremos los algoritmos para mover
las piezas por el tablero, pero, de momento, sin atenernos a las reglas del juego.
Añadiremos algunos elementos al interfaz para facilitar los movimientos y la
jugabilidad.
• Fase 4: Movimientos controlados. Agregaremos las rutinas de control necesarias
para asegurarnos de que los movimientos realizados por el jugador son legales,
es decir, cumplen con las reglas del ajedrez. También añadiremos el control del
tiempo que emplea cada jugador en realizar sus movimientos. Con esto ya
tendremos una versión básica del juego, en la que dos jugadores humanos
podrán enfrentarse utilizando nuestro programa como tablero de juego.
• Fase 5. Guardar y recuperar partidas. Añadiremos diversas funciones para
guardar en disco una partida y poder recuperarla después para continuar
jugando. También añadiremos en esta fase el menú de opciones del programa.
• Fase 6. Reproducir partidas guardadas. Esta funcionalidad nos permitirá ver en
la pantalla el desarrollo de las partidas que tuviéramos guardadas, así como
reproducir otras partidas que descarguemos de Internet. También añadiremos en
esta fase el sistema de ayuda en línea y la posibilidad de que aparezcan escritos
los movimientos que se hayan realizado hasta el momento.
• Fases 7 y 8. Interfaz gráfica. Con el fin de hacer el juego visualmente más
atractivo (y más fácil de utilizar), sustituiremos nuestro primitivo interfaz de
texto por un interfaz gráfico.
• Fases 9 y 10. Inteligencia artificial. Por último, introduciremos la posibilidad de
que uno de los jugadores sea el ordenador, añadiendo las funciones necesarias
para que éste “piense” cual es la jugada que más le conviene hacer en cada
momento.

Qué debe hacer el programa

No vamos a reproducir aquí las reglas del juego del ajedrez. Encontrará montones de
páginas en Internet donde las explican. Lo que haremos será especificar con claridad
qué podrá hacer nuestro programa cuando esté terminado.

Respetará todas las reglas básicas del ajedrez, incluyendo los movimientos especiales
(aunque la toma al paso del peón puede resultar complicada) y el control del tiempo.

Dispondrá de un tablero de juego en dos dimensiones que en principio será en modo


texto y luego se cambiará a un formato gráfico. En modo texto, las piezas se
representarán con su inicial (R = rey, D = dama, A = alfil, T = torre, C = caballo, P =
peón). El programador elegirá los colores que considere más convenientes para facilitar
la jugabilidad. En modo gráfico, las letras serán sustituidas por imágenes de las piezas,
para hacer el interfaz más atractivo.

El ajedrez es un juego para dos jugadores. Nuestro programa debe permitir que juegen
dos jugadores humanos, o bien un jugador humano contra la máquina. Esta última
característica es la más avanzada y difícil de programar, por lo que la dejaremos para el
final. El jugador humano debe poder elegir con qué piezas desea jugar, si las blancas o
las negras.

Además, el juego debe proporcionar en todo momento al jugador información sobre el


estado de la partida: tiempo transcurrido de cada jugador, si se ha tratado de hacer un
movimiento ilegal, si un rey se encuentra en jaque, etc. Se reservará un área de la
pantalla para proporcionar tales mensajes informativos a los jugadores.

Otra función del juego es que debe permitir grabar las partidas para recuperarlas
después. Es decir, se grabarán todos los movimientos realizados hasta el momento en un
archivo de disco para, más tarde, poder cargar la partida y reproducirla desde el inicio, o
bien continuarla desde el punto en el que se quedó.

Por último, existirá un sencillo sistema de ayuda en línea accesible desde la pantalla
principal del juego.

Un ejemplo de funcionamiento

Para dejar claro todo lo que pretendemos hacer, imaginemos una ejecución típica del
juego:

1. Al empezar, el programa da un mensaje de bienvenida


2. El programa pregunta si se va a comenzar una partida nueva o se va a cargar una
que ya esté grabada.
a. Si la partida es nueva, pregunta qué tipo de jugadores van a jugar (dos
humanos, humano contra máquina; podría ser interesante la posibilidad
de máquina contra máquina…) y qué color escoge cada uno. También
preguntará si se desea jugar con límite de tiempo o sin él, y cuál es el
límite de tiempo deseado.
b. Si la partida es grabada, esa selección de jugadores debe recuperarse del
archivo de disco, junto con los movimientos.
3. Aparece el tablero en su posición inicial (si la partida es nueva) o en el estado en
el que se quedó (si la partida se ha recuperado del disco)
4. Comienza el bucle principal del juego:
a. Aparece un mensaje informando de qué jugador es el turno, y su reloj
empieza a correr. El jugador hace su movimiento. Si el movimiento es
ilegal, el ordenador informa de ello y vuelve a pedir que se haga el
movimiento. Si el movimiento es legal, el reloj se detiene. Si se ha
producido alguna situación especial (por ejemplo, un jaque), se mostrará
un mensaje en la pantalla informando del hecho.
b. Se cambia el turno al otro jugador, que realiza su movimiento en las
mismas condiciones que su oponente.
c. Este bucle se repite hasta que la partida termina por alguno de los hechos
que se explicó al hablar de las reglas del juego, o hasta que el usuario
decide ir al paso 5.
5. En cualquier momento del punto 4, el jugador puede decidir interrumpir la
partida pulsando alguna tecla (por ejemplo, ESC). Si es así, se mostrará un menú
con varias opciones:
a. Salir del programa
b. Empezar otra partida
c. Continuar la partida interrumpida
d. Guardar la partida
e. Recuperar otra partida distinta que tuviéramos guardada en disco
f. Reproducir la partida en la pantalla desde el primer movimiento hasta la
situación actual (como si rebobináramos la partida hasta el principio y la
volviéramos a proyectar para verla como simples espectadores)
6. Por último, se implementará sistema de ayuda en el que se resuma el
funcionamiento del juego y las teclas con las que se utiliza. Esta pantalla será
accesible desde el paso 4 pulsando alguna otra tecla especial (por ejemplo, F1)
Ajedrez (paso 1): planificando y
diseñando el sistema
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

Ajedrez (paso 2): inicialización e interfaz


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

Ajedrez (paso 3): movimientos


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

Ajedrez (paso 4): movimientos


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

Ajedrez (paso 5): guardar y recuperar


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

Ajedrez (paso 6): finalizando la partida:


jaque y tablas
2 julio 2009 in ...en lenguaje C, ...para estudiantes, Programación

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.

Ajedrez (pasos 7 y 8): creando la interfaz


gráfica con SDL
5 julio 2009 in ...en lenguaje C, ...para estudiantes, Programación

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, OpenGL, SDL, GTK+, 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.

Ajedrez (paso 9): creando un


contrincante “inteligente” con una
función de evaluación de estática
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.
o
Si el peón está en el centro del tablero, añadir 12 puntos más
o
Añadir 2 puntos por cada casilla que haya avanzado el peón desde su
o
punto de partida
• CABALLO
o Cada caballo propio suma 315 puntos
o Añadir entre 0 y 15 puntos si está cerca del centro del tablero (más
cuanto más cerca del centro)
o Quitar entre 0 y 15 puntos si está lejos del centro (quitar más cuanto más
lejos)
• ALFIL
oCada alfil suma 330 puntos
oAñ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
o Cada torre suma 500 puntos
o 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
o La dama representa 940 puntos
o 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.

Ajedrez (paso 10): nace la “inteligencia”:


minimax con poda alfa-beta
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.

(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.
¿Cuándo se vuelve inteligente un
algoritmo?
6 abril 2008 in ...para estudiantes, Inteligencia artificial, Programación

Ya hemos hablado varias veces acerca de la Inteligencia Artificial (IA), pero hasta ahora
no hemos dicho nada acerca del tipo de algoritmos que se utilizan en IA. Hoy vamos a
acercarnos a ellos utilizando como ejemplo una rama de la IA particularmente atractiva:
los juegos. Usaremos un juego simple y conocido por todos: las tres en raya.

Descripción del juego

Usaremos una versión de las tres en raya muy sencilla. Disponemos de un tablero de
3×3 casillas. Hay dos jugadores, un ordenador y un humano. Las fichas del primero
serán equis (X), y las del segundo, oes (O). Empieza a jugar uno cualquiera. Digamos,
por ejemplo, que empieza la máquina. Su objetivo será conseguir colocar tres X en
línea, ya sea horizontal, vertical o diagonal, e impedir que su oponente humano coloque
tres Oes en raya.

Una solución no inteligente (por no decir idiota)

Cualquier programador medianamente competente puede programar este juego en un


par de horas con la condición de que la máquina no juegue de manera inteligente. Un
programador principiante quizás necesite más tiempo, pero también lo logrará.

La máquina decidirá su movimiento al azar. Simplemente, elegirá aleatoriamente un


número entre 1 y 9, y colocará su pieza en la casilla seleccionada. Si está ocupada,
elegirá otro número al azar, hasta que localice una casilla libre. El juego terminará
cuando uno de los jugadores gane, o bien cuando no quede ninguna casilla libre a donde
mover, en cuyo caso habremos alcanzado una situación de tablas.

+---+---+---+
Numeración de | 1 | 2 | 3 |
las casillas: +---+---+---+
| 4 | 5 | 6 |
+---+---+---+
| 7 | 8 | 9 |
+---+---+---+

Una solución por fuerza bruta

En la cultura popular, los brutos suelen ser poco inteligentes. Los algoritmos de fuerza
bruta no son una excepción. Ahora verán por qué.

El tablero de las Tres en Raya está, al principio, vacío. Por eso la máquina, al hacer el
primer movimiento, puede elegir entre 9 posibles casillas. El tablero vacío es un estado
particular del tablero. Al colocar una pieza en una de las 9 casillas, el tablero cambia de
estado. Hay, por lo tanto, 9 posibles estados que pueden alcanzarse desde el estado
inicial: “colocar X en la casilla 1″, “colocar X en la casilla 2″, y así hasta “colocar X en
la casilla 9″. Podemos representar esto gráficamente, dibujando el estado inicial y,
debajo, cada uno de los posibles estados que pueden alcanzarse desde él:

+---+---+---+

Desde cada uno de los nueve estados inferiores, el jugador humano (que tendría el turno
en ese momento de la partida) podría realizar 8 movimientos legales. Por lo tanto, desde
cada estado es posible alcanzar otros 8 estados. En total, tendremos 72 estados posibles
en el tercer nivel de profundidad.

+---+---+---+
Estado inicial --> | | | |
+---+---+---+
| | | |
+---+---+---+
| | | |
+---+---+---+
| | |
+--------------------+ | +-----------------+
| | |
| +----+ |
| | |
+---+---+---+ +---+---+---+ +---+---+---+
| X | | | | | X | | | | | |
+---+---+---+ +---+---+---+ +---+---+---+
| | | | | | | | ..etc.. | | | |
+---+---+---+ +---+---+---+ +---+---+---+
| | | | | | | | | | | X |
+---+---+---+ +---+---+---+ +---+---+---+
| | |
+------------+ | +-----------------+
| | |
| +-+ |
| | |
+---+---+---+ +---+---+---+ +---+---+---+
| O | X | | | | X | O | | | X | |
+---+---+---+ +---+---+---+ +---+---+---+
| | | | | | | | ..etc.. | | | |
+---+---+---+ +---+---+---+ +---+---+---+
| | | | | | | | | | | O |
+---+---+---+ +---+---+---+ +---+---+---+

Podemos seguir la progresión: desde cada uno de los 72 estados, la máquina (a quien le
correspondería de nuevo el turno) podría hacer 7 movimientos diferentes. Es decir,
existen 72 x 7 = 504 estados diferentes perfectamente alcanzables en el nivel 4.

Si continuamos este razonamiento, en el último nivel del árbol (porque así se denomina
esta estructura, aunque, en realidad, es un árbol invertido) tendremos 362.880 estados,
es decir, 9! (factorial de 9). Observen cómo los estados crecen a un ritmo acelerado
conforme profundizamos en la exploración del espacio de estados. Esto se denomina
explosión combinatoria y, si les parece “explosiva” en las Tres en Raya, imaginen lo
que puede ocurrir en juegos más complejos, como el ajedrez.

¿Para qué sirve todo esto? Atención, ahora nos estamos acercando a un punto crucial.
Un ordenador puede generar con bastante facilidad y rapidez todos los estados del
juego. Por lo tanto, puede saber qué combinaciones de movimientos acabarán con su
victoria, y cuáles acabarán en derrota o en tablas.

El algoritmo que juega a las Tres en raya es, entonces, bien sencillo. A grandes rasgos
quedaría más o menos así:

1. Generar el árbol de estados en su totalidad.


2. Luego, desde el estado de juego actual, explorar cada uno de los movimientos
que se pueden hacer, recorriendo hasta el fondo la rama correspondiente.
3. Cuando se alcance el fondo del árbol, contabilizar el número de estados que dan
la victoria y el número de estados que conducen a la derrota. Repetir este cálculo
con todos los posibles movimientos.
4. Después, decidirse por aquel movimiento que proporcione más posibilidades de
victoria y menos de derrota. Es decir, restar el número de estados-derrota del
número de estados-victoria contabilizados en cada rama, y seleccionar el
movimiento donde el resultado de esa operación sea máximo.

Siguiendo estas reglas, el programa siempre elegirá la casilla central como primer
movimiento, porque es la que más estados ganadores y menos estados perdedores tiene
colgando al fondo del árbol.

Observe que esto no asegura la victoria. De hecho, una partida de las Tres en raya entre
dos jugadores medios siempre acaba en tablas. Pero sí asegura que la máquina hará en
cada ocasión el mejor movimiento posible.

Si esta estrategia se aplicase, por ejemplo, al ajedrez, el programa informático sería


simplemente invencible. Sin embargo, el espacio de estados del ajedrez es del orden de
10150 estados… ¡Más que el número de partículas elementales del universo! Es lo que se
llama, con toda propiedad, un problema no computable.

Una solución “inteligente”

¿Hay inteligencia en la solución anterior? El programa se limita a explorar todas las


posibilidades para elegir la mejor. Puede hacerlo porque su velocidad de cálculo es muy
superior a la de un humano. Por eso denominamos a estas soluciones “de fuerza bruta”.

Pero los seres humanos, aún haciendo los cálculos mucho más despacio, pueden jugar
competentemente a las tres en raya y al ajedrez sin calcular todo el espacio de estados.
Calculando, en realidad, una ínfima parte del mismo. ¿Por qué? Porque los seres
humanos son inteligentes (mientras no se demuestre lo contrario)

Los humanos utilizamos estrategias inteligentes para descartar la mayor parte de los
movimientos. Pondré mi ficha en la casilla central porque sé que desde ahí tengo más
posibilidades de hacer tres en raya. No tengo que predecir todos los movimientos de mi
adversario para saber eso. Y si esa casilla está ocupada, trataré de tomar las casillas
centrales de los bordes del tablero, para impedir al oponente que haga tres en raya con
facilidad.

Podemos tratar de construir un algoritmo levemente inteligente, más o menos así:


1. Si está libre la casilla central (número 5), colocar la pieza ahí.
2. Si no, mirar a ver si están libres las casillas 2, 4, 6 u 8. Elegir una de ellas que
esté libre y que cumpla que el oponente tenga situada al menos una pieza en la
misma fila o columna (para impedirle hacer tres en raya). Si el oponente no tiene
ninguna pieza colocada en tales posiciones, elegir alguna de las casillas – 2, 4, 6
u 8 – al azar.
3. Si tampoco están disponibles las casillas 2, 4, 6 u 8, elegir alguna de las
restantes tratando de impedir las tres en raya del oponente o, si no es posible,
seleccionándola al azar.

Este algoritmo jugará bastante bien a las tres en raya sin necesidad de explorar el
espacio de estados al completo. Ni siquiera una parte. Lo hemos conseguido
transmitiendo al algoritmo parte de nuestra inteligencia, es decir, de nuestro
conocimiento y nuestra experiencia sobre el problema. Por lo tanto, podemos concluir
que esta solución es más inteligente que la de fuerza bruta.

Si combinamos ambos enfoques (exploración por fuerza bruta con “fragmentos de


inteligencia” aquí y allí), podemos conseguir algoritmos muy potentes en determinados
ámbitos como, por ejemplo, los que juegan como grandes maestros al ajedrez, como
Deep Blue o Fritz. Estos programas no necesitan evaluar todas las posibilidades (algo
que, como hemos visto, resulta imposible), sino que dirigen su exploración por el
espacio de estados mediante decisiones inteligentes.

You might also like