Melaksanakan Penyelesaian 2048 di Jawa

1. Pengenalan

Baru-baru ini, kami melihat algoritma untuk menyelesaikan permainan 2048. Kami membincangkannya dari sudut teori, dan bukan dengan kod sebenar di belakangnya.

Di sini kita akan menulis implementasi ini di Java. Ini akan dimainkan sebagai pemain manusia dan komputer, menunjukkan seberapa baik permainan yang lebih optimum dapat dimainkan.

2. Persediaan Awal

Perkara pertama yang kita perlukan adalah persediaan di mana kita dapat bermain permainan dan melihat bagaimana kemajuannya.

Ini akan memberi kita semua konstruksi yang kita perlukan untuk bermain permainan, dan melaksanakan sepenuhnya pemain komputer - yang hanya meletakkan jubin rawak. Ini kemudian memberi kita ruang untuk menerapkan pemain "manusia" untuk bermain permainan.

2.1. Papan permainan

Sebelum perkara lain, kita memerlukan papan permainan. Ini adalah grid sel di mana nombor dapat ditempatkan.

Untuk membuat beberapa perkara menjadi lebih mudah untuk dikerjakan, mari kita mulakan dengan gambaran ringkas mengenai lokasi sel . Ini secara harfiah hanyalah pembungkus sepasang koordinat:

public class Cell { private final int x; private final int y; // constructor, getters, and toString }

Kita sekarang boleh menulis kelas untuk mewakili lembaga itu sendiri . Ini akan menyimpan nilai dalam susunan dua dimensi yang mudah, tetapi membolehkan kita mengaksesnya melalui kelas Sel di atas :

public class Board { private final int[][] board; private final int score; public Board(int size) { this.board = new int[size][]; this.score = 0; for (int x = 0; x < size; ++x) { this.board[x] = new int[size]; for (int y = 0; y < size; ++y) { board[x][y] = 0; } } } public int getSize() { return board.length; } public int getScore() { return score; } public int getCell(Cell cell) { return board[cell.getX()][cell.getY()]; } public boolean isEmpty(Cell cell) { return getCell(cell) == 0; } public List emptyCells() { List result = new ArrayList(); for (int x = 0; x < board.length; ++x) { for (int y = 0; y < board[x].length; ++y) { Cell cell = new Cell(x, y); if (isEmpty(cell)) { result.add(cell); } } } return result; } }

Ini adalah kelas yang tidak berubah yang mewakili dewan dan membolehkan kita menyoal siasatnya untuk mengetahui keadaan semasa. Ia juga mencatat skor semasa, yang akan kita sampaikan kemudian.

2.2. Pemain Komputer dan Meletakkan Jubin

Sekarang kita mempunyai papan permainan, kita mahu dapat bermain dengannya. Perkara pertama yang kami mahukan ialah pemain komputer kerana ini adalah pemain rawak semata-mata dan akan tepat seperti yang diperlukan di kemudian hari.

Pemain komputer tidak lebih daripada meletakkan jubin ke dalam sel, jadi kami memerlukan beberapa cara untuk mencapainya di papan kami. Kami ingin menjadikannya tidak berubah, jadi meletakkan jubin akan menghasilkan papan baru dalam keadaan baru.

Pertama, kita mahukan konstruktor yang akan mengambil keadaan papan sebenarnya , berbanding dengan papan yang sebelumnya yang baru membina papan kosong:

private Board(int[][] board, int score) { this.score = score; this.board = new int[board.length][]; for (int x = 0; x < board.length; ++x) { this.board[x] = Arrays.copyOf(board[x], board[x].length); } }

Ini bersifat peribadi sehingga hanya dapat digunakan dengan kaedah lain dalam kelas yang sama. Ini membantu dengan merangkumi papan kami.

Seterusnya, kami akan menambahkan kaedah untuk meletakkan jubin. Ini mengembalikan papan baru yang sama dengan yang sekarang kecuali bahawa ia mempunyai nombor yang diberikan dalam sel yang diberikan:

public Board placeTile(Cell cell, int number) { if (!isEmpty(cell)) { throw new IllegalArgumentException("That cell is not empty"); } Board result = new Board(this.board, this.score); result.board[cell.getX()][cell.getY()] = number; return result; }

Akhirnya, kami akan menulis kelas baru yang mewakili pemain komputer. Ini akan mempunyai satu kaedah yang akan menggunakan papan semasa dan mengembalikan kaedah baru:

public class Computer { private final SecureRandom rng = new SecureRandom(); public Board makeMove(Board input) { List emptyCells = input.emptyCells(); double numberToPlace = rng.nextDouble(); int indexToPlace = rng.nextInt(emptyCells.size()); Cell cellToPlace = emptyCells.get(indexToPlace); return input.placeTile(cellToPlace, numberToPlace >= 0.9 ? 4 : 2); } }

Ini mendapat senarai setiap sel kosong dari papan, memilih sel rawak, dan kemudian meletakkan nombor di dalamnya. Kami secara rawak akan memutuskan untuk memasukkan "4" ke dalam sel 10% sepanjang masa, dan "2" yang lain 90%.

2.2. Pemain "Manusia" dan Jubin Bergeser

Perkara seterusnya yang kita perlukan adalah pemain "manusia". Ini tidak akan menjadi tujuan akhir, tetapi pemain rawak semata-mata yang memilih arah rawak untuk mengalihkan jubin setiap kali bergerak. Ini kemudian akan bertindak sebagai tempat yang boleh kita bina untuk menjadikan pemain kita yang optimum.

Pertama, kita perlu menentukan penghitungan kemungkinan pergerakan yang boleh dibuat:

public enum Move { UP, DOWN, LEFT, RIGHT }

Seterusnya, kita perlu menambah kelas Dewan untuk menyokong membuat gerakan dengan mengalihkan jubin ke salah satu arah ini. Untuk mengurangkan kerumitan di sini, kami ingin memutar papan sehingga kami sentiasa mengalihkan jubin ke arah yang sama.

Ini bermaksud bahawa kita memerlukan kaedah untuk mengubah dan membalikkan papan:

private static int[][] transpose(int[][] input) { int[][] result = new int[input.length][]; for (int x = 0; x < input.length; ++x) { result[x] = new int[input[0].length]; for (int y = 0; y < input[0].length; ++y) { result[x][y] = input[y][x]; } } return result; } private static int[][] reverse(int[][] input) { int[][] result = new int[input.length][]; for (int x = 0; x < input.length; ++x) { result[x] = new int[input[0].length]; for (int y = 0; y < input[0].length; ++y) { result[x][y] = input[x][input.length - y - 1]; } } return result; }

Memindahkan papan akan menukar semua baris dan lajur di sekelilingnya, sehingga tepi atas menjadi tepi kiri. Membalikkan papan hanya mencerminkannya sehingga tepi kiri menjadi tepi kanan.

Seterusnya, kami menambahkan kaedah ke Dewan untuk membuat langkah ke arah tertentu, dan mengembalikan Dewan baru dalam keadaan baru.

Kita mulakan dengan membuat salinan papan kenyataan bahawa kita boleh bekerjasama dengan:

public Board move(Move move) { int newScore = 0; // Clone the board int[][] tiles = new int[this.board.length][]; for (int x = 0; x < this.board.length; ++x) { tiles[x] = Arrays.copyOf(this.board[x], this.board[x].length); }

Seterusnya, kami memanipulasi salinan kami sehingga kami akan sentiasa menukar jubin:

if (move == Move.LEFT || move == Move.RIGHT) { tiles = transpose(tiles); } if (move == Move.DOWN || move == Move.RIGHT) { tiles = reverse(tiles); }

Kami memerlukan satu lagi jubin - kali ini yang akan kami hasilkan - dan pelacak untuk skor baru yang diperoleh untuk langkah ini:

int[][] result = new int[tiles.length][]; int newScore = 0;

Sekarang kita sudah bersedia untuk memulakan pergeseran jubin, dan kita telah memanipulasi perkara-perkara sehingga kita selalu bekerja ke arah yang sama, kita dapat mulai.

Kita boleh mengalihkan setiap lajur secara bebas daripada yang lain. Kita hanya perlu melakukan lelang pada lajur dan mengulangi, bermula dengan membina satu lagi salinan jubin yang sedang kita ubah.

Kali ini kami membinanya menjadi LinkedList kerana kami ingin dapat mengeluarkan nilai dengan mudah. Kami juga hanya menambahkan jubin sebenar yang mempunyai nombor dan melangkau jubin kosong.

Ini mencapai pergeseran kami tetapi belum penggabungan jubin:

for (int x = 0; x < tiles.length; ++x) { LinkedList thisRow = new LinkedList(); for (int y = 0; y  0) { thisRow.add(tiles[x][y]); } }

Seterusnya, kita perlu menggabungkan jubin. Kita perlu melakukan ini secara berasingan dari perkara di atas; jika tidak, kita berisiko menggabungkan jubin yang sama beberapa kali.

Ini dicapai dengan membina lain-lain LinkedList jubin dari atas, tetapi kali ini bergabung semasa kita pergi:

LinkedList newRow = new LinkedList(); while (thisRow.size() >= 2) { int first = thisRow.pop(); int second = thisRow.peek(); if (second == first) { int newNumber = first * 2; newRow.add(newNumber); newScore += newNumber; thisRow.pop(); } else { newRow.add(first); } } newRow.addAll(thisRow);

Here we're also calculating the new score for this move. This is the sum of the tiles created as a result of merges.

We can now build this into the result array. Once we've run out of tiles from our list, the rest get populated with the value “0” to indicate that they are blank:

 result[x] = new int[tiles[0].length]; for (int y = 0; y < tiles[0].length; ++y) { if (newRow.isEmpty()) { result[x][y] = 0; } else { result[x][y] = newRow.pop(); } } }

Once we've finished shifting tiles, we need to manipulate them again back to the correct rotation. This is the exact opposite that we did earlier:

if (move == Move.DOWN || move == Move.RIGHT) { result = reverse(result); } if (move == Move.LEFT || move == Move.RIGHT) { result = transpose(result); }

And finally, we can build and return a new board with this new set of tiles and the newly calculated score:

 return new Board(result, this.score + newScore); }

We're now in a position where we can write our random “human” player. This does nothing more than generate a random move and call the above method to play that move:

public class Human { private SecureRandom rng = new SecureRandom(); public Board makeMove(Board input) { Move move = Move.values()[rng.nextInt(4)]; return input.move(move); } }

2.3. Playing the Game

We have enough components to play the game, albeit not very successfully. However, soon we will be improving the way that the Human class plays, and this will allow us to see the differences easily.

First, we need a way to print out the game board.

For this example, we're just going to print to the console, so System.out.print is good enough. For a real game we would want to do better graphics:

private static void printBoard(Board board) { StringBuilder topLines = new StringBuilder(); StringBuilder midLines = new StringBuilder(); for (int x = 0; x < board.getSize(); ++x)  ");  topLines.append("+"); midLines.append("|"); for (int y = 0; y < board.getSize(); ++y) { System.out.println(topLines); System.out.println(midLines); for (int x = 0; x < board.getSize(); ++x) { Cell cell = new Cell(x, y); System.out.print("|"); if (board.isEmpty(cell)) { System.out.print(" "); } else { StringBuilder output = new StringBuilder(Integer.toString(board.getCell(cell))); while (output.length() < 8) { output.append(" "); if (output.length() < 8) { output.insert(0, " "); } } System.out.print(output); } } System.out.println("|"); System.out.println(midLines); } System.out.println(topLines); System.out.println("Score: " + board.getScore()); }

We're nearly ready to go. We just need to set things up.

This means creating the board, the two players, and having the computer make two initial moves – that is, placing two random numbers on the board:

Board board = new Board(4); Computer computer = new Computer(); Human human = new Human(); for (int i = 0; i < 2; ++i) { board = computer.makeMove(board); }

And now we have the actual game loop. This is going to be a repetition of the human and computer players taking turns, and stopping only when there are no empty cells left:

printBoard(board); do { System.out.println("Human move"); System.out.println("=========="); board = human.makeMove(board); printBoard(board); System.out.println("Computer move"); System.out.println("============="); board = computer.makeMove(board); printBoard(board); } while (!board.emptyCells().isEmpty()); System.out.println("Final Score: " + board.getScore());

At this point, if we were to run the program, we would see a random game of 2048 being played.

3. Implementing the 2048 Player

Once we have a base from which to play the game, we can start implementing the “human” player and play a better game than just picking a random direction.

3.1. Simulating Moves

The algorithm we are implementing here is based on the Expectimax algorithm. As such, the core of the algorithm is to simulate every possible move, allocate a score to each one, and select the one that does best.

We'll be making heavy use of Java 8 Streams to help structure this code, for reasons we'll see later.

We'll start by re-writing the makeMove() method from inside our Human class:

public Board makeMove(Board input) { return Arrays.stream(Move.values()) .map(input::move) .max(Comparator.comparingInt(board -> generateScore(board, 0))) .orElse(input); }

For every possible direction we can move in, we generate the new board and then start the scoring algorithm – passing in this board and a depth of 0. We then select the move that has the best score.

Our generateScore() method then simulates every possible computer move – that is, placing either a “2” or a “4” into every empty cell – and then sees what could happen next:

private int generateScore(Board board, int depth) { if (depth >= 3) { return calculateFinalScore(board); } return board.emptyCells().stream() .flatMap(cell -> Stream.of(new Pair(cell, 2), new Pair(cell, 4))) .mapToInt(move -> { Board newBoard = board.placeTile(move.getFirst(), move.getSecond()); int boardScore = calculateScore(newBoard, depth + 1); return (int) (boardScore * (move.getSecond() == 2 ? 0.9 : 0.1)); }) .sum(); }

If we have reached our depth limit, then we'll immediately stop and calculate a final score for how good this board is; otherwise, we continue with our simulation.

Our calculateScore() method is then the continuation of our simulation, running the human move side of the equation.

This is very similar to the makeMove() method above, but we're returning the ongoing score instead of the actual board:

private int calculateScore(Board board, int depth) { return Arrays.stream(Move.values()) .map(board::move) .mapToInt(newBoard -> generateScore(newBoard, depth)) .max() .orElse(0); }

3.2. Scoring Final Boards

We're now in a situation where we can simulate moves back and forth by the human and computer players, stopping when we've simulated enough of them. We need to be able to generate a score for the final board in each simulation branch, so that we can see which branch is the one we want to pursue.

Our scoring is a combination of factors, each of which we are going to apply to every row and every column on the board. These all get summed together, and the total is returned.

As such, we need to generate a list of rows and columns to score against:

List
    
      rowsToScore = new ArrayList(); for (int i = 0; i < board.getSize(); ++i) { List row = new ArrayList(); List col = new ArrayList(); for (int j = 0; j < board.getSize(); ++j) { row.add(board.getCell(new Cell(i, j))); col.add(board.getCell(new Cell(j, i))); } rowsToScore.add(row); rowsToScore.add(col); }
    

Then we take the list that we've built, score each of them, and sum the scores together. This is a placeholder that we're about to fill out:

return rowsToScore.stream() .mapToInt(row -> { int score = 0; return score; }) .sum();

Finally, we need actually to generate our scores. This goes inside the above lambda, and is several different factors that all contribute:

  • A fixed score for every row
  • The sum of every number in the row
  • Every merge possible in the row
  • Every empty cell in the row
  • The monotonicity of the row. This represents the amount the row is organized in ascending numerical order.

Before we can calculate the scores, we need to build some extra data.

First, we want a list of the numbers with blank cells removed:

List preMerged = row.stream() .filter(value -> value != 0) .collect(Collectors.toList());

We can then make some counts from this new list, giving the number of adjacent cells with the same number, with strictly ascending numbers and strictly descending numbers:

int numMerges = 0; int monotonicityLeft = 0; int monotonicityRight = 0; for (int i = 0; i  second) { monotonicityLeft += first - second; } else { monotonicityRight += second - first; } }

Now we can calculate our score for this row:

int score = 1000; score += 250 * row.stream().filter(value -> value == 0).count(); score += 750 * numMerges; score -= 10 * row.stream().mapToInt(value -> value).sum(); score -= 50 * Math.min(monotonicityLeft, monotonicityRight); return score;

The numbers selected here are relatively arbitrary. Different numbers will have an impact on how well the game plays, prioritizing different factors in how we play.

4. Improvements to the Algorithm

What we have so far works, and we can see that it plays a good game, but it's slow. It takes around 1 minute per human move. We can do better than this.

4.1. Parallel Processing

The obvious thing that we can do is to do work in parallel. This is a huge benefit of working with Java Streams – we can make this work in parallel by just adding a single statement to each stream.

This change alone gets us down to around 20 seconds per move.

4.2. Pruning Unplayable Branches

The next thing we can do is to prune out any branches that are unplayable. That is, any time that a human move results in an unchanged board. These are almost certainly branches that are going to result in worse outcomes – they are effectively giving the computer a free move – but they cost us processing time to pursue them.

To do this, we need to implement an equals method on our Board so that we can compare them:

@Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } Board board1 = (Board) o; return Arrays.deepEquals(board, board1.board); }

We can then add some filters to our stream pipelines to stop processing anything that hasn't changed.

return Arrays.stream(Move.values()) .parallel() .map(board::move) .filter(moved -> !moved.equals(board)) ........

Ini mempunyai kesan minimum pada bahagian awal permainan - apabila terdapat sedikit sel yang diisi, sangat sedikit pergerakan yang dapat dipangkas. Namun, kemudian, ini mula memberi kesan yang jauh lebih besar, mengurangkan masa bergerak menjadi hanya beberapa saat.

5. Ringkasan

Di sini kami membina kerangka untuk bermain permainan 2048. Kemudian, kami menulis pemecah masalah ini agar kami dapat memainkan permainan yang lebih baik. Semua contoh yang dilihat di sini boleh didapati di GitHub.

Mengapa tidak mencuba mengubah peraturan untuk melihat bagaimana pengaruhnya mempengaruhi permainan.