Programando un tablero de ajedrez que cumpla las reglas
Llevo un tiempo sin contar nada en el blog y ya me estaba generando
estrés el pensamiento recurrente de «debería escribir algo». Así, sin
tema concreto, sólo puedo utilizar lo que he estado haciendo todo este
tiempo. Que si bien no es sólo mérito mío1, me ha tenido absorbido y
concentrado todo este tiempo sin procrastinar en otra cosa que no sea
ésta. Ya comenté en el último artículo lo de hacer un programa de
ajedrez2, pues bien: lo estamos haciendo, no sólo en una, sino en tres
formas de visualización o interfaces y el trabajo está siendo muy
instructivo. Cuando digo interfaces me refiero a representación del
tablero en pantalla y, efectivamente, estamos desarrollando
tres: 1. Modo texto desde el terminal. 2. Mediante la librería gráfica
fltk
y 3. Mediante la librería gráfica imgui
.
Primero veamos cómo pintan los distintos tableros:
Diferencias entre tableros:
- El tablero en modo texto está conectado al motor de juego stockfish, es decir, permite jugar partidas contra la máquina. La forma de comunicación, como se puede ver en la figura 1, consiste en escribir las coordenadas de origen y destino. Con esas coordenadas se procesa el movimiento y se genera el FEN que se le pasa al motor. Cuando el motor contesta con sus coordenadas, se vuelve a realizar el movimiento y a mostrarlo en pantalla.
- El tablero en modo texto muestra la partida en el formato de notación algebraico corto, mientras que los otros dos muestran el estado actual de la partida en notación FEN3.
- La librería
fltk
es más sencilla y requiere mucha menos computación para dibujarse.imgui
está diseñado para dibujarse como lo hacen los videojuegos, en un bucle sin fin que se redibuja todo lo rápido que puede para que sea percibido con suavidad. Esto está bien en juegos 3D exigentes para la tarjeta gráfica, pero el ajedrez no necesita esa velocidad. De hecho, se puede ver en la figura 3 que está limitado su redibujado a unos 10fps, más que suficiente para darle fluidez a un programa de ajedrez.
Al conectar el motor de juego, con el protocolo UCI, nos dimos cuenta que con ese protocolo, no comprueba nada ni tiene seguimiento de la partida. Simplemente le pasas una determinada posición del tablero mediante notación FEN y te devuelve la mejor jugada que pueda calcular en base a ese estado. Así pues, el mantenerse dentro de las reglas del ajedrez corre del lado del programa cliente. Es decir, El motor si encuentra algo erróneo o absurdo se queda esperando una posición «calculable» sin decir nada y por tanto el programa cliente no puede saber si no contesta porque ha habido un error o porque está calculando.
En la figura anterior, el proceso Partida
debe controlar el proceso de
juego asegurándose de que los distintos movimientos se ajustan a las
normas del ajedrez. Cuando es así, su estado cambia, luego veremos
cómo. El proceso Interface
es el encargado de comunicar la Partida
con
el Motor
de juego, enviando jugadas, y con el usuario, mostrando el
tablero. El proceso Motor
únicamente recibe una posición del estado de
la partida y emite la jugada que considera más oportuna. El motor
stockfish permite varios parámetros de configuración, incluyendo nivel
de profundidad de cálculo o el ajuste de ELO para controlar la
dificultad contra la que queremos enfrentarnos. Por ejemplo, también
se puede configurar si queremos que el motor se mantenga ocioso
mientras nosotros movemos o no. En mi caso lo tengo configurado a un
nivel de profundidad 10 (como limitación para acelerar un poco su
respuesta) y sin dejarlo pensar mientras muevo. Aún así me gana.
Partida
Muestro aquí los objetos que intervienen en la partida para no hablar en vacío, pero tampoco voy a hacer una disertación detallada de todos los detalles:
type Tablero* = ref array[64, char] type Movimiento* = object san*: string fen*: string type Partida* = ref object tablero*: Tablero movimientos*: seq[Movimiento] turno*: string enroqueCortoBlancas*: bool enroqueLargoBlancas*: bool enroqueCortoNegras*: bool enroqueLargoNegras*: bool posAlPaso*: string contador*: int numJugada*: int
El Tablero
es un array de 64 caracteres, cada uno de los cuales
representa el contenido de una casilla. La Partida
cuenta con un
tablero
que es el que utilizan las interfaces para mostrarlo por
pantalla. Además, la Partida
cuenta con una lista de movimientos. Cada
Movimiento
consta de una cadena SAN4 y el FEN que muestra el estado de
la partida tras ese movimiento. El movimiento 0 es el estado inicial
del tablero al comenzar la partida. De momento, sólo es posible el
inicio estándar, pero esperamos poder iniciar las partidas de otros
modos, como el ajedrez 9605, o cuando ambos jugadores pactan una
ventaja, por ejemplo iniciando el juego uno de ellos con una figura
menos.
El meollo de la partida la controla muevePieza
. Dicha función
controla que al realizar un movimiento se siguen las normas del
ajedrez llamando a la función esMovimientoLegal
, y si, efectivamente
el movimiento es legal, realiza el movimiento, genera la cadena SAN y
la cadena FEN, las guarda y pone la Partida
en el estado
correspondiente. Aunque aún faltan un par de detalles para dar por
finalizada la implementación de Partida
. El código, de momento,
tiene esta forma:
##[ La función `muevePieza` cambia la posición de una pieza desde una casilla de origen `co` a una casilla final `cf`, en el tablero `p` dado. Devuelve `true` si se efectúa el movimiento de pieza y `false` en caso contrario. Cuando se realiza el movimiento de pieza también cambia el turno en la partida. ]## # TODO: Falta por implementar: # 1. la promoción de los peones: de momento sólo promocionan a reina # 2. detección del Mate proc muevePieza*(p: Partida, co, cf: string): bool = var san: string [...] if p.esMovimientoLegal(t, co, cf): [...] # hace el movimiento t.ponValorCasilla(cf, figura) t.ponValorCasilla(co, VACIA) if san == "": # Si ha habido enroque ya llega aquí relleno # si no, junta las partes de la notación del movimiento san = fig & origen & captura & cf & jaque & ep # Cambia el jugador de turno p.cambiaTurno() # Guarda el registro del movimiento p.movimientos.add(Movimiento(san: san, fen: p.fen())) return true else: return false
Algunas cosas merecen un poco más de atención, como los detalles sobre
movimientos de peones, reyes y torres. Estas tres piezas producen
cambios en el estado de Partida
por reglas especiales que les afectan,
como la captura al paso6 de los peones, la regla de los cincuenta
movimientos7, de los que Partida lleva un contador, o de las reglas de
enroque8, que también debe vigilar Partida y que se ven afectadas por
movimientos de reyes y torres. Además, en el enroque se debe mover
también la torre y no sólo el rey y, por último, debe registrar los
datos para generar la cadena san que es la notación algebraica del
movimiento.
Sin embargo, el mayor peso de proceso y comprobaciones recae sobre la
función esMovimientoLegal
:
##[ Para que un movimiento sea legal, en una partida `p` la pieza situada en la casilla de origen `co` debe estar en su turno, no puede mover al mismo sitio donde está y la casilla de destino `cf` debe ser alcanzable según las normas del ajedrez. ]## proc esMovimientoLegal(p: Partida, t: Tablero, co, cf: string): bool = var figura: char = t.contenidoCasilla(co) tablero: Tablero if figura != VACIA and estaEnTurno(p, figura) and co != cf and p.esDestinoLegal(t, co, cf): # Si el movimiento parece legal, realizamos el movimiento en un tablero aparte tablero = t.copiarTablero() tablero.ponValorCasilla(co, VACIA) tablero.ponValorCasilla(cf, figura) # Y comprobamos que no queda el rey propio en jaque if tablero.estaEnJaque(p, figura.colorPieza()): echo("Deja el rey propio en jaque.") return false return true else: return false
Las comprobaciones que hace esta función son las siguientes:
- Que para la casilla de origen no se ha seleccionado una casilla vacía.
- Que la figura de la casilla de origen está en su turno.
- Que la casilla de origen y destino son distintas.
- Que la figura de la casilla de origen puede mover a la casilla
final. La función
esDestinoLegal
comprueba las normas de movimiento de cada pieza y determina si puede alcanzar la casilla final. - Que al realizar el movimiento no quedaría el rey propio en jaque. Para hacerlo, copia el tablero de la partida en otro, poniéndolo en la situación de que el movimiento se ha realizado y comprueba que el rey de su color no quede en jaque.
Falta por implementar un par de detalles que aún no tengo claro cómo se pueden afrontar, aunque sí alguna idea:
- Promoción del peón: Cuando un peón alcanza la fila más alejada de
su posición de inicio puede promocionar a cualquier figura, excepto
a rey. Puede convertirse en reina, torre, alfil o caballo a elección
del jugador. Lo habitual es que todos elijamos la reina por ser la
pieza más potente, pero puede elegirse otra, por ejemplo, para
evitar ahogar al rey contrario y acabar en tablas o porque
estratégicamente le guste más al jugador. En la notación por
coordenadas se anota la pieza deseada después del movimiento, por
ejemplo:
e7e8N
coronaría el peón y lo convertiría en un caballo. En modo gráfico podría ser mostrar un diálogo para que elija el jugador y en modo texto, añadir la pieza deseada a las coordenadas al introducir el movimiento para coronar el peón... pero aún hay que hacerlo. - Detección del mate: Actualmente
Partida
no sabe si ha acabado el juego porque no se pueda evitar la captura de un rey. Mirando por Internet he visto un algoritmo que tiene dos casos, a partir de un jaque inicial:- El jaque inicial se da por dos piezas a la vez:
- Comprobar si algún movimiento de rey evita el jaque.
- El jaque inicial lo da una sola pieza:
- Comprobar si la pieza que da el jaque puede ser capturada.
- Comprobar si se puede interponer una pieza propia entre el rey y la pieza atacante.
- Comprobar si algún movimiento del rey evita el jaque.
- El jaque inicial se da por dos piezas a la vez:
Poder analizar partidas implica navegarlas, poder avanzar y retroceder tantas veces como sea necesario. Para colocar la partida en una posición cualquiera, la aproximación por fuerza bruta de iniciarla e ir haciendo todos los movimientos anteriores. Sin embargo, creo que esta forma es un poco primitiva y que la mejor manera sería utilizar la notación FEN. Por esto, tras cada movimiento se registra no sólo la notación estándar, sino también el estado de la partida con FEN.
Notación FEN
La forma de comunicar al motor de juego la situación actual de la partida es con la notación FEN. Ya lo expliqué en el artículo anterior: consta de seis campos:
- Cadena representando las 8 filas del tablero:
- El tablero se lee de izquierda a derecha comenzando por la casilla
a8
y de arriba hacia abajo hastah1
. - Cada fila se separa de la siguiente con el signo
/
. - Se utiliza la notación de piezas con la inicial inglesa, en mayúscula para blancas y en minúscula para negras.
- Las casillas vacías se muestran agrupándolas con un número.
- El tablero se lee de izquierda a derecha comenzando por la casilla
- Indicador de color activo, o a quién le toca mover,
w
(white) yb
(black). - Indicador de posibilidad de enroques:
QKqk
(de Queen y King) que actuarían como flags de que blancas (en mayúscula) y negras (en minúscula) tienen capacidad de enrocar aún por alguno de los lados o un-
si ningún jugador tiene ya posibilidad de enroque. - Casilla de destino para la captura al paso. Si no hay casilla de
destino aparecerá un
-
. La casilla destino aparecerá después de un doble avance de salida de un peón. Un peón contrario que se encuentre en la fila 3 (para negras) o en la fila 6 (para blancas) puede ejercer la acción de captura al paso, situándose en la casilla donde hubiera terminado si el movimiento del peón que avanza hubiera sido de una sola casilla. - Reloj de medios movimientos: Este número es el recuento de medios movimientos desde el avance de peones o movimientos de captura y se utiliza para la regla de conteo de 50 movimientos. Esta regla establece tablas o empate, si se producen 50 movimientos seguidos sin que se mueva un peón o se realice alguna captura.
- Número de jugadas completas: Comienza puesto a 1 y se incrementa tras cada movimiento de las negras.
Todos los campos llegan en una sola cadena separados por espacios.
En base a los campos anteriores se establece el estado de la Partida
con la función:
##[ Esta función `fen` recibe una cadena codificada con la notación FEN y la utiliza para situar la partida `p` en el estado descrito por la misma, decodificando los campos que esta notación proporciona. ]## proc fen*(p: Partida, fen: string) = let campos = split(fen) p.limpiarTablero() p.ponPiezasFen(campos[0]) p.turno = campos[1] p.ponEnroquesFen(campos[2]) p.posAlPaso = campos[3] p.contador = parseInt(campos[4]) p.numJugada = parseInt(campos[5])
También hay una función que realiza el proceso inverso y devuelve una cadena codificada como FEN a partir del estado de una partida:
proc fen*(p: Partida): string
Conclusiones
No quiero cansinar mucho con el proyecto, aún está en pañales aunque promete maneras. De momento tenemos un objeto que nos permite mantener una partida de ajedrez dentro de las normas de juego, con alguna carencia, que sin duda para cuando se publique este artículo ya estará solucionada o en vías de solución. Además se puede conectar con un motor de juego. De momento sólo se ha probado con Stockfish mediante el protocolo UCI, aunque está pensado admitir otros motores como GNU chess o Crafty. Quizá también soportar algún protocolo más, como XBoard.
Lo que estoy ahora mirando es poder configurar el motor para jugar desde la interface. También introducir otros parámetros del programa, que me permitan o bien jugar o bien analizar una partida desde el primer movimiento al último realizado.
El siguiente paso es poder cargar archivos PGN, con múltiples partidas, que permita analizar su contenido, desarrollar las partidas y hacer variaciones sobre ellas utilizando el motor de juego, etc.
También el asunto del reloj y el tiempo de juego está en pendientes, pero no creo que sea el momento aún para afinar tanto. Primero hay que terminar con la representación del tablero, asegurarnos de seguir las normas del ajedrez y de que permite un análisis pausado de la partida.
Notas al pie de página:
Me está ayudando un amigo, pero no quiere que lo mencione, ni le atribuya méritos en público, así que no diré su nombre, apodo, ni dato alguno que pueda identificarlo... Pero no puedo dejar de reconocer que anda detrás de mí con el látigo: me corrige el código, lo simplifica, le encuentra errores, me echa la bronca por mi poca seriedad con los comentarios de los commits o mi falta de acierto a la hora de poner nombre a las funciones o escribir comentarios... en fin, mi Pepito Grillo personal sin el cual este proyecto llevaría haciendo aguas casi desde sus inicios.
Sí, ya sé que hay muchos, pero no todos hacen lo que queremos.
Notación de Forsyth-Edwars
Standard Algebraic Notation SAN, es la notación oficial de la FIDE para representar las partidas en campeonatos y también es la base de representación para los archivos PGN (Portable Game Notation).
El ajedrez 960 lo propuso Bobby Fisher. En esta modalidad se forma la primera línea de las figuras de manera aleatoria. https://es.wikipedia.org/wiki/Ajedrez_aleatorio_de_Fischer
Comentarios