AI y 2048. Parte 2: recorte de Minimax + alfa beta



Examinamos el método de Monte Carlo , hoy veremos cómo juega la mente de la computadora en 2048 usando el viejo minimax con recorte alfa-beta.

Software EDISON - desarrollo web
Este artículo fue escrito con el apoyo de EDISON, una compañía que desarrolla aplicaciones móviles y brinda servicios de prueba de software .

Solución espiada por el usuario stackoverflow ovolve , quien señaló en la discusión cómo enseñar a AI el juego 2048 .

Traducción de comentarios de ovolve
Soy el autor del programa mencionado en este hilo. Puedes ver la IA en acción o ver el código .

Actualmente, el programa gana en aproximadamente el 90% de los casos al ejecutar java-scripts en un navegador en mi computadora portátil, gastando 100 milisegundos para pensar en el curso, trabajando, aunque no perfectamente, pero bastante bien.

Dado que el juego es un espacio de estado discreto con información completa, de hecho es un juego por turnos como el ajedrez y las damas, utilicé los mismos métodos que mostraron su rendimiento en estos juegos, es decir, búsqueda de minimax con recorte alfa-beta . Como los enlaces proporcionan mucha información sobre este algoritmo, solo hablaré sobre las dos heurísticas principales que utilicé en la función de estimación estática y formalizaré muchas de las suposiciones intuitivas hechas por otras personas aquí.


Monotonía


Esta heurística trata de garantizar que todos los valores de mosaico aumenten o disminuyan tanto hacia la izquierda / derecha como hacia arriba / abajo. Esta heurística por sí sola refleja la conjetura de que muchos otros han mencionado que los mosaicos más valiosos deberían agruparse en una esquina. Esto, por regla general, evita la acumulación de fichas menos valiosas y mantiene el tablero organizado, ya que las fichas más pequeñas caen en cascada en las más grandes.

Aquí hay una captura de pantalla de una cuadrícula completamente monótona. Obtuve esta situación ejecutando un algoritmo con la función eval instalada para ignorar otras heurísticas y tener en cuenta solo la monotonía.


Suavidad (suavidad, uniformidad)


La heurística anterior en sí misma tiende a crear estructuras en las cuales las celdas vecinas tienen un valor reducido, sin embargo, por supuesto, las vecinas deben tener el mismo significado para combinar. Por lo tanto, la heurística de suavidad simplemente mide la diferencia de valores entre las fichas adyacentes, tratando de minimizar su número.

Un comentarista de Hacker News proporcionó una interesante formalización de esta idea en términos de teoría de grafos.

Traducción de formalización con Hacker News
Ayer le mostré este juego a un colega, un amante de la teoría de gráficos, y también decidimos pensar en cómo resolver este juego usando IA.

La solución más simple es minimax, que, según lo veo, se implementa bastante bien. Si alguien aquí no está familiarizado con minimax, OP escribió un código muy elegante y bien comentado que sería un gran tutorial.

El enfoque menos computacionalmente intensivo que propusimos fue modelar el estado del juego en forma de un gráfico G (V, E) , donde V es un conjunto de fichas activas y E es un conjunto de bordes que conectan fichas adyacentes ponderadas por la función c (v1, v2) , que devuelve el valor absoluto de la diferencia entre los dos mosaicos. Para cada solución, la IA elige un movimiento que minimiza la suma de los pesos de todos los bordes en el nuevo estado del juego.

La razón de esto es que la única forma de progresar en el juego es tener fichas con los mismos valores uno al lado del otro, para lo cual el peso en G será 0. Por lo tanto, la IA debería tratar de minimizar el peso total. Al final, habrá un gran número en los tableros con un gran peso de bordes a las fichas adyacentes, por lo que la IA intentará mantener estas fichas junto a otras fichas grandes para minimizar la diferencia.

Como el juego es estocástico, el enfoque que describí puede no funcionar en el peor de los casos, pero también se puede aplicar a la solución minimax existente como una función de peso para cada nodo en el árbol.


Aquí hay una captura de pantalla de una malla perfectamente lisa, amablemente proporcionada por este excelente tenedor simulado . (enlace al archivo web, mientras que los scripts de Java en la página funcionan y puede usar el teclado para moverse en cualquier dirección - nota del traductor).

Azulejos sueltos


Y finalmente, hay una penalización por tener muy pocas fichas libres, ya que las opciones pueden terminar rápidamente cuando el campo de juego se vuelve demasiado estrecho.

¡Y eso es todo! Buscar en el espacio del juego mientras se optimizan estos criterios ofrece un rendimiento sorprendentemente bueno. Uno de los beneficios de utilizar un enfoque genérico como este en lugar de una estrategia de movimiento codificada explícitamente es que el algoritmo a menudo puede encontrar soluciones interesantes e inesperadas. Si observa su progreso, a menudo realiza movimientos sorprendentes pero efectivos, como el cambio repentino de muros o esquinas, cerca de los cuales construye su juego.


Pequeño cambio


La captura de pantalla demuestra el poder de este enfoque. Eliminé el límite de mosaico (para que continúen creciendo después de llegar a 2048), y aquí está el mejor resultado después de ocho pruebas.

Sí, esto es 4096 junto con 2048. =) Esto significa que ha alcanzado la esquiva ficha 2048 en un tablero.







El código Java-Script para minimax con recorte alfa-beta y función de evaluación estática del usuario ovolve stackoverflow se detalla a continuación en el artículo.

El método minimax está dedicado a varios excelentes artículos habr, por lo que omitimos la explicación académica detallada de en qué consiste. Para aquellos que se unieron a la comunidad de TI, recientemente escuché los hermosos términos "minimax" y "recorte alfa-beta", pero no sé lo que esto significa, intentemos, literalmente en un par de párrafos, explicar el significado más general.

Minimax


En algunos juegos, el proceso de un juego entre dos jugadores (que hacen un movimiento a su vez) puede representarse como un llamado árbol de opciones. En cada posición específica, cada jugador generalmente puede elegir entre diferentes opciones para su movimiento. Y en respuesta a cada una de estas opciones, un oponente también puede ser de muchas maneras.


Fragmento de un árbol de opciones.

Dado que en cualquier momento del juego hay información completa sobre el estado del campo de juego, el estado actual de la posición siempre se puede estimar con precisión. Dicha función se denomina función de evaluación estática o SFO abreviada. Además, cuanto más importante es esta función al evaluar una posición específica, más ventajosa es la posición para un jugador (llamémosle jugador maximizador ). Cuanto más pequeño es el valor numérico de esta función al evaluar una posición, más ventajosa es la posición para el segundo jugador (llamémoslo el jugador que minimiza ).

Después de cada movimiento, la posición cambia y, por lo tanto, su puntaje cambia. Al considerar el árbol de opciones, cada jugador necesita no solo preferir aquellas ramas en las que la calificación es más favorable para él. También debes evitar aquellas ramas en las que la evaluación de la posición es favorable para el oponente.

Se supone que el oponente también se guía por el racionalismo y también evita opciones que podrían llevarlo a perder. Es decir, cada jugador, al elegir una opción, procede a maximizar su propio beneficio y al mismo tiempo minimizar el beneficio del oponente.

Esto es minimax.

Recorte alfa beta


Es bastante obvio: quien calcula un árbol desde una posición dada a una mayor profundidad, tiene más posibilidades de ganar. Pero hay una molestia: el árbol de opciones en los juegos tiene el desagradable hábito de ramificarse y crecer exponencialmente con cada nivel de anidación. Las habilidades de conteo de los programas, y aún más por lo que las personas son limitadas, el conteo "hasta el tapete" está lejos de ser siempre posible. Puede resultar fácilmente que un jugador ha contado hasta una posición en la que tiene una buena evaluación del campo de juego, pero literalmente en el siguiente nivel (ilegible) el oponente tiene la oportunidad de hacer un movimiento que cambie radicalmente la estimación de la posición al contrario.

¿Quién tiene la culpa y qué hacer? La complejidad computacional es la responsable del recorrido completo del árbol; se propone luchar cortando ramas innecesarias. Si el jugador que evalúa la posición ve que alguna rama del árbol de opciones:

o menos rentable para él que otras ramas que ya han sido analizadas,
o más beneficioso para el oponente que otras ramas que ya han sido analizadas,

entonces el jugador descarta esta rama, no pierde tiempo y recursos al considerar las subopciones de esta rama obviamente peor para él.

Esto le permite asignar más recursos informáticos para calcular ramas más favorables a una mayor profundidad de representación en el árbol de opciones. En el proceso de evaluar el campo de juego en diferentes niveles del árbol de opciones, el jugador opera con dos coeficientes que cambian dinámicamente: alfa (el valor del SFD que se encuentra mínimamente en la rama, es decir, más favorable para el jugador que minimiza) y beta (el valor del SFD que se encuentra más en la rama, es decir, más favorable para el jugador maximizador). En cada nivel, comparar el SFD de la posición actual con los coeficientes alfa y beta le permite barrer (sin calcularlos completamente) ramas que son menos beneficiosas para el jugador que evalúa la posición y / o más beneficioso para su oponente.

Este es el recorte alfa beta.

Función minimax recursiva con recorte alfa beta


2048 con AI se implementa como una aplicación de Excel con macros VBA, así es como el algoritmo minimax con recorte alfa beta parece un básico visual despreciable.
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' '''''''''''''''''''''''( - )''''''''''''''''''''''''' ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' '       -- 'Position -  4  4     'Depth - ,    'Alpha, Beta -         'MaximisingPlayer -      ? Private Function MiniMaxAlpaBeta_Evaluation(Position As Variant, Depth As Long, _ Alpha As Double, Beta As Double, _ MaximisingPlayer As Boolean, _ Optional MainLevel As Boolean = False) As Double Dim MaxEval As Double '  Dim MinEval As Double '  Dim PositionNext As Variant '     Dim PositionTemp As Variant '     Dim Eval As Double '   Dim Way As Long '   -      Dim Row As Long '     Dim Col As Long '     Dim TileNew As Long '      '   (  ,  '     ) If GameOverPosition(Position) Then '    ? '     MiniMaxAlpaBeta_Evaluation = -1000000 + TileMax(Position) '         ElseIf Depth = 0 Then '     MiniMaxAlpaBeta_Evaluation = StaticEvaluation(Position) '  ,    '     () ElseIf MaximisingPlayer Then MaxEval = -1000000 '      For Way = 1 To 4 ' 4   - (, , , ) ChangeCount = 0 ' ,      ',       PositionNext = StepHuman(Position, Way) If ChangeCount > 0 Then '     '      , '    () Eval = MiniMaxAlpaBeta_Evaluation(PositionNext, Depth - 1, _ Alpha, Beta, False) If Eval > MaxEval Then MaxEval = Eval '  '     If Eval > Alpha Then Alpha = Eval '    ,   '   -    If Beta > Alpha Then Exit For End If Next '          MiniMaxAlpaBeta_Evaluation = MaxEval '  ,    '     () Else 'Not MaximisingPlayer MinEval = 1000000 '      For Row = 1 To 4 '     For Col = 1 To 4 '     If Position(Row, Col) = 0 Then '   For TileNew = 2 To 4 Step 2 '    2  4 ',       '    PositionNext = StepComp(Position, Row, Col, TileNew) '     , '    () Eval = MiniMaxAlpaBeta_Evaluation(PositionNext, Depth - 1, _ Alpha, Beta, True) If Eval < MinEval Then MinEval = Eval '  '     If Eval < Beta Then Beta = Eval '    ,   '   -    If Alpha < Beta Then Exit For Next End If Next Next '          MiniMaxAlpaBeta_Evaluation = MinEval End If End Function 

Código Ovolve en java-script
 function AI(grid) { this.grid = grid; } //   () AI.prototype.eval = function() { var emptyCells = this.grid.availableCells().length; var smoothWeight = 0.1, //monoWeight = 0.0, //islandWeight = 0.0, mono2Weight = 1.0, emptyWeight = 2.7, maxWeight = 1.0; return this.grid.smoothness() * smoothWeight //+ this.grid.monotonicity() * monoWeight //- this.grid.islands() * islandWeight + this.grid.monotonicity2() * mono2Weight + Math.log(emptyCells) * emptyWeight + this.grid.maxValue() * maxWeight; }; // alpha-beta depth first search AI.prototype.search = function(depth, alpha, beta, positions, cutoffs) { var bestScore; var bestMove = -1; var result; // the maxing player if (this.grid.playerTurn) { bestScore = alpha; for (var direction in [0, 1, 2, 3]) { var newGrid = this.grid.clone(); if (newGrid.move(direction).moved) { positions++; if (newGrid.isWin()) { return { move: direction, score: 10000, positions: positions, cutoffs: cutoffs }; } var newAI = new AI(newGrid); if (depth == 0) { result = { move: direction, score: newAI.eval() }; } else { result = newAI.search(depth-1, bestScore, beta, positions, cutoffs); if (result.score > 9900) { // win result.score--; // to slightly penalize higher depth from win } positions = result.positions; cutoffs = result.cutoffs; } if (result.score > bestScore) { bestScore = result.score; bestMove = direction; } if (bestScore > beta) { cutoffs++ return { move: bestMove, score: beta, positions: positions, cutoffs: cutoffs }; } } } } else { // computer's turn, we'll do heavy pruning to keep the branching factor low bestScore = beta; // try a 2 and 4 in each cell and measure how annoying it is // with metrics from eval var candidates = []; var cells = this.grid.availableCells(); var scores = { 2: [], 4: [] }; for (var value in scores) { for (var i in cells) { scores[value].push(null); var cell = cells[i]; var tile = new Tile(cell, parseInt(value, 10)); this.grid.insertTile(tile); scores[value][i] = -this.grid.smoothness() + this.grid.islands(); this.grid.removeTile(cell); } } // now just pick out the most annoying moves var maxScore = Math.max(Math.max.apply(null, scores[2]), Math.max.apply(null, scores[4])); for (var value in scores) { // 2 and 4 for (var i=0; i<scores[value].length; i++) { if (scores[value][i] == maxScore) { candidates.push( { position: cells[i], value: parseInt(value, 10) } ); } } } // search on each candidate for (var i=0; i<candidates.length; i++) { var position = candidates[i].position; var value = candidates[i].value; var newGrid = this.grid.clone(); var tile = new Tile(position, value); newGrid.insertTile(tile); newGrid.playerTurn = true; positions++; newAI = new AI(newGrid); result = newAI.search(depth, alpha, bestScore, positions, cutoffs); positions = result.positions; cutoffs = result.cutoffs; if (result.score < bestScore) { bestScore = result.score; } if (bestScore < alpha) { cutoffs++; return { move: null, score: alpha, positions: positions, cutoffs: cutoffs }; } } } return { move: bestMove, score: bestScore, positions: positions, cutoffs: cutoffs }; } // performs a search and returns the best move AI.prototype.getBest = function() { return this.iterativeDeep(); } // performs iterative deepening over the alpha-beta search AI.prototype.iterativeDeep = function() { var start = (new Date()).getTime(); var depth = 0; var best; do { var newBest = this.search(depth, -10000, 10000, 0 ,0); if (newBest.move == -1) { break; } else { best = newBest; } depth++; } while ( (new Date()).getTime() - start < minSearchTime); return best } AI.prototype.translate = function(move) { return { 0: 'up', 1: 'right', 2: 'down', 3: 'left' }[move]; } 

Función de evaluación estática


Dado que en cada nivel del árbol de opciones tienes que evaluar el campo de juego (para decidir cuál de los jugadores, la posición estimada es realmente más ventajosa), debes decidir con qué criterios distinguir una buena posición de una mala.

Suponemos que el jugador maximizador es la persona (o IA) que decide en cuál de las 4 direcciones (arriba, izquierda, derecha, abajo) mover todas las fichas. Un jugador que minimiza es esa subrutina insidiosa que genera aleatoriamente 2 o 4 en los lugares más inapropiados.

La OFS se compila desde la perspectiva de un jugador maximizador. Cuanto mayor sea la calificación SFD para el campo de juego, mejor será la posición para el "maximalista". Cuanto más bajo, más agradable es la posición en el tablero para el "minimalista".

En el caso de 2048, ¿qué factores se consideran favorables para quien mueve las fichas?

Monotonía


En primer lugar, es deseable que las fichas estén dispuestas en orden ascendente / descendente en algunas direcciones. Si esto no se hace, cuando se generen nuevas fichas, el campo de juego se obstruirá rápidamente con fichas ordenadas al azar de diferentes tamaños, que no pueden conectarse inmediatamente entre sí normalmente.

En el Distrito Federal de Siberia, debe mirar en las 4 direcciones (de arriba hacia abajo, de izquierda a derecha, de derecha a izquierda, de abajo hacia arriba) y calcular dónde están los progresos decrecientes o decrecientes. Si en progresión hay fichas que no encajan en la serie general, esto reduce el coeficiente numérico de la monotonía. Luego, de los 4 coeficientes para todas las direcciones, se selecciona el mejor, que se tiene en cuenta en el valor total del Distrito Federal de Siberia.

Suavidad


Además, sería más preferible que la progresión de estar parado en una fila de fichas no solo aumentara, sino que no disminuyera (o en lugar de disminuirla, es preferible que no aumentara), es decir, es bueno cuando las mismas fichas están cerca, lo que les permite colapsar en una, ganando puntos y aumentando el espacio libre en el campo de juego.

Por lo tanto, el Distrito Federal de Siberia está buscando en el campo de juego fichas adyacentes idénticas y tiene en cuenta el número de tales pares en un coeficiente especial.

Celdas vacías


Obviamente, cuanto más espacio libre, más espacio para maniobrar y menos posibilidades de perder rápidamente.

La OFS considera celdas vacías en el campo y, cuanto más, la posición se considera más rentable para el jugador maximizador.

Azulejo máximo


Dado que lo principal en este juego es obtener un gran mosaico en el campo, cuanto más mejor, 2048, 4096, 8192 (o lo que sea que tenga la fuerza y ​​la paciencia para), las opciones en las que el valor máximo del mosaico es más deben considerarse como el SFD más rentable.

Distrito Federal de Siberia para 2048


Implementación del Distrito Federal de Siberia como macro VBA
 ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' '''''''''''''''''''''''  '''''''''''''''''''''''' ''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' '     'Position -  4  4     Private Function StaticEvaluation(Position As Variant) As Double Dim Smoothness As Double ' Dim Monotonicity As Double ' Dim EmptyCount As Double '  Dim MaxValue As Long '  '   Const SmoothWeight = 0.1 Const MonoWeight = 1 Const EmptyWeight = 2.7 Const MaxWeight = 1 Dim k As Long '   Dim i As Long '  Dim j As Long '  Dim x As Long '  Dim y As Long '  ' Dim Value As Double '       '         Dim TargetValue As Double Smoothness = 0 '    For i = 1 To 4 '     For j = 1 To 4 '     If Position(i, j) > 0 Then '   Value = Log(Position(i, j)) / Log(2) If i < 4 Then '       For x = i + 1 To 4 '    If Position(x, j) > 0 Then '    '    TargetValue = Log(Position(x, j)) / Log(2) ',   Smoothness = Abs(Value - TargetValue) '       Exit For End If Next End If If j < 4 Then '       For y = j + 1 To 4 '    If Position(i, y) > 0 Then '    '    TargetValue = Log(Position(i, y)) / Log(2) ',   Smoothness = Abs(Value - TargetValue) '        Exit For End If Next End If End If Next Next ' Dim arrTotals(1 To 4) As Double '     Dim Current As Long '   Dim Next_ As Long '      Dim CurrentValue As Double '      Dim NextValue As Double '        Monotonicity = 0 '    '      For k = 1 To 4 arrTotals(k) = 0 Next ' -  - For x = 1 To 4 '   Current = 1 '      '   (     )  Next_ = Current + 1 Do While Next_ <= 4 '       '      '(       ) Do While Next_ <= 4 '       If Position(x, Next_) = 0 Then '     Next_ = Next_ + 1 '   Else ' -    Exit Do ' ,  ,   End If Loop '         If Next_ > 4 Then Next_ = 4 '          If Position(x, Current) > 0 Then CurrentValue = Log(Position(x, Current)) / Log(2) Else CurrentValue = 0 End If ' MsgBox "Position[" & x & ", " & Next_ & "]=" & Position(x, Next_) If Position(x, Next_) > 0 Then NextValue = Log(Position(x, Next_)) / Log(2) Else NextValue = 0 End If If CurrentValue > NextValue Then '   ? arrTotals(Up) = arrTotals(Up) + NextValue - CurrentValue ElseIf NextValue > CurrentValue Then '   ? arrTotals(Down) = arrTotals(Down) + CurrentValue - NextValue End If Current = Next_ '       Next_ = Next_ + 1 '    Loop Next '      -  - Monotonicity = IIf(arrTotals(Up) >= arrTotals(Down), _ Monotonicity + arrTotals(Up), _ Monotonicity + arrTotals(Down)) ' -  - For y = 1 To 4 '   Current = 1 '      '   (     )  Next_ = Current + 1 Do While Next_ <= 4 '       '      '(       ) Do While Next_ <= 4 '       If Position(Next_, y) = 0 Then '     Next_ = Next_ + 1 '   Else ' -    Exit Do ' ,  ,   End If Loop '         If Next_ > 4 Then Next_ = 4 '          If Position(Current, y) > 0 Then CurrentValue = Log(Position(Current, y)) / Log(2) Else CurrentValue = 0 End If If Position(Next_, y) > 0 Then NextValue = Log(Position(Next_, y)) / Log(2) Else NextValue = 0 End If If CurrentValue > NextValue Then '   ? arrTotals(Left) = arrTotals(Left) + NextValue - CurrentValue ElseIf NextValue > CurrentValue Then '   ? arrTotals(Right) = arrTotals(Right) + CurrentValue - NextValue End If Current = Next_ '       Next_ = Next_ + 1 '    Loop Next '      -  - Monotonicity = IIf(arrTotals(Left) >= arrTotals(Right), _ Monotonicity + arrTotals(Left), _ Monotonicity + arrTotals(Right)) '     EmptyCount = 0 '      MaxValue = 0 '    For i = 1 To 4 '     For j = 1 To 4 '     If Position(i, j) = 0 Then '  ... '...     EmptyCount = EmptyCount + 1 '     ... ElseIf Position(i, j) > MaxValue Then MaxValue = Position(i, j) '...    End If Next Next '   StaticEvaluation = Smoothness * SmoothWeight + _ Monotonicity * MonoWeight + _ Log_Base_Arg(EmptyCount) * EmptyWeight + _ MaxValue * MaxWeight End Function 

Código Ovolve en java-script
 function Grid(size) { this.size = size; this.startTiles = 2; this.cells = []; this.build(); this.playerTurn = true; } // pre-allocate these objects (for speed) Grid.prototype.indexes = []; for (var x=0; x<4; x++) { Grid.prototype.indexes.push([]); for (var y=0; y<4; y++) { Grid.prototype.indexes[x].push( {x:x, y:y} ); } } // Build a grid of the specified size Grid.prototype.build = function () { for (var x = 0; x < this.size; x++) { var row = this.cells[x] = []; for (var y = 0; y < this.size; y++) { row.push(null); } } }; // Find the first available random position Grid.prototype.randomAvailableCell = function () { var cells = this.availableCells(); if (cells.length) { return cells[Math.floor(Math.random() * cells.length)]; } }; Grid.prototype.availableCells = function () { var cells = []; var self = this; this.eachCell(function (x, y, tile) { if (!tile) { //cells.push(self.indexes[x][y]); cells.push( {x:x, y:y} ); } }); return cells; }; // Call callback for every cell Grid.prototype.eachCell = function (callback) { for (var x = 0; x < this.size; x++) { for (var y = 0; y < this.size; y++) { callback(x, y, this.cells[x][y]); } } }; // Check if there are any cells available Grid.prototype.cellsAvailable = function () { return !!this.availableCells().length; }; // Check if the specified cell is taken Grid.prototype.cellAvailable = function (cell) { return !this.cellOccupied(cell); }; Grid.prototype.cellOccupied = function (cell) { return !!this.cellContent(cell); }; Grid.prototype.cellContent = function (cell) { if (this.withinBounds(cell)) { return this.cells[cell.x][cell.y]; } else { return null; } }; // Inserts a tile at its position Grid.prototype.insertTile = function (tile) { this.cells[tile.x][tile.y] = tile; }; Grid.prototype.removeTile = function (tile) { this.cells[tile.x][tile.y] = null; }; Grid.prototype.withinBounds = function (position) { return position.x >= 0 && position.x < this.size && position.y >= 0 && position.y < this.size; }; Grid.prototype.clone = function() { newGrid = new Grid(this.size); newGrid.playerTurn = this.playerTurn; for (var x = 0; x < this.size; x++) { for (var y = 0; y < this.size; y++) { if (this.cells[x][y]) { newGrid.insertTile(this.cells[x][y].clone()); } } } return newGrid; }; // Set up the initial tiles to start the game with Grid.prototype.addStartTiles = function () { for (var i=0; i<this.startTiles; i++) { this.addRandomTile(); } }; // Adds a tile in a random position Grid.prototype.addRandomTile = function () { if (this.cellsAvailable()) { var value = Math.random() < 0.9 ? 2 : 4; //var value = Math.random() < 0.9 ? 256 : 512; var tile = new Tile(this.randomAvailableCell(), value); this.insertTile(tile); } }; // Save all tile positions and remove merger info Grid.prototype.prepareTiles = function () { this.eachCell(function (x, y, tile) { if (tile) { tile.mergedFrom = null; tile.savePosition(); } }); }; // Move a tile and its representation Grid.prototype.moveTile = function (tile, cell) { this.cells[tile.x][tile.y] = null; this.cells[cell.x][cell.y] = tile; tile.updatePosition(cell); }; Grid.prototype.vectors = { 0: { x: 0, y: -1 }, // up 1: { x: 1, y: 0 }, // right 2: { x: 0, y: 1 }, // down 3: { x: -1, y: 0 } // left } // Get the vector representing the chosen direction Grid.prototype.getVector = function (direction) { // Vectors representing tile movement return this.vectors[direction]; }; // Move tiles on the grid in the specified direction // returns true if move was successful Grid.prototype.move = function (direction) { // 0: up, 1: right, 2:down, 3: left var self = this; var cell, tile; var vector = this.getVector(direction); var traversals = this.buildTraversals(vector); var moved = false; var score = 0; var won = false; // Save the current tile positions and remove merger information this.prepareTiles(); // Traverse the grid in the right direction and move tiles traversals.x.forEach(function (x) { traversals.y.forEach(function (y) { cell = self.indexes[x][y]; tile = self.cellContent(cell); if (tile) { //if (debug) { //console.log('tile @', x, y); //} var positions = self.findFarthestPosition(cell, vector); var next = self.cellContent(positions.next); // Only one merger per row traversal? if (next && next.value === tile.value && !next.mergedFrom) { var merged = new Tile(positions.next, tile.value * 2); merged.mergedFrom = [tile, next]; self.insertTile(merged); self.removeTile(tile); // Converge the two tiles' positions tile.updatePosition(positions.next); // Update the score score += merged.value; // The mighty 2048 tile if (merged.value === 2048) { won = true; } } else { //if (debug) { //console.log(cell); //console.log(tile); //} self.moveTile(tile, positions.farthest); } if (!self.positionsEqual(cell, tile)) { self.playerTurn = false; //console.log('setting player turn to ', self.playerTurn); moved = true; // The tile moved from its original cell! } } }); }); //console.log('returning, playerturn is', self.playerTurn); //if (!moved) { //console.log('cell', cell); //console.log('tile', tile); //console.log('direction', direction); //console.log(this.toString()); //} return {moved: moved, score: score, won: won}; }; Grid.prototype.computerMove = function() { this.addRandomTile(); this.playerTurn = true; } // Build a list of positions to traverse in the right order Grid.prototype.buildTraversals = function (vector) { var traversals = { x: [], y: [] }; for (var pos = 0; pos < this.size; pos++) { traversals.x.push(pos); traversals.y.push(pos); } // Always traverse from the farthest cell in the chosen direction if (vector.x === 1) traversals.x = traversals.x.reverse(); if (vector.y === 1) traversals.y = traversals.y.reverse(); return traversals; }; Grid.prototype.findFarthestPosition = function (cell, vector) { var previous; // Progress towards the vector direction until an obstacle is found do { previous = cell; cell = { x: previous.x + vector.x, y: previous.y + vector.y }; } while (this.withinBounds(cell) && this.cellAvailable(cell)); return { farthest: previous, next: cell // Used to check if a merge is required }; }; Grid.prototype.movesAvailable = function () { return this.cellsAvailable() || this.tileMatchesAvailable(); }; // Check for available matches between tiles (more expensive check) // returns the number of matches Grid.prototype.tileMatchesAvailable = function () { var self = this; //var matches = 0; var tile; for (var x = 0; x < this.size; x++) { for (var y = 0; y < this.size; y++) { tile = this.cellContent({ x: x, y: y }); if (tile) { for (var direction = 0; direction < 4; direction++) { var vector = self.getVector(direction); var cell = { x: x + vector.x, y: y + vector.y }; var other = self.cellContent(cell); if (other && other.value === tile.value) { return true; //matches++; // These two tiles can be merged } } } } } //console.log(matches); return false; //matches; }; Grid.prototype.positionsEqual = function (first, second) { return first.x === second.x && first.y === second.y; }; Grid.prototype.toString = function() { string = ''; for (var i=0; i<4; i++) { for (var j=0; j<4; j++) { if (this.cells[j][i]) { string += this.cells[j][i].value + ' '; } else { string += '_ '; } } string += '\n'; } return string; } // counts the number of isolated groups. Grid.prototype.islands = function() { var self = this; var mark = function(x, y, value) { if (x >= 0 && x <= 3 && y >= 0 && y <= 3 && self.cells[x][y] && self.cells[x][y].value == value && !self.cells[x][y].marked ) { self.cells[x][y].marked = true; for (direction in [0,1,2,3]) { var vector = self.getVector(direction); mark(x + vector.x, y + vector.y, value); } } } var islands = 0; for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (this.cells[x][y]) { this.cells[x][y].marked = false } } } for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (this.cells[x][y] && !this.cells[x][y].marked) { islands++; mark(x, y , this.cells[x][y].value); } } } return islands; } // measures how smooth the grid is (as if the values of the pieces // were interpreted as elevations). Sums of the pairwise difference // between neighboring tiles (in log space, so it represents the // number of merges that need to happen before they can merge). // Note that the pieces can be distant Grid.prototype.smoothness = function() { var smoothness = 0; for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if ( this.cellOccupied( this.indexes[x][y] )) { var value = Math.log(this.cellContent( this.indexes[x][y] ).value) / Math.log(2); for (var direction=1; direction<=2; direction++) { var vector = this.getVector(direction); var targetCell = this.findFarthestPosition(this.indexes[x][y], vector).next; if (this.cellOccupied(targetCell)) { var target = this.cellContent(targetCell); var targetValue = Math.log(target.value) / Math.log(2); smoothness -= Math.abs(value - targetValue); } } } } } return smoothness; } Grid.prototype.monotonicity = function() { var self = this; var marked = []; var queued = []; var highestValue = 0; var highestCell = {x:0, y:0}; for (var x=0; x<4; x++) { marked.push([]); queued.push([]); for (var y=0; y<4; y++) { marked[x].push(false); queued[x].push(false); if (this.cells[x][y] && this.cells[x][y].value > highestValue) { highestValue = this.cells[x][y].value; highestCell.x = x; highestCell.y = y; } } } increases = 0; cellQueue = [highestCell]; queued[highestCell.x][highestCell.y] = true; markList = [highestCell]; markAfter = 1; // only mark after all queued moves are done, as if searching in parallel var markAndScore = function(cell) { markList.push(cell); var value; if (self.cellOccupied(cell)) { value = Math.log(self.cellContent(cell).value) / Math.log(2); } else { value = 0; } for (direction in [0,1,2,3]) { var vector = self.getVector(direction); var target = { x: cell.x + vector.x, y: cell.y+vector.y } if (self.withinBounds(target) && !marked[target.x][target.y]) { if ( self.cellOccupied(target) ) { targetValue = Math.log(self.cellContent(target).value ) / Math.log(2); if ( targetValue > value ) { //console.log(cell, value, target, targetValue); increases += targetValue - value; } } if (!queued[target.x][target.y]) { cellQueue.push(target); queued[target.x][target.y] = true; } } } if (markAfter == 0) { while (markList.length > 0) { var cel = markList.pop(); marked[cel.x][cel.y] = true; } markAfter = cellQueue.length; } } while (cellQueue.length > 0) { markAfter--; markAndScore(cellQueue.shift()) } return -increases; } // measures how monotonic the grid is. This means the values of the tiles are strictly increasing // or decreasing in both the left/right and up/down directions Grid.prototype.monotonicity2 = function() { // scores for all four directions var totals = [0, 0, 0, 0]; // up/down direction for (var x=0; x<4; x++) { var current = 0; var next = current+1; while ( next<4 ) { while ( next<4 && !this.cellOccupied( this.indexes[x][next] )) { next++; } if (next>=4) { next--; } var currentValue = this.cellOccupied({x:x, y:current}) ? Math.log(this.cellContent( this.indexes[x][current] ).value) / Math.log(2) : 0; var nextValue = this.cellOccupied({x:x, y:next}) ? Math.log(this.cellContent( this.indexes[x][next] ).value) / Math.log(2) : 0; if (currentValue > nextValue) { totals[0] += nextValue - currentValue; } else if (nextValue > currentValue) { totals[1] += currentValue - nextValue; } current = next; next++; } } // left/right direction for (var y=0; y<4; y++) { var current = 0; var next = current+1; while ( next<4 ) { while ( next<4 && !this.cellOccupied( this.indexes[next][y] )) { next++; } if (next>=4) { next--; } var currentValue = this.cellOccupied({x:current, y:y}) ? Math.log(this.cellContent( this.indexes[current][y] ).value) / Math.log(2) : 0; var nextValue = this.cellOccupied({x:next, y:y}) ? Math.log(this.cellContent( this.indexes[next][y] ).value) / Math.log(2) : 0; if (currentValue > nextValue) { totals[2] += nextValue - currentValue; } else if (nextValue > currentValue) { totals[3] += currentValue - nextValue; } current = next; next++; } } return Math.max(totals[0], totals[1]) + Math.max(totals[2], totals[3]); } Grid.prototype.maxValue = function() { var max = 0; for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (this.cellOccupied(this.indexes[x][y])) { var value = this.cellContent(this.indexes[x][y]).value; if (value > max) { max = value; } } } } return Math.log(max) / Math.log(2); } // WIP. trying to favor top-heavy distributions (force consolidation of higher value tiles) /* Grid.prototype.valueSum = function() { var valueCount = []; for (var i=0; i<11; i++) { valueCount.push(0); } for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (this.cellOccupied(this.indexes[x][y])) { valueCount[Math.log(this.cellContent(this.indexes[x][y]).value) / Math.log(2)]++; } } } var sum = 0; for (var i=1; i<11; i++) { sum += valueCount[i] * Math.pow(2, i) + i; } return sum; } */ // check for win Grid.prototype.isWin = function() { var self = this; for (var x=0; x<4; x++) { for (var y=0; y<4; y++) { if (self.cellOccupied(this.indexes[x][y])) { if (self.cellContent(this.indexes[x][y]).value == 2048) { return true; } } } } return false; } //Grid.prototype.zobristTable = {} //for //Grid.prototype.hash = function() { //} 

2048.xlsm


La propia aplicación Excel se puede descargar de Google .

La funcionalidad de la aplicación se describe en un artículo anterior, donde AI juega usando el método Monte Carlo . La solución de hoy se ha agregado al Monte Carlo existente.

Todos los artículos de la serie AI y 2048.


  • Montecarlo
  • Recorte de minimax + alfa beta
  • Esperando el máximo
  • Red neuronal

Source: https://habr.com/ru/post/474680/


All Articles