ZetCode

Java 扫雷

最后修改于 2023 年 1 月 10 日

在 Java 2D 游戏教程的这一部分,我们将创建一个扫雷游戏克隆。源代码和图片可以在作者的 Github Java-Minesweeper-Game 仓库中找到。

扫雷

扫雷是一款流行的棋盘游戏,许多操作系统都默认附带。游戏的目标是从雷区扫除所有地雷。如果玩家点击了包含地雷的单元格,地雷就会爆炸,游戏结束。

一个单元格可以包含一个数字,或者是一个空白单元格。数字表示该单元格周围有多少个地雷。我们通过右键点击来标记一个单元格。这样我们就表明我们认为那里有一枚地雷。

Java 扫雷游戏开发

该游戏由两个类组成:BoardMinesweeper。我们在 src/resources 目录中有十三张图片。

com/zetcode/Board.java
package com.zetcode;

import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.Random;
import javax.swing.ImageIcon;
import javax.swing.JLabel;
import javax.swing.JPanel;

public class Board extends JPanel {

    private final int NUM_IMAGES = 13;
    private final int CELL_SIZE = 15;

    private final int COVER_FOR_CELL = 10;
    private final int MARK_FOR_CELL = 10;
    private final int EMPTY_CELL = 0;
    private final int MINE_CELL = 9;
    private final int COVERED_MINE_CELL = MINE_CELL + COVER_FOR_CELL;
    private final int MARKED_MINE_CELL = COVERED_MINE_CELL + MARK_FOR_CELL;

    private final int DRAW_MINE = 9;
    private final int DRAW_COVER = 10;
    private final int DRAW_MARK = 11;
    private final int DRAW_WRONG_MARK = 12;

    private final int N_MINES = 40;
    private final int N_ROWS = 16;
    private final int N_COLS = 16;

    private final int BOARD_WIDTH = N_COLS * CELL_SIZE + 1;
    private final int BOARD_HEIGHT = N_ROWS * CELL_SIZE + 1;

    private int[] field;
    private boolean inGame;
    private int minesLeft;
    private Image[] img;

    private int allCells;
    private final JLabel statusbar;

    public Board(JLabel statusbar) {

        this.statusbar = statusbar;
        initBoard();
    }

    private void initBoard() {

        setPreferredSize(new Dimension(BOARD_WIDTH, BOARD_HEIGHT));

        img = new Image[NUM_IMAGES];

        for (int i = 0; i < NUM_IMAGES; i++) {

            var path = "src/resources/" + i + ".png";
            img[i] = (new ImageIcon(path)).getImage();
        }

        addMouseListener(new MinesAdapter());
        newGame();
    }

    private void newGame() {

        int cell;

        var random = new Random();
        inGame = true;
        minesLeft = N_MINES;

        allCells = N_ROWS * N_COLS;
        field = new int[allCells];

        for (int i = 0; i < allCells; i++) {

            field[i] = COVER_FOR_CELL;
        }

        statusbar.setText(Integer.toString(minesLeft));

        int i = 0;

        while (i < N_MINES) {

            int position = (int) (allCells * random.nextDouble());

            if ((position < allCells)
                    && (field[position] != COVERED_MINE_CELL)) {

                int current_col = position % N_COLS;
                field[position] = COVERED_MINE_CELL;
                i++;

                if (current_col > 0) {
                    cell = position - 1 - N_COLS;
                    if (cell >= 0) {
                        if (field[cell] != COVERED_MINE_CELL) {
                            field[cell] += 1;
                        }
                    }
                    cell = position - 1;
                    if (cell >= 0) {
                        if (field[cell] != COVERED_MINE_CELL) {
                            field[cell] += 1;
                        }
                    }

                    cell = position + N_COLS - 1;
                    if (cell < allCells) {
                        if (field[cell] != COVERED_MINE_CELL) {
                            field[cell] += 1;
                        }
                    }
                }

                cell = position - N_COLS;
                if (cell >= 0) {
                    if (field[cell] != COVERED_MINE_CELL) {
                        field[cell] += 1;
                    }
                }

                cell = position + N_COLS;
                if (cell < allCells) {
                    if (field[cell] != COVERED_MINE_CELL) {
                        field[cell] += 1;
                    }
                }

                if (current_col < (N_COLS - 1)) {
                    cell = position - N_COLS + 1;
                    if (cell >= 0) {
                        if (field[cell] != COVERED_MINE_CELL) {
                            field[cell] += 1;
                        }
                    }
                    cell = position + N_COLS + 1;
                    if (cell < allCells) {
                        if (field[cell] != COVERED_MINE_CELL) {
                            field[cell] += 1;
                        }
                    }
                    cell = position + 1;
                    if (cell < allCells) {
                        if (field[cell] != COVERED_MINE_CELL) {
                            field[cell] += 1;
                        }
                    }
                }
            }
        }
    }

    private void find_empty_cells(int j) {

        int current_col = j % N_COLS;
        int cell;

        if (current_col > 0) {
            cell = j - N_COLS - 1;
            if (cell >= 0) {
                if (field[cell] > MINE_CELL) {
                    field[cell] -= COVER_FOR_CELL;
                    if (field[cell] == EMPTY_CELL) {
                        find_empty_cells(cell);
                    }
                }
            }

            cell = j - 1;
            if (cell >= 0) {
                if (field[cell] > MINE_CELL) {
                    field[cell] -= COVER_FOR_CELL;
                    if (field[cell] == EMPTY_CELL) {
                        find_empty_cells(cell);
                    }
                }
            }

            cell = j + N_COLS - 1;
            if (cell < allCells) {
                if (field[cell] > MINE_CELL) {
                    field[cell] -= COVER_FOR_CELL;
                    if (field[cell] == EMPTY_CELL) {
                        find_empty_cells(cell);
                    }
                }
            }
        }

        cell = j - N_COLS;
        if (cell >= 0) {
            if (field[cell] > MINE_CELL) {
                field[cell] -= COVER_FOR_CELL;
                if (field[cell] == EMPTY_CELL) {
                    find_empty_cells(cell);
                }
            }
        }

        cell = j + N_COLS;
        if (cell < allCells) {
            if (field[cell] > MINE_CELL) {
                field[cell] -= COVER_FOR_CELL;
                if (field[cell] == EMPTY_CELL) {
                    find_empty_cells(cell);
                }
            }
        }

        if (current_col < (N_COLS - 1)) {
            cell = j - N_COLS + 1;
            if (cell >= 0) {
                if (field[cell] > MINE_CELL) {
                    field[cell] -= COVER_FOR_CELL;
                    if (field[cell] == EMPTY_CELL) {
                        find_empty_cells(cell);
                    }
                }
            }

            cell = j + N_COLS + 1;
            if (cell < allCells) {
                if (field[cell] > MINE_CELL) {
                    field[cell] -= COVER_FOR_CELL;
                    if (field[cell] == EMPTY_CELL) {
                        find_empty_cells(cell);
                    }
                }
            }

            cell = j + 1;
            if (cell < allCells) {
                if (field[cell] > MINE_CELL) {
                    field[cell] -= COVER_FOR_CELL;
                    if (field[cell] == EMPTY_CELL) {
                        find_empty_cells(cell);
                    }
                }
            }
        }

    }

    @Override
    public void paintComponent(Graphics g) {

        int uncover = 0;

        for (int i = 0; i < N_ROWS; i++) {

            for (int j = 0; j < N_COLS; j++) {

                int cell = field[(i * N_COLS) + j];

                if (inGame && cell == MINE_CELL) {

                    inGame = false;
                }

                if (!inGame) {

                    if (cell == COVERED_MINE_CELL) {
                        cell = DRAW_MINE;
                    } else if (cell == MARKED_MINE_CELL) {
                        cell = DRAW_MARK;
                    } else if (cell > COVERED_MINE_CELL) {
                        cell = DRAW_WRONG_MARK;
                    } else if (cell > MINE_CELL) {
                        cell = DRAW_COVER;
                    }

                } else {

                    if (cell > COVERED_MINE_CELL) {
                        cell = DRAW_MARK;
                    } else if (cell > MINE_CELL) {
                        cell = DRAW_COVER;
                        uncover++;
                    }
                }

                g.drawImage(img[cell], (j * CELL_SIZE),
                        (i * CELL_SIZE), this);
            }
        }

        if (uncover == 0 && inGame) {

            inGame = false;
            statusbar.setText("Game won");

        } else if (!inGame) {

            statusbar.setText("Game lost");
        }
    }

    private class MinesAdapter extends MouseAdapter {

        @Override
        public void mousePressed(MouseEvent e) {

            int x = e.getX();
            int y = e.getY();

            int cCol = x / CELL_SIZE;
            int cRow = y / CELL_SIZE;

            boolean doRepaint = false;

            if (!inGame) {

                newGame();
                repaint();
            }

            if ((x < N_COLS * CELL_SIZE) && (y < N_ROWS * CELL_SIZE)) {

                if (e.getButton() == MouseEvent.BUTTON3) {

                    if (field[(cRow * N_COLS) + cCol] > MINE_CELL) {

                        doRepaint = true;

                        if (field[(cRow * N_COLS) + cCol] <= COVERED_MINE_CELL) {

                            if (minesLeft > 0) {
                                field[(cRow * N_COLS) + cCol] += MARK_FOR_CELL;
                                minesLeft--;
                                String msg = Integer.toString(minesLeft);
                                statusbar.setText(msg);
                            } else {
                                statusbar.setText("No marks left");
                            }
                        } else {

                            field[(cRow * N_COLS) + cCol] -= MARK_FOR_CELL;
                            minesLeft++;
                            String msg = Integer.toString(minesLeft);
                            statusbar.setText(msg);
                        }
                    }

                } else {

                    if (field[(cRow * N_COLS) + cCol] > COVERED_MINE_CELL) {

                        return;
                    }

                    if ((field[(cRow * N_COLS) + cCol] > MINE_CELL)
                            && (field[(cRow * N_COLS) + cCol] < MARKED_MINE_CELL)) {

                        field[(cRow * N_COLS) + cCol] -= COVER_FOR_CELL;
                        doRepaint = true;

                        if (field[(cRow * N_COLS) + cCol] == MINE_CELL) {
                            inGame = false;
                        }

                        if (field[(cRow * N_COLS) + cCol] == EMPTY_CELL) {
                            find_empty_cells((cRow * N_COLS) + cCol);
                        }
                    }
                }

                if (doRepaint) {
                    repaint();
                }
            }
        }
    }
}

首先,我们定义游戏中使用的常量。

private final int NUM_IMAGES = 13;
private final int CELL_SIZE = 15;

游戏中使用了十三张图片。一个单元格最多可以被八个地雷包围,所以我们需要数字一到八。我们需要一张空白单元格、地雷、被覆盖的单元格、被标记的单元格以及最后一张错误标记的单元格的图片。每张图片的尺寸为 15x15 像素。

private final int COVER_FOR_CELL = 10;
private final int MARK_FOR_CELL = 10;
private final int EMPTY_CELL = 0;
...

雷区是一个数字数组。例如,0 表示一个空白单元格。数字 10 用于覆盖单元格和标记。使用常量可以提高代码的可读性。

private final int MINE_CELL = 9;

MINE_CELL 代表一个包含地雷的单元格。

private final int COVERED_MINE_CELL = MINE_CELL + COVER_FOR_CELL;
private final int MARKED_MINE_CELL = COVERED_MINE_CELL + MARK_FOR_CELL;

COVERED_MINE_CELL 用于被覆盖且包含地雷的区域。MARKED_MINE_CELL 是用户标记的被覆盖的地雷单元格。

private final int DRAW_MINE = 9;
private final int DRAW_COVER = 10;
private final int DRAW_MARK = 11;
private final int DRAW_WRONG_MARK = 12;

这些常量决定了是否绘制地雷、地雷覆盖、标记和错误标记的单元格。

private final int N_MINES = 40;
private final int N_ROWS = 16;
private final int N_COLS = 16;

我们的游戏雷区有四十颗隐藏的地雷。雷区有十六行十六列。所以雷区总共有二百二十六个单元格。

private int[] field;

雷区是一个数字数组。雷区中的每个单元格都有一个特定的数字。例如,地雷单元格的数字是 9。数字 2 表示它相邻有两个地雷。数字是累加的。例如,被覆盖的地雷数字是 19,9 代表地雷,10 代表单元格覆盖,以此类推。

private boolean inGame;

inGame 变量决定了我们是否还在游戏中,或者游戏已经结束。

private int minesLeft;

minesLeft 变量表示还剩多少地雷需要标记。

for (int i = 0; i < NUM_IMAGES; i++) {

    var path = "src/resources/" + i + ".png";
    img[i] = (new ImageIcon(path)).getImage();
}

我们将图片加载到图片数组中。图片的名称是 0.png, 1.png ... 12.png。

newGame() 初始化扫雷游戏。

allCells = N_ROWS * N_COLS;
field = new int[allCells];

for (int i = 0; i < allCells; i++) {

    field[i] = COVER_FOR_CELL;
}

这些行设置了雷区。默认情况下,每个单元格都被覆盖。

int i = 0;

while (i < N_MINES) {

    int position = (int) (allCells * random.nextDouble());

    if ((position < allCells)
            && (field[position] != COVERED_MINE_CELL)) {

        int current_col = position % N_COLS;
        field[position] = COVERED_MINE_CELL;
        i++;
...

在 while 循环中,我们随机放置雷区中的所有地雷。

cell = position - N_COLS;

if (cell >= 0) {
    if (field[cell] != COVERED_MINE_CELL) {
        field[cell] += 1;
    }
}

每个单元格最多可以被八个单元格包围。(这不适用于边界单元格。)我们为每个随机放置的地雷增加相邻单元格的数字。在我们的例子中,我们将数字 1 加到当前单元格的上邻居。

find_empty_cells() 方法中,我们寻找空白单元格。如果玩家点击了地雷单元格,游戏就结束了。如果他点击了相邻地雷的单元格,他会揭开一个数字,指示该单元格有多少个相邻地雷。点击空白单元格会导致揭开许多其他空白单元格以及形成空白边界的数字单元格。我们使用递归算法来查找空白单元格。

cell = j - 1;
if (cell <= 0) {
    if (field[cell] > MINE_CELL) {
        field[cell] -= COVER_FOR_CELL;
        if (field[cell] == EMPTY_CELL) {
            find_empty_cells(cell);
        }
    }
}

在这段代码中,我们检查位于待定空白单元格左侧的单元格。如果它不为空,则将其揭开。如果它为空,我们通过递归调用 find_empty_cells() 方法来重复整个过程。

paintComponent() 方法将数字转换为图片。

if (!inGame) {
    if (cell == COVERED_MINE_CELL) {
        cell = DRAW_MINE;
    } else if (cell == MARKED_MINE_CELL) {
        cell = DRAW_MARK;
    } else if (cell > COVERED_MINE_CELL) {
        cell = DRAW_WRONG_MARK;
    } else if (cell > MINE_CELL) {
        cell = DRAW_COVER;
    }
} ...

如果游戏结束并且我们输了,我们会显示所有未揭开的地雷(如果有)以及所有错误标记的单元格(如果有)。

g.drawImage(img[cell], (j * CELL_SIZE),
    (i * CELL_SIZE), this);

这行代码将在窗口上绘制每个单元格。

if (uncover == 0 && inGame) {

    inGame = false;
    statusbar.setText("Game won");
} else if (!inGame) {

    statusbar.setText("Game lost");
}

如果没有剩余的单元格可以揭开,我们就赢了。如果 inGame 变量被设置为 false,我们就输了。

mousePressed() 方法中,我们响应鼠标点击。扫雷游戏完全由鼠标控制。我们响应鼠标左键和右键点击。

int x = e.getX();
int y = e.getY();

我们确定鼠标指针的 xy 坐标。

int cCol = x / CELL_SIZE;
int cRow = y / CELL_SIZE;

我们计算雷区对应的列和行。

if ((x < N_COLS * CELL_SIZE) && (y < N_ROWS * CELL_SIZE)) {

我们检查我们是否位于雷区区域内。

if (e.getButton() == MouseEvent.BUTTON3) {

地雷的揭开是用鼠标右键完成的。

field[(cRow * N_COLS) + cCol] += MARK_FOR_CELL;
minesLeft--;

如果我们右键点击一个未标记的单元格,我们会将 MARK_FOR_CELL 添加到代表该单元格的数字中。这会导致在 paintComponent() 方法中绘制一个带有标记的被覆盖单元格。

field[(cRow * N_COLS) + cCol] -= MARK_FOR_CELL;
minesLeft++;

var msg = Integer.toString(minesLeft);
statusbar.setText(msg);

如果我们左键点击一个已标记的单元格,我们会移除标记并增加待标记单元格的数量。

if (field[(cRow * N_COLS) + cCol] > COVERED_MINE_CELL) {
    return;
}

如果我们点击一个被覆盖并已标记的单元格,什么也不会发生。它必须先被另一次右键点击揭开,然后才可能被左键点击。

field[(cRow * N_COLS) + cCol] -= COVER_FOR_CELL;

左键点击会移除单元格的覆盖。

if (field[(cRow * N_COLS) + cCol] == MINE_CELL) {
    inGame = false;
}

if (field[(cRow * N_COLS) + cCol] == EMPTY_CELL) {
    find_empty_cells((cRow * N_COLS) + cCol);
}

如果我们左键点击了一个地雷,游戏就结束了。如果我们左键点击了一个空白单元格,我们就调用 find_empty_cells() 方法,该方法会递归地查找所有相邻的空白单元格。

if (doRepaint) {
    repaint();
}

如果需要重绘棋盘(例如,设置或移除了标记),我们就调用 repaint() 方法。

com/zetcode/Minesweeper.java
package com.zetcode;

import java.awt.BorderLayout;
import java.awt.EventQueue;
import javax.swing.JFrame;
import javax.swing.JLabel;

/**
 * Java Minesweeper Game
 *
 * Author: Jan Bodnar
 * Website: https://zetcode.cn
 */

public class Minesweeper extends JFrame {

    private JLabel statusbar;

    public Minesweeper() {

        initUI();
    }

    private void initUI() {

        statusbar = new JLabel("");
        add(statusbar, BorderLayout.SOUTH);

        add(new Board(statusbar));

        setResizable(false);
        pack();

        setTitle("Minesweeper");
        setLocationRelativeTo(null);
        setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    }

    public static void main(String[] args) {

        EventQueue.invokeLater(() -> {

            var ex = new Minesweeper();
            ex.setVisible(true);
        });
    }
}

这是主类。

setResizable(false);

窗口的大小是固定的。为此,我们使用 setResizable() 方法。

Minesweeper
图:扫雷

在 Java 2D 游戏教程的这一部分,我们创建了一个 Java 版的扫雷游戏克隆。