Documentos de Académico
Documentos de Profesional
Documentos de Cultura
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
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.
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.
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.
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. 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.
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.
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.
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.
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.
No hay una sola forma correcta de hacer la descomposición. Cada cuál debe encontrar
la suya. Pero recuerde: ¡divide y vencerás!
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.
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.
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
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.
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.
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.
2º) Mover las piezas seleccionando la casilla con las teclas del cursor
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.
Piense en ello. No es nada difícil y hará que el juego empiece a parecer algo serio. No
pase a la siguiente fase hasta haber terminado ésta.
Si 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.
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:
Además, recuerde que las piezas no pueden pasar, al moverse, por encima de otras,
excepto el caballo.
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:
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:
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.
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.
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…
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.
Menú 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.
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.
Las opciones del menú deben ser las siguientes (el texto, el aspecto y el orden, que cada
cual lo elija a su gusto):
Selección de opciones
Puede empezar programando la versión 1 del menú y, más adelante, cuando todo lo
demás funcione, mejorar su aspecto.
MIAJEDREZ 1.0
MENÚ DE OPCIONES
(1) Continuar partida
(2) Empezar otra partida
(3) Guardar partida
(4) Cargar partida
(5) Salir del programa
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
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.
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.
• 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 …
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
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.
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.
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:
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)
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
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
Una vez conocido y comprendido el formato PGN, lo que hay que hacer para guardar
partidas está muy claro:
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
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:
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.
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?
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:
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.
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.
Si el turno fuera de las negras, el proceso de comprobación sería igual pero al revés.
• 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.
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”.
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.
copiar_tablero(tablero, tablero2);
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.
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.
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.
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.
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.
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.
#define INTERFAZ_TEXTO
#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.
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.
• 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.
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
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.
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.
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.
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)
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.
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)
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.
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:
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.
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.
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.
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.
+---+---+---+
Numeración de | 1 | 2 | 3 |
las casillas: +---+---+---+
| 4 | 5 | 6 |
+---+---+---+
| 7 | 8 | 9 |
+---+---+---+
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í:
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.
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.
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.