1. 简介

之前我们探讨过一篇关于 2048 游戏求解算法 的文章,但那篇更多是从理论角度分析,没有给出实际代码实现。

本文我们将用 Java 实现一个完整的 2048 求解器。这个求解器会同时模拟“人类”和“计算机”两个角色的走法,展示如何通过算法实现更优的游戏策略。最终目标是让程序自己玩出高分,而不是靠手速或运气。

✅ 重点:这不是一个图形界面游戏,而是一个基于控制台的 AI 求解框架。
⚠️ 踩坑提示:别一上来就想搞 GUI,先把核心逻辑跑通才是正道。


2. 初始框架搭建

要实现求解器,首先得有个能跑起来的游戏环境。我们需要构建游戏板、计算机玩家、人类玩家的基本结构,确保游戏能正常进行。

2.1. 游戏板(Board)设计

游戏的核心是 4x4 的网格。我们先定义一个 Cell 类来表示坐标位置:

public class Cell {
    private final int x;
    private final int y;

    public Cell(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() { return x; }
    public int getY() { return y; }

    @Override
    public String toString() {
        return "(" + x + "," + y + ")";
    }
}

接着是 Board 类,用于表示当前游戏状态。它是一个不可变对象(immutable),每次操作都返回新实例,便于后续算法回溯。

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;
    }
}

📌 说明:

  • 使用二维数组存储格子值,0 表示空。
  • emptyCells() 返回所有空格位置,供后续随机放置新数字使用。
  • 不可变设计 ✅:每次操作生成新 Board,避免状态污染。

2.2. 计算机玩家与放置方块

计算机玩家的任务很简单:在空格中随机放一个 2 或 4(90% 概率为 2)。

为了支持不可变性,我们添加一个私有构造函数用于复制状态:

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);
    }
}

然后实现 placeTile 方法:

public Board placeTile(Cell cell, int number) {
    if (!isEmpty(cell)) {
        throw new IllegalArgumentException("该格子非空");
    }

    Board result = new Board(this.board, this.score);
    result.board[cell.getX()][cell.getY()] = number;
    return result;
}

最后是 Computer 类:

public class Computer {
    private final SecureRandom rng = new SecureRandom();

    public Board makeMove(Board input) {
        List<Cell> emptyCells = input.emptyCells();
        if (emptyCells.isEmpty()) return input;

        double numberToPlace = rng.nextDouble();
        int indexToPlace = rng.nextInt(emptyCells.size());
        Cell cellToPlace = emptyCells.get(indexToPlace);

        return input.placeTile(cellToPlace, numberToPlace >= 0.9 ? 4 : 2);
    }
}

📌 要点:

  • 使用 SecureRandom 更安全(虽然这里无所谓)。
  • 放置失败直接抛异常,防止逻辑错乱。

2.3. 人类玩家与滑动逻辑

这里的“人类”玩家初期只是一个随机走法的占位符,后续会被 AI 替代。

先定义方向枚举:

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

为了让滑动逻辑统一处理,我们引入两个辅助方法:转置(transpose)和翻转(reverse):

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;
}

核心 move 方法如下:

public Board move(Move move) {
    int newScore = 0;
    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);
    }

    // 统一转换为向上滑动
    if (move == Move.LEFT || move == Move.RIGHT) {
        tiles = transpose(tiles);
    }
    if (move == Move.DOWN || move == Move.RIGHT) {
        tiles = reverse(tiles);
    }

    int[][] result = new int[tiles.length][];
    
    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]);
            }
        }

        // 合并逻辑
        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);

        // 填充结果
        result[x] = new int[tiles[0].length];
        for (int y = 0; y < tiles[0].length; ++y) {
            result[x][y] = newRow.isEmpty() ? 0 : newRow.pop();
        }
    }

    // 恢复原始方向
    if (move == Move.DOWN || move == Move.RIGHT) {
        result = reverse(result);
    }
    if (move == Move.LEFT || move == Move.RIGHT) {
        result = transpose(result);
    }

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

📌 滑动合并要点:

  • 先压缩非零元素 ✅
  • 再合并相邻相同数字 ✅
  • 每次合并只触发一次 ❌(避免 2 2 2 2 变成 8
  • 分数累加正确 ✅

最后是随机“人类”玩家:

public class Human {
    private final SecureRandom rng = new SecureRandom();

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

2.4. 运行游戏循环

加个打印方法方便调试:

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());
}

主循环:

Board board = new Board(4);
Computer computer = new Computer();
Human human = new Human();

// 初始放置两个数字
for (int i = 0; i < 2; ++i) {
    board = computer.makeMove(board);
}

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());

运行后可以看到一个随机玩家的游戏过程。


3. 实现智能求解器

现在我们把 Human 类升级为 AI 玩家,使用 Expectimax 算法(类似 Minimax,但考虑概率分支)。

3.1. 模拟走法与评分机制

核心思想:尝试每个方向,模拟后续几步,选择期望得分最高的路径。

重写 makeMove

public Board makeMove(Board input) {
    return Arrays.stream(Move.values())
        .parallel()
        .map(input::move)
        .filter(moved -> !moved.equals(input)) // 剪枝:跳过无效移动
        .max(Comparator.comparingInt(board -> generateScore(board, 0)))
        .orElse(input);
}

递归生成评分:

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();
}

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

📌 关键点:

  • depth >= 3 时停止递归,调用最终评分。
  • 使用 flatMap 模拟计算机放置 2 或 4。
  • 权重:2 占 90%,4 占 10%。

3.2. 终局评分策略

对最终状态进行打分,综合多个因素:

private int calculateFinalScore(Board board) {
    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);
    }

    return rowsToScore.stream()
        .mapToInt(row -> {
            List<Integer> preMerged = row.stream()
                .filter(value -> value != 0)
                .collect(Collectors.toList());

            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;
                }
            }

            int score = 1000;
            score += 250 * row.stream().filter(value -> value == 0).count(); // 空格奖励
            score += 750 * numMerges; // 合并奖励
            score -= 10 * row.stream().mapToInt(Integer::intValue).sum(); // 数值惩罚(鼓励小数)
            score -= 50 * Math.min(monotonicityLeft, monotonicityRight); // 单调性惩罚

            return score;
        })
        .sum();
}

📌 评分因子说明:

  • ✅ 空格越多越好(保留操作空间)
  • ✅ 合并机会多加分
  • ✅ 行列单调有序加分(如 2-4-8-16)
  • ❌ 大数过多扣分(防过早合并)

⚠️ 参数可调!不同权重组合会影响 AI 风格(激进 or 保守)


4. 性能优化

原始版本每步耗时约 1 分钟,太慢了。我们来优化。

4.1. 并行流加速

利用 Java 8 Stream 的 .parallel()

Arrays.stream(Move.values()).parallel()

✅ 效果:从 60s → 20s 左右。

4.2. 剪枝无效分支

添加 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);
}

然后在流中过滤:

.filter(moved -> !moved.equals(board))

✅ 效果:后期节省大量无效计算,最终耗时降至 3~5 秒/步


5. 总结

我们实现了一个基于 Expectimax 算法的 2048 求解器,包含:

  • 完整游戏逻辑 ✅
  • AI 决策框架 ✅
  • 多因子评分系统 ✅
  • 并行与剪枝优化 ✅

🎯 后续可尝试:

  • 调整评分权重,观察 AI 行为变化
  • 增加搜索深度(需进一步优化性能)
  • 引入启发式规则(如最大数靠角)

源码地址:github.com/dev-2048/solver
作者邮箱:contact@baeldung.com


原始标题:Implementing a 2048 Solver in Java | Baeldung