Tic-tac-toe ... todos los jugaron, estoy seguro. El juego es atractivo en su simplicidad, especialmente cuando arrastra el reloj a algún lugar de la lección, un par, y no hay nada a mano excepto una hoja de cuaderno y un simple lápiz. No sé quién fue el primero en adivinar que dibujó cruces y círculos en 9 casillas, pero desde entonces el juego no ha perdido demanda en absoluto, especialmente desde que la gente presentó muchas de sus variaciones.

Este artículo trata sobre el proceso de desarrollo de IA en javascript para reproducir una de estas variaciones de tic-tac-toe: obtuve mucho material, pero lo diluí con animación e imágenes. En cualquier caso, al menos vale la pena intentarlo.
Las diferencias entre esta versión del juego y la original son las siguientes:
- El campo puede ser arbitrariamente grande (¿Cuánto durará la computadora portátil?)
- El ganador es el que pone 5 piezas (si puede llamarlas así) en una fila.
Todo es simple ... y al mismo tiempo complicado: el resultado del juego no se puede calcular de antemano, como en el análogo clásico. Esta "pequeña proyección" me quitó mucho tiempo y nervios. Espero que lo encuentres interesante.
Antes de comenzar
Obligado a pedir disculpas de antemano por el volumen del artículo y, en algunos lugares, la presentación de pensamiento no del todo inteligible, sin embargo, no pude exprimir al rebaño sin pérdida de contenido y calidad.
Le recomiendo que primero se familiarice con el
resultado .
CódigoTeclas de acceso rápido y comandos:
- D - AI hará un movimiento por ti
- T - ver peso celular
- Escriba SHOW_WEIGHTS = true en la consola para ver los pesos de todas las celdas analizadas.
Empecemos
Debes comenzar con la implementación del juego en sí, es decir escribe una aplicación para dos jugadores, hasta ahora sin bot. Para mis propósitos, decidí usar javascript + jquery + bootstrap4, aunque prácticamente no se usa allí, pero es mejor dejarlo, o la tabla flotará. No hay nada especial que contar, hay mucho material sobre js, jquery y bootstrap. Solo puedo decir que usé MVC. De todos modos, no explicaré absolutamente todo el código, ya ha habido mucho material.
Entonces, el campo de juego estaba listo. Puede establecer formas en celdas. Pero la victoria de cualquiera de los jugadores no fue arreglada de ninguna manera.
Fin del escaneo del juego
El juego termina cuando uno de los jugadores pone
5 piezas seguidas. "¡Es simple!" Pensé Y comenzó a escanear absolutamente todas las celdas del campo: primero todas las horizontales, luego las verticales y finalmente las diagonales.
Esta es una manera tonta, pero funcionó. Sin embargo, podría mejorarse significativamente, lo cual hice: la mayoría de las celdas permanecerán vacías durante todo el juego; el campo de juego es demasiado grande para llenarse por completo. Como era necesario escanearlo en cada movimiento, y solo se coloca una pieza en un movimiento, puede enfocarse solo en esta pieza (celda): escanee solo una horizontal, vertical y dos diagonales de la celda que posee la misma celda.
Además, no necesita escanear todas las líneas celulares. Como el final del juego es de 5 piezas seguidas, las piezas que están separadas por 6 celdas no nos interesan. Es suficiente escanear cinco celdas en cada lado. No entiendo? Vea la animación a continuación.

Ver códigocheckWin( cellX, cellY ){ var res = null; var newFig = getFig(cellX,cellY); if( ! newFig ) return false; var res; res = res || checkLine( cellX, cellY, 1, 0 );
Vamos al bot mismo
Entonces, ya hemos escrito una página con tres en raya. Pasamos a la tarea principal: AI.
No puede simplemente tomar y escribir código si no sabe cómo: debe pensar en la lógica del bot.
La conclusión es analizar el campo de juego, al menos parte de él, y calcular el
precio (peso) de cada celda en el campo. La celda con el peso más alto, la más prometedora, el bot pondrá una cifra allí. La principal dificultad está en calcular el peso de una celda.
Terminología
Las cruces y los dedos son figuras.
Un ataque se llamará varias figuras idénticas de pie lado a lado en la misma línea. De hecho, esto es mucho. El número de piezas en un ataque es su
poder . Una pieza separada también es un ataque (potencia 1).
En las celdas de ataque adyacentes (en los extremos) puede haber celdas vacías o piezas enemigas. Es lógico pensar que un ataque con dos celdas vacías en los "extremos" se puede desarrollar en dos direcciones, lo que lo hace más prometedor. El número de celdas vacías en los "extremos" del ataque se denominará
potencial . El potencial puede ser 0, 1 o 2.
Denotamos los ataques de la siguiente manera:
[poder de ataque, potencial] . Por ejemplo, un
ataque [4: 1] .
Figura 1. Ataque [4: 1]En el curso del análisis, evaluaremos todas las celdas que ingresan a un área específica. Cada celda calculará su
peso . Se calcula en función de los pesos de todos los ataques que afecta esta célula.
La esencia del análisis.
Imagine que en el campo de juego ya hay varios ataques de uno y el segundo jugador. Uno de los jugadores hace un movimiento (deja los cruces). Naturalmente, se muda a una celda vacía y, por lo tanto, puede:
- Desarrolla tu ataque, y quizás más de uno, aumentando su poder. Puede lanzar un nuevo ataque, etc.
- Prevenir el desarrollo de un ataque enemigo o bloquearlo por completo.
Es decir, nuestro protagonista puede atacar y defender. O tal vez todo de una vez. Para él, tanto el primero como el segundo son importantes.
La esencia del análisis es la siguiente:
- El bot sustituye las cifras en la celda marcada: primero una cruz, luego un cero.
- Luego busca todos los ataques que fueron recibidos por tales movimientos y resume sus pesos.
- La cantidad recibida es el peso de la celda.
- Se realiza un algoritmo similar para todas las celdas del campo de juego.

De hecho, verificamos con tal algoritmo qué sucederá si vamos por este camino ... y qué sucederá si el oponente va por ese camino. Esperamos un paso y seleccionamos la celda más adecuada, con el mayor peso.
Si una célula tiene más peso que otra, entonces conduce a la creación de ataques más peligrosos o a bloquear fuertes ataques enemigos. Todo es lógico ... me parece a mí.
Si va a la página y escribe en la consola SHOW_WEIGHTS = true, puede sentir visualmente el funcionamiento del algoritmo (se mostrarán los pesos de las celdas).
Pesos de ataque
Revisé mis cerebros y traje tal correspondencia de ataques y pesos:
ATTACK_WEIGHT = [[],[],[],[],[],[]]; ATTACK_WEIGHT[1][1] = 0.1; ATTACK_WEIGHT[2][1] = 2; ATTACK_WEIGHT[3][1] = 4; ATTACK_WEIGHT[4][1] = 6; ATTACK_WEIGHT[5][1] = 200; ATTACK_WEIGHT[1][2] = 0.25; ATTACK_WEIGHT[2][2] = 5; ATTACK_WEIGHT[3][2] = 7; ATTACK_WEIGHT[4][2] = 100; ATTACK_WEIGHT[5][2] = 200; ATTACK_WEIGHT[5][0] = 200;
Seleccionado empíricamente, quizás esta no sea la mejor opción.
Agregué un poder de ataque de 5 con un peso prohibitivamente grande a la matriz. Esto puede explicarse por el hecho de que el bot analiza el juego, mirando un paso adelante (sustituyendo la figura en la celda). Omitir tal ataque no es más que una derrota. Bueno, o victoria ... dependiendo de quién.
Los ataques con alto potencial se valoran más alto.
El ataque [4: 2] en la mayoría de los casos decide el resultado del juego. Si el jugador logró crear dicho ataque, entonces el oponente ya no podrá bloquearlo. Sin embargo, esto no es una victoria. El enemigo puede terminar el juego más rápido, incluso si tenemos un ataque [4: 2] en el campo, por lo que su peso es menor que el de los ataques con una potencia de 5. Vea un ejemplo a continuación.
Figura 2. Ataque [4: 2]Ataques rotos
El código no se presenta en este párrafo. Aquí presentamos el concepto de un divisor de ataques y explicamos la esencia de los
"ataques desgarrados" .
Considere la siguiente situación: al sustituir una figura para eliminar varias celdas vacías, pero no más de 5, se ubica una más.
Y, al parecer, dos figuras idénticas, en la misma línea ... visualmente parece un ataque, pero en realidad no. No es una orden, ya que tales ataques "desgarrados" también conllevan una amenaza potencial.
Especialmente para tales casos, para cada ataque calcularemos el divisor. Inicialmente, su valor es 1.
- Presentamos el ataque "desgarrado" como varios ordinarios
- Contamos el número de celdas vacías entre el ataque central y el lateral.
- Para cada celda vacía, el divisor se incrementa en 1
- Calculamos el peso del ataque central como de costumbre, el peso de los ataques laterales, dividido por el divisor
Fig. 3. Análisis del "ataque rasgado". Se escanea una celda con una cruz amarilla.Por lo tanto, los ataques desgarrados también serán tomados en cuenta por AI. De hecho, estos serán ataques ordinarios, pero cuanto más lejos estén de la celda escaneada, menos influencia tendrán sobre ella y, en consecuencia, tendrán menos peso (gracias al divisor).
Algoritmo de búsqueda de ataque
Primero, crea
una clase de ataque. El ataque tendrá 3 atributos, sobre los que escribí anteriormente:
class Attack{ constructor( cap = 0, pot = 0, div = 1 ){ this.capability = cap;
Y un
método que devolverá el peso de un ataque dado:
countWeigth(){ return ATTACK_WEIGHT[ this.capability, this.potential ] / this.divider } }
Siguiente Dividiremos la búsqueda de todos los ataques para una célula en:
- Búsqueda horizontal
- Búsqueda vertical
- Búsqueda diagonal de 45 grados
- Búsqueda diagonal de 135 grados
Todas estas son
líneas , y el algoritmo para buscar ataques en estas líneas puede generalizarse:
la clase checkLine .
Sin embargo, no necesitamos verificar toda la línea. El poder de ataque máximo que nos interesa es 5. Por supuesto, es posible crear un ataque con un poder de, digamos, 6. Pero para una IA que analiza la situación del juego del próximo movimiento, es lo mismo que 6 o 5. La posibilidad de recibir uno de estos ataques indica el final del juego en el siguiente movimiento. En consecuencia, el peso de la celda analizada será el mismo en ambos casos.
Atributos de clase:
class checkLine{ constructor(){
Es necesario detenerse aquí, ya que puede surgir la pregunta: ¿por qué verificar la sexta celda si la potencia máxima de ataque es 5. La respuesta es determinar el potencial remoto desde el centro de ataque.
Aquí hay un ejemplo: un ataque con un poder de 1 en la imagen se encuentra en el borde del área escaneada. Para descubrir el potencial de este ataque, debe "mirar al extranjero".
Fig. 3. Escaneo de 6tas celdas. Si no escanea la sexta celda, puede determinar incorrectamente el potencial de ataque.
Puede que simplemente no haya suficiente espacio para completar algunos ataques. Habiendo contado el lugar del ataque, podemos entender de antemano cuál de los ataques es poco prometedor.
Fig. 4. Lugar para atacarEl algoritmo es el siguiente:
1) Comencemos con la celda central. Debe estar vacío (vamos a hacer un movimiento hacia él, ¿verdad? Pero no olvidamos que nuestra IA debe sustituir figuras en esta celda para el análisis del próximo movimiento. La figura que sustituimos es
this.subfig : el valor predeterminado es una cruz. Dado que la celda central inicialmente contendrá alguna forma después de la sustitución, pertenecerá a algún ataque
this.curAttack :
- su potencia no será inferior a 1 (una cifra en la celda central)
- divisor - 1, porque es un ataque central (pertenece a la celda escaneada);
- el potencial aún no se conoce: el valor predeterminado es 0;
Mostramos todos estos puntos en los valores predeterminados del constructor; consulte el código anterior.
2) A continuación, reduciendo el iterador, itera más de 5 celdas en un lado del escaneado. La función
getAttacks (cellX, cellY, subFig, dx, dy) es responsable de esto, donde:
cellX, cellY : coordenadas de la celda marcada
subFig : la figura que sustituimos en la celda marcada
dx, dy - cambios en las coordenadas x e y en ciclos - así es como establecemos la dirección de búsqueda:
- Horizontal (dx = 1, dy = 0)
- Vertical (dx = 0, dy = 1)
- Diagonal 45 (dx = 1, dy = -1)
- Diagonal 135 (dx = 1, dy = 1)
En cierto sentido, este es un vector paralelo a la línea de búsqueda. Por lo tanto, una función podrá buscar en 4 direcciones y no volveremos a violar el principio DRY.
Código de función:
getAttacks( cellX, cellY, subFig, dx, dy ){ this.substitudeFigure( subFig );
Tenga en cuenta que si checkCell () devuelve algo, entonces el ciclo se detiene.
3) Verificamos las cifras de estas celdas.
La función
checkCell (x, y) es responsable de esto:
Primero, escriba la forma en la variable
fig :
Model.Field es nuestro campo de juego.
checkCell( x, y ){ var fig = Model.Field[x] && Model.Field[x][y] !== undefined ? Model.Field[x][y] : 'b';
fig puede ser 'x', 'o', 'b' (borde), 0 (celda vacía).
4) Si en la quinta celda la figura coincide con la celda central, entonces el ataque "descansó" contra el borde y para determinar el potencial de ataque tendrá que "verificar el borde" (
this.checkEdge = true ).
if( this.iter == 4 && fig == this.subFig )
La función
checkCell está lista. Sin embargo, seguimos trabajando en la clase
checkLine .
5) Después de completar el primer ciclo, debe "dar la vuelta". Traducimos el iterador al centro y al ataque central, con el índice 0, lo eliminamos de la matriz de ataques y lo configuramos como el actual.
turnAround(){ this.iter = 1; this.checkEdge = false; this.curAttack = this.Attacks[0]; this.Attacks.splice(0,1) }
6) A continuación, vaya al otro lado de la celda actual, aumentando el iterador.
Absolutamente el mismo cheque de cifras. (Código ya escrito - función
getAttacks )
7) Todo, reunimos todos los ataques que estaban en la línea en una matriz.
Eso es todo con la clase
checkLine ... todo está hecho.
Bueno, entonces todo es simple: cree un objeto
checkLine para cada una de las líneas (2 diagonales, horizontal y vertical) y llame a la función
getAttacks . Es decir, para cada línea: su propio objeto
checkLine y, en consecuencia, su propio conjunto de ataques.
Deje que la función
getAllAttacks () sea responsable de todo esto, ya por separado de las clases descritas anteriormente;
getAllAttacks( cellX, cellY ){
En la salida, tenemos un objeto con todos los ataques para la celda probada
Sin embargo, es posible que haya notado algún tipo de función de filtro. Su tarea es tamizar ataques "inútiles":
- Con potencia cero (nunca se sabe si entran en la matriz)
- Ataques que carecen de espacio (lugar de ataque <5)
- Con cero potencial.
Sin embargo, si el ataque tiene un poder superior a 5, entonces el filtro lo omitirá. El bot debe ver tales ataques, su detección conducirá a jambas al final del juego.
filterAttacks( attackLine ){ var res = [] if( attackLine.attackplace >= 5 ) attackLine.Attacks.forEach( ( a )=>{ if( a.capability && a.potential || a.capability >= 5 ) res.push( a ) }) attackLine.Attacks = res; return res }
Puntos de corte
Sí ... otra vez, lo siento! Entonces llamaremos a la situación en el juego, cuando un movimiento incorrecto decide el resultado del juego.
Por ejemplo, un ataque [3: 2] es un punto de interrupción. Si el oponente no lo bloquea colocando una pieza al lado, entonces el siguiente movimiento, ya tenemos un ataque [4: 2] en el campo de juego, bueno, el resultado del juego se decide, lo que sea que se diga (en la gran mayoría de los casos).
O un ataque [4: 1]. Un bostezo, y el juego se puede completar fácilmente.
Figura 5. Punto de interrupciónTodo es claro y comprensible, y el algoritmo descrito anteriormente ya puede tener en cuenta los puntos de interrupción y bloquearlos de manera oportuna. El bot mira hacia adelante. Verá que en el próximo turno el oponente puede crear un ataque [5: 1], por ejemplo, cuyo peso es 200, lo que significa que el astuto nerd irá aquí.
Sin embargo, imagina una situación en la que uno de los jugadores consigue 2 puntos de quiebre en el campo. Y esto, obviamente, no deja ninguna posibilidad para el oponente, porque en un movimiento podemos bloquear solo un punto de interrupción. ¿Cómo enseñar a nuestra IA a bloquear tales ataques?
Figura 6. 2 puntos de corteTodo es simple, al analizar una celda, al sustituir una pieza en ella, contaremos el número de puntos de interrupción que obtendremos en el próximo movimiento (el bot mira el movimiento hacia adelante, no lo olvide). Al contar 2 puntos de interrupción, aumentamos el peso celular en 100.
Y ahora, el bot no solo evitará tales situaciones de juego, sino que también podrá crearlas, lo que lo convierte en un oponente más formidable.
Cómo entender que un ataque es un punto de quiebre
Comencemos con lo obvio: cualquier ataque con una potencia de 4 es un punto de quiebre. Solo un movimiento perdido nos da la oportunidad de completar el juego, es decir poner 5 piezas en una fila.
Además, si el potencial de ataque es 2, gastaremos 1 turno más para bloquear dicho ataque, lo que significa que hay un punto de interrupción con un poder de 3. Pero solo hay un punto de interrupción: este es un ataque [3: 2].
Y aún más difícil:
"ataques desgarrados" .
Solo consideraremos ataques con una celda vacía en el medio, no más. Esto se debe a que para completar el ataque con dos celdas vacías en el medio, debes gastar al menos 2 movimientos; esto claramente no es un punto de interrupción.
Como recordamos, consideramos los ataques rotos como varios convencionales: un ataque central y ataques secundarios. El ataque central pertenece a la celda escaneada, el divisor lateral tiene más de 1, esto se describió anteriormente.
Algoritmo para encontrar un punto de interrupción (más fácil, lea a continuación):
- Introducimos el puntaje variable
- Tomamos el ataque central, consideramos el poder
- Tomamos uno de los secundarios si su divisor no es más de 2x.
- Puntuación : la suma del poder de los ataques centrales y secundarios
- Si los potenciales de los ataques centrales y laterales son 2, entonces para bloquear dicho ataque necesitas pasar un turno más. Por lo tanto, la puntuación se incrementa en 1
- Si puntaje > = 4, entonces este es un punto de interrupción
De hecho, los puntos de interrupción podrían enumerarse simplemente, no hay muchos de ellos, pero no entendí esto de inmediato.
isBreakPoint( attackLine ){ if( ! attackLine || ! attackLine.length ) return false; var centAtk; attackLine.forEach( ( a )=>{ if( a.divider == 1 ) centAtk = a; }) if( centAtk.capability >= 4 ) return true if( centAtk.potential == 2 && centAtk.capability >= 3 ) return true; var res = false; attackLine.forEach( ( a )=>{ var score = centAtk.capability; if( a.divider == 2 ){
Sí, finalmente lo juntaremos todo
Entonces, el infierno principal detrás se describe arriba. Es hora de moldear algo que funcione a partir de eso. Función
countWeight (x, y) : toma las coordenadas de la celda como entrada y devuelve su peso. ¿Qué hay debajo de su capucha?
Primero, obtenemos una serie de todos los ataques a los que pertenece la célula. (
getAllAttacks (x, y) ). Al pasar por todas las líneas, contamos el número de puntos de interrupción. Si hay 2 puntos de interrupción, recordamos que tal situación puede decidir el resultado del juego y aumentar el peso celular en 100.
Sin embargo, todos los puntos de interrupción deben pertenecer a un jugador, por lo que tuve que implementar una verificación en 2 pasos: primero cruces, luego ceros.
Dado que en el conjunto de pesos de ataque (
ATTACK_WEIGHTS [] ) no proporcioné ataques con un poder de 6 o más, tuve que reemplazarlos con ataques con un poder de 5. No hay diferencia: todos conducen al final del juego.
Bueno, resumimos los pesos de ataque, eso es todo.
Otro pequeño punto: para que el bot no sea estúpido al final del juego, cuando ya ha construido un ataque con un poder de 4 y piensa en el movimiento actual, es necesario aumentar significativamente el peso de la celda para completar dicho ataque. Sin esto, la IA, simplemente, puede comenzar a defenderse de los ataques "peligrosos" del oponente, aunque el juego parece estar ganado. El último movimiento es importante.
countWeight( x, y ){ var attacks = this.getAttacks( x, y ) if( ! attacks ) return; var sum = 0; sum += count.call( this, attacks.x, '×' ); sum += count.call( this, attacks.o, '○' ); return sum function count( atks, curFig ){ var weight = 0; var breakPoints = 0; [ "0", "45", "90", "135" ].forEach( ( p )=>{ if( this.isBreakPoint( atks[p] ) ){ debug( "Break point" ) if( ++breakPoints == 2 ){ weight += 100; debug( "Good cell" ) return; } } atks[p].forEach( ( a )=>{ if( a.capability > 5 ) a.capability = 5; if( a.capability == 5 && curFig == Model.whoPlays.char ) weight += 100; weight += a.getWeight(); }); }) return weight } }
Ahora, al llamar a esta función para una celda específica, obtendremos su peso. Realizamos esta operación para todas las celdas y seleccionamos la mejor (con el mayor peso). Ahí y ve)
Puedes encontrar el resto del código en
github . Ya hay mucho material y su presentación, como no lo he probado, deja mucho que desear.
Pero si pudieras leer hasta este punto, querido lector, entonces te estoy agradecido.Mi opinion sobre el resultado
¡Baja! Sí, puedes vencerlo, pero hacerlo es un poco problemático para mí personalmente. Quizás no soy lo suficientemente cuidadoso. Prueba tu fuerza también.Sé que es más fácil, pero no sé cómo. Me gustaría escuchar a las personas que conocen o observan otras implementaciones de tal bot.Sé lo que puede ser mejor. Sí ... puedes usar algoritmos bien conocidos, como minimax, pero para esto necesitas tener una base de conocimiento en el campo de la teoría de juegos, que desafortunadamente no puedo presumir.En el futuro planeo agregar análisis de puntos de corte varios pasos adelante, lo que hará que el bot sea un rival aún más serio. Sin embargo, ahora no tengo una idea clara sobre la implementación de esto; Solo tengo la próxima sesión y un diploma incompleto, lo que me entristece.Gracias si lees hasta el final.