Jogo da velha ... todos jogaram, tenho certeza. O jogo é atraente em sua simplicidade, especialmente quando você arrasta o relógio em algum lugar da lição, alguns, e não há nada em mãos, exceto uma folha de caderno e um lápis simples. Eu não sei quem foi o primeiro que adivinhou desenhar cruzes e círculos em 9 quadrados, mas desde então o jogo não perdeu muito, especialmente porque as pessoas criaram muitas variações.

Este artigo é sobre o processo de desenvolvimento de IA em javascript para reproduzir uma dessas variações do jogo da velha: tenho muito material, mas o diluí com animação e imagens. De qualquer forma, pelo menos vale a pena tentar reproduzi-lo.
As diferenças entre esta versão do jogo e o original são as seguintes:
- O campo pode ser arbitrariamente grande (quanto tempo durará o notebook)
- O vencedor é aquele que coloca 5 peças (se você puder chamá-las assim) em uma fileira.
Tudo é simples ... e ao mesmo tempo complicado: o resultado do jogo não pode ser calculado com antecedência, como no clássico analógico. Essa "pequena projeção" levou muito do meu tempo e nervosismo. Espero que você ache interessante.
Antes de começarmos
Forçado a pedir desculpas antecipadamente pelo volume do artigo e, em alguns lugares, uma apresentação não muito inteligível do pensamento, no entanto, não consegui espremer o rebanho sem perda de conteúdo e qualidade.
Eu recomendo que você se familiarize primeiro com o
resultado .
CódigoTeclas de atalho e comandos:
- D - AI fará um movimento para você
- T - veja peso celular
- Escreva SHOW_WEIGHTS = true no console para visualizar os pesos de todas as células analisadas.
Vamos começar
Você precisa começar com a implementação do jogo em si, ou seja, escreva uma aplicação para dois jogadores, até agora sem um bot. Para meus propósitos, decidi usar javascript + jquery + bootstrap4, embora praticamente não seja usado lá, mas é melhor deixá-lo - ou a tabela flutuará. Não há nada de especial para contar, há muito material sobre js, jquery e bootstrap. Só posso dizer que usei o MVC. De qualquer forma, não vou explicar absolutamente todo o código - já existe muito material.
Então, o campo de jogo estava pronto. Você pode definir formas nas células. Mas a vitória de qualquer um dos jogadores não foi fixada de forma alguma.
Verificação de final de jogo
O jogo termina quando um dos jogadores coloca
5 peças seguidas. "É simples!" Eu pensei. E ele começou a escanear absolutamente todas as células do campo: primeiro todas as horizontais, depois as verticais e finalmente as diagonais.
Essa é uma maneira idiota, mas funcionou. No entanto, poderia ser significativamente melhorado, o que eu fiz: A maioria das células permanecerá vazia durante o jogo - o campo de jogo é muito grande para ser preenchido completamente. Como era necessário digitalizá-lo a cada movimento e apenas uma peça é colocada em um movimento, você pode se concentrar apenas nessa peça (célula): digitalize apenas uma horizontal, vertical e duas diagonais da célula que possui a mesma célula.
Além disso, você não precisa digitalizar todas as linhas de células. Como o final do jogo é de 5 peças seguidas, as peças separadas por 6 células não são do nosso interesse. Basta escanear cinco células de cada lado. Não entende? Veja a animação abaixo.

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 direto ao bot
Então, nós já escrevemos uma página com tic-tac-toe. Passamos para a tarefa principal - AI.
Você não pode simplesmente pegar e escrever código, se não souber: precisa pensar na lógica do bot.
A linha inferior é analisar o campo de jogo, pelo menos parte dele, e calcular o
preço (peso) de cada célula no campo. A célula com o maior peso - o mais promissor - o bot colocará uma figura lá. A principal dificuldade está no cálculo do peso de uma célula.
Terminologia
Cruzes e dedos são figuras.
Um ataque será chamado de várias figuras idênticas, lado a lado na mesma linha. De fato, isso é muito. O número de peças em um ataque é o seu
poder . Uma peça separada também é um ataque (poder 1).
Nas células de ataque adjacentes (nas extremidades), pode haver células vazias ou peças inimigas. É lógico pensar que um ataque com duas células vazias nas extremidades pode ser desenvolvido em duas direções, o que o torna mais promissor. O número de células vazias nas "extremidades" do ataque será chamado de
potencial . O potencial pode ser 0, 1 ou 2.
Denotamos ataques da seguinte forma:
[poder de ataque, potencial] . Por exemplo, um
ataque [4: 1] .
Figura 1. Ataque [4: 1]No decorrer da análise, avaliaremos todas as células que entram em uma área específica. Cada célula irá calcular seu
peso . É calculado com base nos pesos de todos os ataques que essa célula afeta.
A essência da análise
Imagine que no campo de jogo já existem vários ataques de um e do segundo jogador. Um dos jogadores faz um movimento (deixe as cruzes). Naturalmente, ele faz uma mudança para uma célula vazia - e, assim, ele pode:
- Desenvolva seu ataque, e talvez mais de um, aumentando seu poder. Pode lançar um novo ataque, etc.
- Impeça o desenvolvimento de um ataque inimigo ou bloqueie-o completamente.
Ou seja, nosso protagonista pode atacar e defender. Ou talvez de uma só vez. Para ele, o primeiro e o segundo são importantes.
A essência da análise é a seguinte:
- O bot substitui as figuras na célula verificada: primeiro uma cruz, depois um zero.
- Em seguida, ele procura todos os ataques que foram recebidos por esses movimentos e resume seus pesos.
- A quantidade recebida é o peso da célula.
- Um algoritmo semelhante é realizado para todas as células do campo de jogo.

De fato, verificamos com esse algoritmo o que acontecerá se formos por esse caminho ... e o que acontecerá se o oponente for por esse caminho. Esperamos um passo adiante e selecionamos a célula mais adequada - com o peso mais alto.
Se uma célula tem mais peso que outra, isso leva à criação de ataques mais perigosos ou ao bloqueio de fortes ataques inimigos. Tudo é lógico ... parece-me.
Se você for para a página e escrever no console SHOW_WEIGHTS = true, poderá sentir visualmente a operação do algoritmo (os pesos das células serão mostrados).
Pesos de Ataque
Revisei meu cérebro e trouxe essa correspondência de ataques e 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;
Empiricamente selecionado - talvez essa não seja a melhor opção.
Eu adicionei um poder de ataque 5 com peso proibitivamente grande ao array. Isso pode ser explicado pelo fato de o bot analisar o jogo, observando um passo à frente (substituindo a figura na célula). Ignorar tal ataque não passa de uma derrota. Bem, ou vitória ... dependendo de quem.
Os ataques com alto potencial são mais valorizados.
Ataque [4: 2] na maioria dos casos decide o resultado do jogo. Se o jogador conseguiu criar um ataque assim, o oponente não poderá mais bloqueá-lo. No entanto, isso não é uma vitória. O inimigo pode terminar o jogo mais rápido, mesmo que tenhamos um ataque [4: 2] em campo, então seu peso é menor que o dos ataques com uma potência de 5. Veja um exemplo abaixo.
Figura 2. Ataque [4: 2]Ataques rasgados
O código não é apresentado neste parágrafo. Aqui, apresentamos o conceito de um divisor de ataques e explicamos a essência dos
"ataques rasgados" .
Considere a seguinte situação: ao substituir uma figura para remover várias células vazias, mas não mais que 5, mais uma está localizada.
E, ao que parece, duas figuras idênticas, na mesma linha ... visualmente, parece um ataque, mas na verdade não. Não é uma ordem, pois esses ataques "rasgados" também trazem uma ameaça em potencial.
Especialmente nesses casos, para cada ataque calcularemos o divisor. Inicialmente, seu valor é 1.
- Apresentamos o ataque "rasgado" como várias
- Contamos o número de células vazias entre o ataque central e o lado
- Para cada célula vazia, o divisor é aumentado em 1
- Calculamos o peso do ataque central, como de costume, o peso dos ataques laterais - divididos pelo divisor
Fig 3. Análise de "ataque rasgado". Uma célula com uma cruz amarela é digitalizada.Assim, ataques rasgados também serão levados em consideração pela IA. De fato, serão ataques comuns, mas quanto mais eles estiverem na célula digitalizada, menor a influência que terão nela e, consequentemente, terão menos peso (graças ao divisor).
Algoritmo de pesquisa de ataque
Primeiro, crie
uma classe de ataque. O ataque terá três atributos, sobre os quais escrevi anteriormente:
class Attack{ constructor( cap = 0, pot = 0, div = 1 ){ this.capability = cap;
E um
método que retornará o peso de um determinado ataque:
countWeigth(){ return ATTACK_WEIGHT[ this.capability, this.potential ] / this.divider } }
Próximo. Dividiremos a pesquisa de todos os ataques de uma célula em:
- Pesquisa Horizontal
- Pesquisa vertical
- Pesquisa na diagonal de 45 graus
- Pesquisa na diagonal de 135 graus
Todas essas são
linhas , e o algoritmo para procurar ataques nessas linhas pode ser generalizado:
a classe checkLine .
No entanto, não precisamos verificar a linha inteira. O poder máximo de ataque que nos interessa é 5. É claro que é possível criar um ataque com um poder de, digamos, 6. Mas para uma IA que analisa a situação do jogo da próxima jogada, é igual a 6 ou 5. A perspectiva de receber um desses ataques indica o final do jogo na próxima jogada. Consequentemente, o peso da célula analisada será o mesmo em ambos os casos.
Atributos de classe:
class checkLine{ constructor(){
É necessário parar por aqui, pois a pergunta pode surgir: por que verificar a 6ª célula se a potência máxima de ataque é 5. A resposta é determinar o potencial remoto do centro de ataque.
Aqui está um exemplo: um ataque com uma potência de 1 na imagem está localizado na borda da área digitalizada. Para descobrir o potencial desse ataque, você precisa "procurar no exterior".
Fig. 3. Digitalizando 6ª células. Se você não varrer a 6ª célula, poderá determinar incorretamente o potencial de ataque.
Simplesmente pode não haver espaço suficiente para concluir alguns ataques. Depois de contar o local do ataque, podemos entender com antecedência qual dos ataques é pouco promissor.
Fig. 4. Lugar para atacarO algoritmo é o seguinte:
1) Vamos começar com a célula central. Deveria estar vazio (vamos fazer uma mudança, certo? Mas não esquecemos que nossa IA deve substituir figuras nesta célula pela análise do próximo movimento. A figura que substituímos é
this.subfig - o padrão é uma cruz. Como a célula central inicialmente conterá alguma forma após a substituição, ela pertencerá a algum ataque
this.curAttack :
- seu poder não será menor que 1 (uma figura na célula central)
- divisor - 1, porque é um ataque central (pertence à célula digitalizada);
- o potencial ainda não é conhecido - o padrão é 0;
Exibimos todos esses pontos nos valores padrão do construtor - veja o código acima.
2) Em seguida, reduzindo o iterador, itera mais de 5 células em um lado do digitalizado. A função
getAttacks (cellX, cellY, subFig, dx, dy) é responsável por isso, onde:
cellX, cellY - coordenadas da célula marcada
subFig - a figura que substituímos na célula marcada
dx, dy - alterações nas coordenadas xey em ciclos - é assim que definimos a direção da pesquisa:
- Horizontal (dx = 1, dy = 0)
- Vertical (dx = 0, dy = 1)
- Diagonal 45 (dx = 1, dy = -1)
- Diagonal 135 (dx = 1, dy = 1)
De certa forma, esse é um vetor paralelo à linha de pesquisa. Assim, uma função poderá pesquisar em 4 direções e não violaremos o princípio DRY novamente.
Código da função:
getAttacks( cellX, cellY, subFig, dx, dy ){ this.substitudeFigure( subFig );
Observe que, se checkCell () retornar algo, o loop será interrompido.
3) Verificamos as figuras dessas células.
A função
checkCell (x, y) é responsável por isso:
Primeiro, escreva a forma na variável
fig :
Model.Field é o nosso campo de jogo.
checkCell( x, y ){ var fig = Model.Field[x] && Model.Field[x][y] !== undefined ? Model.Field[x][y] : 'b';
fig pode ser 'x', 'o', 'b' (borda), 0 (célula vazia).
4) Se na quinta célula a figura coincidir com a célula central, o ataque "repousará" contra a borda e, para determinar o potencial de ataque, você terá que "verificar a borda" (
this.checkEdge = true ).
if( this.iter == 4 && fig == this.subFig )
A função
checkCell está pronta. No entanto, continuamos a trabalhar na classe
checkLine .
5) Depois de concluir o primeiro ciclo, você precisa "virar". Traduzimos o iterador para o centro e o ataque central, com o índice 0, o removemos da matriz de ataques e o definimos como o atual.
turnAround(){ this.iter = 1; this.checkEdge = false; this.curAttack = this.Attacks[0]; this.Attacks.splice(0,1) }
6) Em seguida, vá para o outro lado da célula atual, aumentando o iterador.
Absolutamente a mesma verificação de números. (Código já gravado - função
getAttacks )
7) Tudo, reunimos todos os ataques que estavam em jogo em uma única matriz.
É isso com a classe
checkLine ... tudo é feito.
Bem, então tudo é simples - crie um objeto
checkLine para cada uma das linhas (2 diagonais, horizontal e vertical) e chame a função
getAttacks . Ou seja, para cada linha - seu próprio objeto
checkLine e, consequentemente, seu próprio conjunto de ataques.
Deixe a função
getAllAttacks () ser responsável por tudo isso - já separadamente das classes descritas acima;
getAllAttacks( cellX, cellY ){
Na saída, temos um objeto com todos os ataques para a célula testada
No entanto, você pode ter notado algum tipo de função de filtro. Sua tarefa é filtrar ataques "fúteis":
- Com potência zero (você nunca sabe se eles entram no array)
- Ataques que não têm espaço (local de ataque <5)
- Com potencial zero.
No entanto, se o ataque tiver um poder maior que 5, o filtro o ignorará. O bot deve ver esses ataques, a triagem deles levará a batentes no final do jogo.
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 }
Pontos de interrupção
Sim ... novamente, desculpe! Então, chamaremos a situação no jogo, quando um movimento errado decidir o resultado do jogo.
Por exemplo, um ataque [3: 2] é um ponto de interrupção. Se o oponente não o bloquear colocando uma peça ao lado, então na próxima jogada, já temos um ataque [4: 2] no campo de jogo - bem, o resultado do jogo é decidido, o que quer que se diga (na grande maioria dos casos).
Ou um ataque [4: 1]. Um bocejo - e o jogo pode ser facilmente concluído.
Figura 5. Ponto de interrupçãoTudo é claro e compreensível, e o algoritmo descrito acima já é capaz de levar em conta pontos de interrupção e bloqueá-los em tempo hábil. O bot está ansioso. Ele verá que, no próximo turno, o oponente é capaz de criar um ataque [5: 1], por exemplo, cujo peso é 200 - o que significa que o nerd esperto vai até aqui.
No entanto, imagine uma situação em que um dos jogadores consiga 2 pontos de interrupção no campo. E isso, obviamente, não deixa chance para o oponente, porque de uma só vez, podemos bloquear apenas um ponto de interrupção. Como ensinar nossa IA a bloquear esses ataques?
Figura 6. 2 pontos de interrupçãoÉ simples: ao analisar uma célula, ao substituir uma peça nela, contaremos o número de pontos de interrupção que obteremos no próximo turno (o bot olha para o avanço, não se esqueça). Contando 2 pontos de interrupção, aumentamos o peso da célula em 100.
E agora, o bot não apenas impedirá tais situações de jogo, mas também poderá criá-las, o que o torna agora um oponente mais formidável.
Como entender que um ataque é um ponto de interrupção
Vamos começar pelo óbvio: qualquer ataque com um poder de 4 é um ponto de interrupção. Apenas uma jogada perdida nos dá a oportunidade de concluir o jogo, ou seja, coloque 5 peças seguidas.
Além disso, se o potencial de ataque for 2, gastaremos 1 turno a mais para bloquear esse ataque, o que significa que há um ponto de interrupção com potência de 3. Mas existe apenas um ponto de interrupção - este é um ataque [3: 2].
E ainda mais difícil -
"ataques rasgados" .
Consideraremos apenas ataques com uma célula vazia no meio - não mais. Isso ocorre porque, para concluir o ataque com duas células vazias no meio, você precisa gastar pelo menos 2 movimentos - isso claramente não é um ponto de interrupção.
Como lembramos, consideramos os ataques divididos como vários convencionais: um ataque central e ataques laterais. O ataque central pertence à célula digitalizada, o divisor lateral possui mais de 1 - isso foi descrito acima.
Algoritmo para encontrar um ponto de interrupção (mais fácil, leia abaixo):
- Introduzimos a pontuação variável
- Tomamos o ataque central, consideramos o poder
- Tomamos um dos lados se o seu divisor não for superior a 2x.
- Pontuação - a soma do poder dos ataques central e lateral
- Se o potencial dos ataques central e lateral for 2, para bloquear um ataque desse tipo, você precisará passar mais um turno. Portanto, a pontuação é aumentada em 1
- Se pontuação > = 4, então este é um ponto de interrupção
De fato, os pontos de interrupção poderiam ser simplesmente enumerados, não existem muitos, mas eu não entendi imediatamente.
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 ){
Sim, finalmente vamos juntar tudo
Então, o principal inferno por trás é descrito acima. É hora de moldar algo que funcione a partir dele. A função
countWeight (x, y) - pega as coordenadas da célula como uma entrada e retorna seu peso. O que há sob o capuz dela?
Primeiro, obtemos uma matriz de todos os ataques aos quais a célula pertence. (
getAllAttacks (x, y) ). Atravessando todas as linhas, contamos o número de pontos de interrupção. Se houver 2 pontos de interrupção, lembramos que essa situação pode decidir o resultado do jogo e aumentar o peso da célula em 100.
No entanto, todos os pontos de interrupção devem pertencer a um jogador, então eu tive que implementar uma verificação em 2 etapas: primeiro cruze e depois zeros.
Como não
forneci ataques com potência de 6 ou mais na variedade de pesos de ataque (
ATTACK_WEIGHTS [] ), tive que substituí-los por ataques com potência de 5. Não faz diferença - todos eles levam ao final do jogo.
Bem, resumimos os pesos de ataque - isso é tudo.
Outro pequeno ponto: para que o bot não seja estúpido no final do jogo, quando já construiu um ataque com potência de 4 e pensa no movimento atual, é necessário aumentar significativamente o peso da célula para concluir esse ataque. Sem isso, a IA, simplesmente, pode começar a se defender dos ataques "perigosos" do oponente, embora o jogo pareça ter sido vencido. A última jogada é 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 } }
Agora, ao chamar essa função para uma célula específica, obteremos seu peso. Realizamos esta operação para todas as células e selecionamos as melhores (com o maior peso). Lá e vá)
Você pode encontrar o restante do código no
github . Já existe bastante material, e sua apresentação, como ainda não tentei, deixa muito a desejar.
Mas se você pudesse ler até este ponto, caro leitor, então eu sou grato a você.Minha opinião sobre o resultado
Desça! Sim, você pode vencê-lo, mas fazê-lo é um pouco problemático para mim pessoalmente. Talvez eu não seja cuidadoso o suficiente. Tente sua força também.Eu sei que é mais fácil, mas não sei como. Eu gostaria de ouvir pessoas que conhecem ou observam outras implementações desse bot.Eu sei o que pode ser melhor. Sim ... você pode usar algoritmos conhecidos, como o minimax, mas para isso você precisa ter alguma base de conhecimento no campo da teoria dos jogos, da qual infelizmente não posso me gabar.No futuro, pretendo adicionar a análise de pontos de interrupção vários passos adiante, o que tornará o bot um rival ainda mais sério. No entanto, agora não tenho uma idéia clara sobre a implementação disso; Só tenho a próxima sessão e um diploma incompleto - o que me entristece.Obrigado se você ler até o fim.