1. Introduction
1.介绍
Recently, we looked at an algorithm for solving the game 2048. We discussed this from a theoretical point of view, and not with any real code behind it.
最近,我们研究了一个解决2048游戏的算法。我们从理论的角度讨论了这个问题,背后没有任何实际的代码。
Here we’re going to write an implementation of this in Java. This will play as both the human and computer players, showing how well a more optimal game can be played.
这里我们要用Java写一个实现。这将作为人类和计算机玩家进行游戏,展示一个更优化的游戏的效果。
2. Initial Setup
2.初始设置
The first thing we need is a setup in which we can play the game and see how progress is going.
我们首先需要的是一个设置,在这个设置中,我们可以玩游戏,看到进展如何。
This will give us all of the constructs that we need to play the game, and fully implement the computer player – which only places random tiles anyway. This then gives us the scope to implement a “human” player to play the game.
这将为我们提供玩游戏所需的所有结构,并完全实现计算机玩家–反正它只放置随机牌。这就给了我们实现 “人类 “玩家玩游戏的空间。
2.1. Game Board
2.1.游戏板
Before anything else, we need a game board. This is a grid of cells into which numbers can be placed.
在做其他事情之前,我们需要一个游戏板。这是一个由单元格组成的网格,可以把数字放进去。
To make some things a bit easier to work with, let’s begin with a simple representation of a cell location. This is literally just a wrapper around a pair of coordinates:
为了使一些事情更容易操作,让我们从细胞位置的简单表示开始。从字面上看,这只是一对坐标的一个包装。
public class Cell {
private final int x;
private final int y;
// constructor, getters, and toString
}
We can now write a class to represent the board itself. This is going to store the values in a simple two-dimensional array, but allow us to access them via the above Cell class:
我们现在可以写一个类来表示棋盘本身。这将在一个简单的二维数组中存储数值,但允许我们通过上述Cell类来访问它们。
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<Cell> emptyCells() {
List<Cell> 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;
}
}
This is an immutable class that represents a board and lets us interrogate it to find out the current state. It also keeps track of a current score, which we will come to later.
这是一个不可变的类,它代表了一个棋盘,并让我们询问它以找出当前的状态。它还记录了一个当前的分数,我们将在后面讨论。
2.2. A Computer Player and Placing Tiles
2.2.电脑玩家和放置瓷砖
Now that we’ve got a game board, we want to be able to play with it. The first thing we want is the computer player because this is a purely random player and will be exactly as needed later on.
现在我们已经有了一个游戏板,我们希望能够用它来玩。我们首先要的是电脑玩家,因为这是一个纯粹的随机玩家,以后将完全按照需要进行。
The computer player does nothing more than place a tile into a cell, so we need some way to achieve that on our board. We want to keep this as being immutable, so placing a tile will generate a brand new board in the new state.
电脑玩家所做的不过是将瓷砖放入一个单元格,所以我们需要一些方法来实现我们的棋盘。我们希望保持这一点是不可改变的,所以放置一块瓷砖会在新的状态下生成一个全新的棋盘。
First, we want a constructor that will take the actual board state, as opposed to our earlier one that just constructed a blank board:
首先,我们想要一个构造函数,它将接受实际的棋盘状态,而不是我们之前的那个只是构造一个空白的棋盘。
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);
}
}
This is private so that it can only ever be used by other methods within the same class. This helps with our encapsulation of the board.
这是private,所以它只能被同一个类中的其他方法使用。这有助于我们对电路板进行封装。
Next, we’ll add a method to place a tile. This returns a brand new board that is identical to the current one except that it has the given number in the given cell:
接下来,我们将添加一个放置瓷砖的方法。这将返回一个全新的棋盘,该棋盘与当前棋盘相同,只是在给定的单元格中有给定的数字。
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;
}
Finally, we’ll write a new class representing a computer player. This will have a single method that will take the current board and return the new one:
最后,我们将编写一个代表电脑玩家的新类。这将有一个单一的方法,它将接收当前的棋盘并返回新的棋盘。
public class Computer {
private final SecureRandom rng = new SecureRandom();
public Board makeMove(Board input) {
List<Cell> 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);
}
}
This gets the list of every empty cell from the board, picks a random one, and then puts a number in it. We’ll randomly decide to put a “4” into the cell 10% of the time, and a “2” the other 90%.
这是从棋盘上获取每一个空单元格的列表,随机挑选一个,然后在其中放入一个数字。我们将随机决定在10%的时间内放入一个 “4”,而在其他90%的时间内放入一个 “2”。
2.2. A “Human” Player and Shifting Tiles
2.2.一个 “人类 “棋手和移动的瓷砖
The next thing we need is a “human” player. This isn’t going to be the end goal, but a purely random player that picks a random direction to shift the tiles every time it makes a move. This will then act as a place that we can build upon to make our optimal player.
我们接下来需要的是一个 “人类 “玩家。这不会是最终目标,而是一个纯粹的随机玩家,每次移动时都会选择一个随机的方向来转移瓷砖。这将作为一个地方,我们可以在此基础上制作我们的最佳玩家。
Firstly, we need to define an enumeration of the possible moves that can be made:
首先,我们需要定义一个可能的动作的枚举。
public enum Move {
UP,
DOWN,
LEFT,
RIGHT
}
Next, we need to augment the Board class to support making moves by shifting tiles in one of these directions. To reduce the complexity here, we want to rotate the board such that we’re always shifting tiles in the same direction.
接下来,我们需要增强Board类,以支持通过向其中一个方向移动瓷砖来进行移动。为了减少这里的复杂性,我们想要旋转棋盘,以便我们总是向同一方向移动瓷砖。
This means that we need a means both to transpose and to reverse the board:
这意味着我们需要一种手段,既能转位,又能反转棋盘。
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;
}
Transposing the board will swap all rows and columns around, such that the top edge becomes the left edge. Reversing the board simply mirrors it such that the left edge becomes the right edge.
翻转棋盘会将所有的行和列对调,使顶边变成左边。反转棋盘只是简单的镜像,使左边缘变成右边缘。
Next, we add a method to the Board to make a move in a given direction, and return a new Board in the new state.
接下来,我们为Board添加一个方法,在给定的方向上进行移动,并返回新状态下的新Board。
We start by making a copy of the board state that we can then work with:
我们首先制作一个棋盘状态的副本,然后就可以进行工作。
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);
}
Next, we manipulate our copy so that we’re always going to be shifting tiles up:
接下来,我们操作我们的副本,使我们总是要将瓷砖向上移动。
if (move == Move.LEFT || move == Move.RIGHT) {
tiles = transpose(tiles);
}
if (move == Move.DOWN || move == Move.RIGHT) {
tiles = reverse(tiles);
}
We need yet another array of tiles – this time the one that we will build the final result into – and a tracker for the new score gained for this move:
我们需要另一个瓦片阵列–这次是我们将建立最终结果的瓦片阵列–和一个追踪器,用于追踪这步棋获得的新分数。
int[][] result = new int[tiles.length][];
int newScore = 0;
Now that we’re ready to start shifting tiles, and we’ve manipulated things so that we’re always working in the same direction, we can start.
现在我们已经准备好开始移动瓷砖,并且我们已经操纵了一些东西,使我们总是在同一个方向工作,我们可以开始了。
We can shift each column independently of the others. We just need to iterate over the columns and repeat, starting with building yet another copy of the tiles we are shifting.
我们可以独立于其他列进行移位。我们只需要在这些列上进行迭代和重复,从建立我们要移位的瓷砖的又一个副本开始。
This time we build them into a LinkedList because we will want to be able to pop values off of it easily. We also only add the actual tiles that have numbers and skip over empty tiles.
这一次,我们将它们建立在一个LinkedList中,因为我们希望能够很容易地从其中弹出值。我们也只添加有数字的实际瓷砖,而跳过空瓷砖。
This achieves our shifting but not yet the merging of tiles:
这实现了我们的移位,但还没有实现瓷砖的合并。
for (int x = 0; x < tiles.length; ++x) {
LinkedList<Integer> thisRow = new LinkedList<>();
for (int y = 0; y < tiles[0].length; ++y) {
if (tiles[x][y] > 0) {
thisRow.add(tiles[x][y]);
}
}
Next, we need to merge tiles. We need to do this separately from the above; otherwise, we risk merging the same tile multiple times.
接下来,我们需要合并瓦片。我们需要与上述工作分开进行;否则,我们有可能多次合并同一瓷砖。
This is achieved by building another LinkedList of the tiles from the above, but this time merging as we go:
这是通过从上面的瓷砖中建立另一个LinkedList来实现的,但这次是边走边合并。
LinkedList<Integer> 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:
我们现在可以将其纳入结果数组。一旦我们的列表中的瓷砖用完了,剩下的就会被填充为 “0”,表示它们是空白的。
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
2.3.玩游戏
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:
在这个例子中,我们只是要打印到控制台,所以System.out.print已经很好了。对于一个真正的游戏,我们会希望做更好的图形。
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("| ");
}
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.
在这一点上,如果我们运行该程序,我们会看到一个随机的2048游戏正在进行。
3. Implementing the 2048 Player
3.实施2048播放器
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
3.1.模拟移动
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.
我们在这里实现的算法是基于Expectimax算法的。因此,该算法的核心是模拟每一个可能的动作,为每一个动作分配一个分数,并选择一个表现最好的动作。
We’ll be making heavy use of Java 8 Streams to help structure this code, for reasons we’ll see later.
我们将大量使用Java 8 Streams来帮助构建这段代码,原因我们将在后面看到。
We’ll start by re-writing the makeMove() method from inside our Human class:
我们将从重写makeMove()方法开始,在我们的Human类中。
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.
对于每一个可能的移动方向,我们都会生成新的棋盘,然后开始计分算法–传入这个棋盘,深度为0,然后我们选择具有最佳分数的棋步。
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:
我们的generateScore()方法然后模拟每一个可能的计算机动作–即在每个空单元格中放置一个 “2 “或一个 “4”–然后看看接下来会发生什么。
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.
我们的calculateScore()方法是我们模拟的延续,运行方程中的人类行动。
This is very similar to the makeMove() method above, but we’re returning the ongoing score instead of the actual board:
这与上面的makeMove()方法非常相似,但我们要返回正在进行的分数,而不是实际的棋盘。
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
3.2.决赛板的评分
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<List<Integer>> rowsToScore = new ArrayList<>();
for (int i = 0; i < board.getSize(); ++i) {
List<Integer> row = new ArrayList<>();
List<Integer> 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:
最后,我们实际上需要生成我们的分数。这是在上面的lambda里面,是几个不同的因素,都有贡献。
- 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<Integer> 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 < preMerged.size() - 1; ++i) {
Integer first = preMerged.get(i);
Integer second = preMerged.get(i + 1);
if (first.equals(second)) {
++numMerges;
} else if (first > 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
4.对算法的改进
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.
到目前为止,我们所拥有的东西是有效的,我们可以看到它玩得很好,但它很慢。每个人的动作大约需要1分钟。我们可以做得比这更好。
4.1. Parallel Processing
4.1.并行处理
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.
我们可以做的显而易见的事情是并行工作。这是用Java流工作的一个巨大好处–我们只需在每个流中添加一条语句就可以使其并行工作。
This change alone gets us down to around 20 seconds per move.
仅仅这一变化就使我们的每一步棋下降到20秒左右。
4.2. Pruning Unplayable Branches
4.2.修剪无法播放的枝条
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:
要做到这一点,我们需要在我们的Board上实现一个equals方法,这样我们就可以比较它们。
@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))
........
This has minimal impact on the early parts of playing – when there are very few filled cells, there are very few moves that can be trimmed. However, later on, this starts to make a much bigger impact, reducing move times down to only a few seconds.
这对下棋的早期部分影响很小–当有很少的填充单元格时,可以修剪的棋步就很少。然而,到了后来,这就开始产生了更大的影响,将下棋时间缩短到只有几秒钟。
5. Summary
5.摘要
Here we built a framework for playing the game 2048. Then, we wrote a solver into this so that we can play a better game. All of the examples seen here can be found over on GitHub.
在这里,我们建立了一个用于玩2048游戏的框架。然后,我们在其中写了一个解算器,这样我们就可以玩一个更好的游戏。这里看到的所有例子都可以在GitHub上找到over。
Why not try varying the rules to see how they impact the gameplay.
为什么不试试改变规则,看看它们对游戏的影响。