java实现扫雷游戏(附带源码)

项目背景详细介绍
扫雷(Minesweeper)是一款经典的单人益智游戏,最早随 Windows 操作系统一同发布,凭借简单易上手却富含策略性的游戏体验,一度风靡全球。游戏规则并不复杂:在一个由方格组成的棋盘中,隐藏着若干地雷。玩家需要根据每个已打开方块显示的数字(代表周围 8 个格子中地雷的数量),推断出哪些方块安全可点,哪些方块可能藏雷,直到将所有非地雷格子全部打开或标记出所有地雷为止。

实现一款完整的扫雷游戏,对初学 Java 或中级开发者都有很大意义:

  1. GUI 编程练习:典型的扫雷界面基于 Swing/AWT 构建,包含自定义绘制、事件监听、布局管理、定时器等要素,可以帮助学习者深入掌握 Java 桌面 GUI 编程。

  2. 游戏逻辑设计:扫雷核心在于二维数组的递归翻开(连击)算法、数字统计、旗帜标记、胜负判定等,有助于学习者巩固算法与数据结构基础。

  3. MVC 分层思维:可以通过分离数据模型(Model)、界面视图(View)和业务逻辑(Controller),提升软件可维护性和可扩展性。

  4. 用户体验优化:要从界面布局、美术资源、用户交互反馈(如鼠标右键标记,左键打开),以及计时器、剩余地雷显示等细节入手,培养对用户体验的关注。

  5. 项目管理与可扩展性:通过本项目可学习如何组织一个较为完整的中型项目、如何划分包和类、如何撰写详细注释、如何设计可选难度模式和保存成绩等功能。

本项目旨在以“Java 实现扫雷游戏”为主题,提供一个从零开始的完整示例,从项目背景、需求分析、相关技术、实现思路、完整代码、代码详细解读、项目总结、常见问题及解答,以及扩展方向与性能优化等九个方面,做全方位、极其详细的介绍。文章全文约 10000 汉字,适合用作博客、课堂讲义或团队分享示例。所有代码集中在一个代码块中,并配有细致注释,便于复制、阅读与二次教学。


1、项目需求详细介绍

基于传统 Windows 版本“扫雷”游戏功能与玩法,本项目需要实现一个具有下列功能的桌面版扫雷游戏:

1.1 基本功能需求

  1. 难度设置

    • 提供三种默认难度:

      1. 初级:9×9 方格,10 个地雷;

      2. 中级:16×16 方格,40 个地雷;

      3. 高级:30×16 方格,99 个地雷;

    • 用户可选择或通过菜单切换难度。

    • 可支持自定义行数、列数、地雷数。

  2. 游戏面板

    • 使用 Swing 窗口(JFrame)创建主游戏窗口,窗口包含菜单栏、工具栏、状态栏和游戏网格区。

    • 网格区由若干 Cell(格子)组件构成,每个格子为 JButton 或自定义绘制组件。

    • 初始状态:所有格子均为“未打开”状态,显示灰色阴影或默认图标。

  3. 点击操作

    • 左键点击

      • 如果点击的格子是地雷,游戏立即失败,自动打开所有地雷并显示“游戏失败”提示;

      • 如果格子周围有数字(即相邻 8 个格子中有地雷),则显示该数字;

      • 如果格子相邻没有地雷(数字为 0),则自动“连开”(递归/广度优先或深度优先方式)所有与之相连、且周围数字为 0 的格子边界,直到数字大于 0 的格子为止。

    • 右键点击

      • 将当前格子标记为旗帜(旗子图标),表示“我认为这里有地雷”;

      • 再次右键点击可以切换为问号状态(可选),再次右键恢复为未标记状态。

    • 当所有非地雷格子都已打开时,游戏胜利,弹出“游戏胜利”提示并停止计时。

  4. 计时与地雷剩余提示

    • 窗口上方或下方显示三个面板:

      1. “剩余地雷数”计数器:初始为难度对应的地雷总数,每标记一个旗帜显示减 1,移除旗帜则加 1;

      2. “计时器”:游戏开始时自动从零开始计时,每秒递增,在游戏结束后停止;

      3. “新游戏按钮”:可随时重置游戏,重新随机布置地雷并清除计时与标记。

    • 支持键盘快捷键(如按 F2 开启新游戏)或菜单选项“游戏 → 新游戏”“游戏 → 退出”等。

  5. 菜单与工具栏

    • 菜单栏包含:

      • “游戏”菜单:新游戏、初级、中级、高级、自定义、退出;

      • “帮助”菜单:关于,显示作者信息与版本提示。

    • 工具栏可放置“新游戏”、“提示”、“重置计时”等图标按钮。

  6. 用户体验细节

    • 点击未打开格时,鼠标指针变为按下效果;游戏失败时,所有未标记地雷处显示扫雷炸弹标志,已标记但未放雷处显示错误标记(X);

    • 格子数字使用不同颜色表示:1 为蓝色,2 为绿色,3 为红色,4 为深蓝,5 为棕色,6 为青色,7 为黑色,8 为灰色;

    • 鼠标左键按下时将当前格及四周八个相邻格进行临时高亮显示;松开时执行实际点击操作(鼠标按下效果)。

    • 支持游戏窗口大小自适应,格子大小固定(如 24×24 像素),在困难难度下横向滚动或缩放界面。

  7. 游戏状态保存(可选)

    • 当窗口大小或应用最小化时,可将当前游戏状态(格子打开状态、标记状态、计时数)保存于内存中;

    • 用户关闭后再次打开当前程序时,自动恢复上一次游戏(可在配置中设置是否启用该功能)。

  8. 辅助功能(可选)

    • “提示”按钮:点击后,随机打开一个未标记且无雷周围的格子,但每局只能使用一次或若干次;

    • 排行榜:记录不同难度下的最快通关时间,可将记录保存在本地文件或序列化对象中,并在“帮助”菜单中显示。

  9. 性能要求

    • 在最大难度(30×16、99 雷)下,点击连开操作需要瞬时完成,界面无明显卡顿;

    • 资源占用尽量低,支持绝大多数主流桌面环境运行(JDK 1.8+)。

  10. 文档说明

    • 本篇博文及示例代码提供完整的项目结构与依赖说明,便于读者一键复制、在 IDE(IntelliJ IDEA、Eclipse)中直接运行。


2、相关技术详细介绍

实现扫雷游戏需要掌握以下关键技术与概念:

2.1 Java Swing GUI 编程

  • 顶层容器与窗口

    • JFrame:顶层窗口容器,用于承载菜单栏、工具栏、游戏面板、状态栏等组件;

    • JDialog:弹出对话框,用于“游戏胜利”“游戏失败”“自定义难度”“关于”等信息提示;

  • 布局管理器

    • BorderLayout:将组件分布在北(N)、南(S)、东(E)、西(W)、中(Center)五个区域;

    • GridLayout:将面板划分为指定行列的网格,本项目用于将游戏格子面板划分为 rows × cols

    • FlowLayoutBoxLayout:用于工具栏、状态栏的水平或垂直排列;

  • 组件

    • JPanel:最常用的容器面板,支持设置不同布局管理器、边框、背景色;

    • JButton:可自定义背景图标,用于每个格子的交互;可以通过 setIconsetDisabledIconsetBorderPainted(false) 等方法实现纯图形效果;

    • JLabel:用于显示文本或图像,例如状态栏中的剩余地雷数与计时;

    • JMenuBarJMenuJMenuItem:用于创建菜单栏、菜单项;可绑定 ActionListener 响应菜单点击;

    • JToolBar:工具栏,可包含按钮图标,实现快速操作;

  • 图标与图像处理

    • 使用 ImageIcon 加载 PNG、GIF 等格式图像资源,用于未打开、已打开、旗帜、地雷、数字 1~8、问号等图标;

    • 采用 ClassLoader.getResource(...) 或绝对路径加载资源,确保跨平台兼容;

    • 对图标进行缩放(getScaledInstance)以适应格子大小;

  • 事件监听

    • 鼠标事件:给每个格子 Cell 组件添加 MouseListener(尤其关注 mousePressedmouseReleasedmouseClicked);

      • mousePressed:鼠标按下时触发,用于实现鼠标按下高亮效果;

      • mouseReleased:鼠标抬起时触发,判断左键或右键,执行打开或标记操作;

    • 动作事件:菜单项(JMenuItem)与工具栏按钮(JButton)使用 ActionListener 响应点击;

  • 定时器

    • 使用 javax.swing.Timer 创建 Swing 线程安全的定时器(参数为每 1 秒触发一次),在 actionPerformed 中更新状态栏上的计时数字;

    • 游戏开始时启动定时器,游戏结束(胜利或失败)时停止定时器;可在重置或新游戏时重置计时为 0;

  • 自定义绘制(可选)

    • 若需要更灵活的视觉效果,可对 Cell 写成继承自 JPanelJComponent,重写 paintComponent(Graphics g) 方法自行绘制地雷、数字与背景;

    • 需要在 repaint()validate() 之间取舍,保证刷新效率与视觉效果。

2.2 数据模型与业务逻辑(Model)

  • 二维数组表示棋盘

    • 使用 int[][] mines 数组表示格子状态:

      • -1:该格为地雷;

      • 0~8:该格周围 8 个格子中地雷的数量;

    • 使用 boolean[][] opened 数组表示格子是否已打开;

    • 使用 boolean[][] flagged 数组表示格子是否已标记旗帜;

    • 使用 boolean[][] questioned 数组表示是否处于问号状态(可选);

  • 随机布雷算法

    • 在新游戏开始时,根据难度对应的地雷总数 numMines,在 rows × cols 总格子中随机选择 numMines 个不同的坐标放置地雷;

    • 实现方式:

      1. 洗牌法:将所有 rows*cols 个序号保存到 List<Integer> 中,打乱顺序,然后取前 numMines 个索引;索引到 (index / cols, index % cols) 放置雷;

      2. 随机取值法:使用 Random rand = new Random(),不断 rand.nextInt(rows)rand.nextInt(cols) 随机生成坐标,若该格还没有地雷,则放雷;重复直到放满即可;

    • 布雷完成后,根据每个格子,统计其周围 8 个格子(注意边界检查)包含地雷的个数,将数值存入 mines[r][c]

  • 递归连开(Flood Fill)算法

    • 当玩家点击一个 mines[r][c] == 0 的格子时,需要连开它周围所有未打开且非地雷的格子,递归或迭代方式均可:

      1. 深度优先:若当前格周围没有地雷,则打开所有相邻 8 个格子,若相邻格子也是 mines[nr][nc] == 0,继续递归;

      2. 广度优先:使用 Queue<Point> 维护待展开格子队列,初始将当前格加入队列,然后 while (!queue.isEmpty()),取出一个格子,若周围数字为 0,则将相邻格子加入队列并打开;否则仅打开;

    • 需要维护一个 visited 或利用 opened 数组避免重复处理;

  • 游戏胜负判定

    • 失败:当左键点击地雷时,直接触发游戏失败;将 opened 数组中所有地雷格子设为 true 并显示地雷图标;若玩家错误标记了一个没有地雷的格子,则在该格子显示失败标记(红色 X);

    • 胜利:当玩家将所有非地雷格子都打开,即:openedCount == rows*cols - numMines,或当玩家正确将所有地雷标记为旗帜(即 flaggedCount == numMines && 所有地雷格子的 flagged == true),触发游戏胜利;弹出胜利提示,并停止计时;

  • 状态统计

    • openedCount:记录已打开格子数;

    • flaggedCount:记录已标记旗帜格子数;

    • remainingMines = numMines - flaggedCount:保持与状态栏显示同步;

2.3 MVC 分层与包结构设计

为了便于后续扩展和维护,将代码分为以下包与类:

/JavaMinesweeper
 ├─ src
 │   ├─ main
 │   │   └─ java
 │   │       └─ com
 │   │           └─ example
 │   │               ├─ model
 │   │               │     ├─ MineField.java         // 数据模型,地雷逻辑与游戏状态
 │   │               │     └─ CellState.java         // Cell 状态枚举(隐藏、打开、旗帜、问号)
 │   │               ├─ view
 │   │               │     ├─ MineCell.java           // 自定义格子组件,继承 JButton
 │   │               │     ├─ MinePanel.java          // 承载格子网格的 JPanel
 │   │               │     └─ StatusBar.java          // 状态栏面板,显示计时与剩余地雷
 │   │               ├─ controller
 │   │               │     └─ GameController.java     // 事件监听与游戏流程控制
 │   │               ├─ util
 │   │               │     └─ ResourceLoader.java     // 加载图标资源
 │   │               └─ MineSweeperApp.java           // 主程序入口,构造 JFrame、菜单、组件
 │   └─ test
 │       └─ java
 │           └─ com
 │               └─ example
 │                   └─ model
 │                       └─ MineFieldTest.java        // 对 MineField 的单元测试
 └─ README.md
  • model 包:只关注游戏数据与逻辑,不涉及界面。

  • view 包:自定义 Swing 组件,仅负责界面渲染与交互效果,不保存业务逻辑。

  • controller 包:负责监听用户事件(鼠标点击、菜单命令等),调用 MineField 更新状态,并通知视图刷新;

  • util 包:封装资源加载、图标管理等通用功能。

  • MineSweeperApp.java:程序主类,初始化 MVC 组件,建立 JFrame,并添加菜单与工具栏。

这样分层后,当要修改游戏逻辑(比如连开算法、胜负判定)时,只需改 model;当要修改界面样式(比如更换图标、调整配色)时,只需改 view;当要添加功能(比如保存分数、网络对战)时,只需在 controller 或添加新的 model

2.4 单元测试(JUnit)

  • MineFieldTest.java:主要对 MineField 类中的以下逻辑进行单元测试:

    1. 随机布雷测试

      • 给定固定 rows=5, cols=5, numMines=5,调用 resetField(或构造函数),查看 mines 数组中是否恰好有 5 个 -1

      • 多次调用,确保每次地雷随机分布不同。

    2. 周围数字统计测试

      • 手动创建一个已知地雷位置的 mines 数组(可通过反射或特定 setMines 方法),调用 computeAdjacentCounts,验证每个非地雷格子数字是否等于周围实际地雷数。

    3. 连开算法测试

      • 构造一个 3×3 无雷区域,调用 openCell(r, c) 并判断 opened[r][c] 及其周围是否全部打开;

      • 构造带有部分雷的位置,测试如果数字不为 0 是否仅打开该格。

    4. 胜负判定测试

      • 手动打开所有非雷格子时 isWin() 应返回 true

      • 点击雷时 isLost() 应返回 true

    5. 标记旗帜测试

      • 对同一格子多次调用 toggleFlag(r, c),检查 flagged[r][c] 状态翻转,并且 remainingMines 更新正确;

    6. 边界条件测试

      • 对超出范围索引调用 openCell(-1, 0)toggleFlag(rows, cols+1),可抛 IndexOutOfBoundsException 或自行捕获并忽略;

      • numMines > rows*cols 时,构造函数或 resetField 应抛出 IllegalArgumentException


3、实现思路详细介绍

在动手编写代码前,需要对游戏整体架构、各模块职责和核心算法进行详细规划,确保项目结构清晰、逻辑严谨、可维护性好。以下从包结构、主要类设计、关键方法流程、事件处理、UI 构建等角度分解。

3.1 包结构与主要类职责

com.example.minesweeper
 ├─ model
 │    ├─ MineField.java          // 游戏模型,存储地雷、数字、状态,提供打开、标记、重置方法
 │    └─ CellState.java          // 枚举类,描述格子 4 种状态:HIDDEN、REVEALED、FLAGGED、QUESTIONED
 ├─ view
 │    ├─ MineCell.java           // 自定义格子组件,继承 JButton,根据状态绘制不同图标
 │    ├─ MinePanel.java          // 游戏网格面板,使用 GridLayout 管理 MineCell
 │    └─ StatusBar.java          // 状态栏面板,显示剩余雷数、计时器、重置按钮
 ├─ controller
 │    └─ GameController.java     // 游戏控制器,监听鼠标与菜单事件,调用 MineField 更新并刷新视图
 ├─ util
 │    └─ ResourceLoader.java     // 工具类,用于加载图标资源,避免硬编码路径
 └─ MineSweeperApp.java          // 主程序,构造 JFrame、菜单栏、将 MVC 各组件组合
3.1.1 model/MineField.java
  • 成员变量

    • private int rows, cols, numMines;

    • private int[][] mines; // -1 表示地雷,0~8 表示周围地雷数

    • private CellState[][] states; // 格子当前状态枚举

    • private int openedCount; // 已打开格子数

    • private int flaggedCount; // 已标记旗帜数量

    • private boolean isLost, isWin; // 游戏胜负标志

  • 构造函数

public MineField(int rows, int cols, int numMines) {
    if (rows <= 0 || cols <= 0 || numMines <= 0 || numMines >= rows * cols) {
        throw new IllegalArgumentException("参数非法:行、列、地雷数不合法");
    }
    this.rows = rows;
    this.cols = cols;
    this.numMines = numMines;
    resetField();
}

重置方法

public void resetField() {
    mines = new int[rows][cols];
    states = new CellState[rows][cols];
    openedCount = 0;
    flaggedCount = 0;
    isLost = false;
    isWin = false;
    // 初始化 states 为 HIDDEN
    for (int r = 0; r < rows; r++) {
        for (int c = 0; c < cols; c++) {
            states[r][c] = CellState.HIDDEN;
        }
    }
    placeMinesRandomly();
    computeAdjacentCounts();
}
  • 随机布雷

    • 使用随机数洗牌或随机取值法将 numMines-1 分布于 mines 中;

  • 统计周围数字

    • 遍历 mines 数组,若 mines[r][c] == -1(地雷),跳过;否则统计周围 8 个方向地雷个数,赋值给 mines[r][c]

  • 打开格子

public void openCell(int r, int c) {
    if (isLost || isWin || !isValid(r, c) || states[r][c] != CellState.HIDDEN) {
        return;
    }
    // 如果点击地雷,游戏失败
    if (mines[r][c] == -1) {
        isLost = true;
        revealAllMines();
        return;
    }
    // 连击打开
    floodFillOpen(r, c);
    // 胜利判定:已打开格子 == 总格子 - 地雷数
    if (openedCount == rows * cols - numMines) {
        isWin = true;
    }
}

连击算法(Flood Fill)

private void floodFillOpen(int r, int c) {
    Queue<Point> queue = new LinkedList<>();
    queue.offer(new Point(r, c));
    while (!queue.isEmpty()) {
        Point p = queue.poll();
        int row = p.x, col = p.y;
        if (!isValid(row, col) || states[row][col] != CellState.HIDDEN) {
            continue;
        }
        states[row][col] = CellState.REVEALED;
        openedCount++;
        // 如果周围数字为 0,继续加入相邻 8 格
        if (mines[row][col] == 0) {
            for (int dr = -1; dr <= 1; dr++) {
                for (int dc = -1; dc <= 1; dc++) {
                    if (dr == 0 && dc == 0) continue;
                    int nr = row + dr, nc = col + dc;
                    if (isValid(nr, nc) && states[nr][nc] == CellState.HIDDEN) {
                        queue.offer(new Point(nr, nc));
                    }
                }
            }
        }
    }
}

标记/取消标记

public void toggleFlag(int r, int c) {
    if (isLost || isWin || !isValid(r, c) || states[r][c] == CellState.REVEALED) {
        return;
    }
    if (states[r][c] == CellState.HIDDEN) {
        states[r][c] = CellState.FLAGGED;
        flaggedCount++;
    } else if (states[r][c] == CellState.FLAGGED) {
        states[r][c] = CellState.QUESTIONED;
        flaggedCount--;
    } else if (states[r][c] == CellState.QUESTIONED) {
        states[r][c] = CellState.HIDDEN;
    }
}

4、完整实现代码

// =======================================================
// 文件:MineField.java
// 包名:com.example.minesweeper.model
// 功能:游戏数据模型,存储地雷、格子状态,提供打开、标记与重置方法
// 作者:YourName
// 日期:2025-06-03
// =======================================================

package com.example.minesweeper.model;

import java.awt.Point;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;

/**
 * MineField:扫描雷游戏的核心数据模型,包含地雷布置、格子状态、
 * 递归连开与胜负判定等逻辑。
 */
public class MineField {
    private int rows, cols, numMines;          // 行数、列数、地雷数
    private int[][] mines;                     // 地雷分布及数字:-1 表示地雷,0~8 表示周围雷数
    private CellState[][] states;              // 每个格子的当前状态:HIDDEN/REVEALED/FLAGGED/QUESTIONED
    private int openedCount;                   // 已打开格子数
    private int flaggedCount;                  // 已标记旗帜数量
    private boolean isLost, isWin;             // 游戏失败与胜利标志

    /**
     * 构造函数:指定行数、列数、地雷数
     * @param rows     行数
     * @param cols     列数
     * @param numMines 地雷总数
     */
    public MineField(int rows, int cols, int numMines) {
        if (rows <= 0 || cols <= 0 || numMines <= 0 || numMines >= rows * cols) {
            throw new IllegalArgumentException("参数非法:行、列、地雷数不合法");
        }
        this.rows = rows;
        this.cols = cols;
        this.numMines = numMines;
        resetField();
    }

    /**
     * 重置棋盘:清空格子状态,随机放置地雷,计算周围数字
     */
    public void resetField() {
        mines = new int[rows][cols];
        states = new CellState[rows][cols];
        openedCount = 0;
        flaggedCount = 0;
        isLost = false;
        isWin = false;

        // 初始化所有格子状态为 HIDDEN
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                states[r][c] = CellState.HIDDEN;
            }
        }

        placeMinesRandomly();
        computeAdjacentCounts();
    }

    /**
     * 随机布雷:使用洗牌法在 rows*cols 格子中随机选择 numMines 个位置放置地雷(-1)
     */
    private void placeMinesRandomly() {
        List<Integer> indices = new ArrayList<>(rows * cols);
        for (int i = 0; i < rows * cols; i++) {
            indices.add(i);
        }
        Collections.shuffle(indices);
        for (int i = 0; i < numMines; i++) {
            int idx = indices.get(i);
            int r = idx / cols;
            int c = idx % cols;
            mines[r][c] = -1;  // 地雷标记
        }
    }

    /**
     * 计算每个格子周围的地雷数量
     */
    private void computeAdjacentCounts() {
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (mines[r][c] == -1) continue;  // 跳过地雷
                int count = 0;
                for (int dr = -1; dr <= 1; dr++) {
                    for (int dc = -1; dc <= 1; dc++) {
                        if (dr == 0 && dc == 0) continue;
                        int nr = r + dr, nc = c + dc;
                        if (isValid(nr, nc) && mines[nr][nc] == -1) {
                            count++;
                        }
                    }
                }
                mines[r][c] = count;  // 周围地雷数
            }
        }
    }

    /**
     * 打开格子:如果是地雷则游戏失败, 否则根据周围数字进行连击打开
     * @param r 格子行索引
     * @param c 格子列索引
     */
    public void openCell(int r, int c) {
        if (isLost || isWin || !isValid(r, c) || states[r][c] != CellState.HIDDEN) {
            return;  // 游戏已结束或格子不合法或非隐藏状态,直接返回
        }
        // 如果点中了地雷
        if (mines[r][c] == -1) {
            isLost = true;
            revealAllMines();
            return;
        }
        floodFillOpen(r, c);
        // 胜利判定:所有非地雷格子都被打开
        if (openedCount == rows * cols - numMines) {
            isWin = true;
        }
    }

    /**
     * 广度优先连击打开算法:从 (r, c) 开始,如果格子周围数字为 0 则继续打开相邻格子
     * @param r 起始行
     * @param c 起始列
     */
    private void floodFillOpen(int r, int c) {
        Queue<Point> queue = new LinkedList<>();
        queue.offer(new Point(r, c));
        while (!queue.isEmpty()) {
            Point p = queue.poll();
            int row = p.x, col = p.y;
            if (!isValid(row, col) || states[row][col] != CellState.HIDDEN) {
                continue;
            }
            states[row][col] = CellState.REVEALED;
            openedCount++;
            // 如果周围无雷,继续将相邻未打开格子加入队列
            if (mines[row][col] == 0) {
                for (int dr = -1; dr <= 1; dr++) {
                    for (int dc = -1; dc <= 1; dc++) {
                        if (dr == 0 && dc == 0) continue;
                        int nr = row + dr, nc = col + dc;
                        if (isValid(nr, nc) && states[nr][nc] == CellState.HIDDEN) {
                            queue.offer(new Point(nr, nc));
                        }
                    }
                }
            }
        }
    }

    /**
     * 标记或切换旗帜、问号状态:HIDDEN -> FLAGGED -> QUESTIONED -> HIDDEN
     * @param r 行索引
     * @param c 列索引
     */
    public void toggleFlag(int r, int c) {
        if (isLost || isWin || !isValid(r, c) || states[r][c] == CellState.REVEALED) {
            return;  // 游戏已结束、格子已打开或索引无效,返回
        }
        switch (states[r][c]) {
            case HIDDEN:
                states[r][c] = CellState.FLAGGED;
                flaggedCount++;
                break;
            case FLAGGED:
                states[r][c] = CellState.QUESTIONED;
                flaggedCount--;
                break;
            case QUESTIONED:
                states[r][c] = CellState.HIDDEN;
                break;
            default:
                break;
        }
    }

    /**
     * 显示所有地雷:设置所有 mines[r][c] == -1 的格子为 REVEALED,用于游戏失败后的展示
     */
    private void revealAllMines() {
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (mines[r][c] == -1) {
                    states[r][c] = CellState.REVEALED;
                }
            }
        }
    }

    /**
     * 边界检查:判断 (r, c) 是否在有效范围内
     * @param r 行
     * @param c 列
     * @return true 表示合法
     */
    private boolean isValid(int r, int c) {
        return r >= 0 && r < rows && c >= 0 && c < cols;
    }

    /**
     * 获取指定格子状态
     * @param r 行索引
     * @param c 列索引
     * @return 当前 CellState
     */
    public CellState getCellState(int r, int c) {
        if (!isValid(r, c)) {
            throw new IndexOutOfBoundsException("坐标越界");
        }
        return states[r][c];
    }

    /**
     * 获取指定格子周围地雷数量;若是地雷返回 -1
     * @param r 行
     * @param c 列
     * @return -1 表示地雷,否则 0~8
     */
    public int getAdjacentMineCount(int r, int c) {
        if (!isValid(r, c)) {
            throw new IndexOutOfBoundsException("坐标越界");
        }
        return mines[r][c];
    }

    /**
     * 游戏是否失败
     * @return true 表示已失败
     */
    public boolean isLost() {
        return isLost;
    }

    /**
     * 游戏是否胜利
     * @return true 表示已胜利
     */
    public boolean isWin() {
        return isWin;
    }

    /**
     * 获取剩余可标记的地雷数(地雷总数减去已标记旗帜数)
     * @return 剩余地雷
     */
    public int getRemainingMines() {
        return numMines - flaggedCount;
    }

    /**
     * 获取行数
     */
    public int getRows() {
        return rows;
    }

    /**
     * 获取列数
     */
    public int getCols() {
        return cols;
    }
}
// =======================================================
// 文件:MineCell.java
// 包名:com.example.minesweeper.view
// 功能:自定义格子组件,继承 JButton,根据状态绘制图标
// 作者:YourName
// 日期:2025-06-03
// =======================================================

package com.example.minesweeper.view;

import com.example.minesweeper.model.CellState;
import com.example.minesweeper.model.MineField;
import com.example.minesweeper.util.ResourceLoader;

import javax.swing.JButton;
import javax.swing.BorderFactory;
import java.awt.Color;
import java.awt.Dimension;

/**
 * MineCell:代表扫雷游戏中的单个格子,继承自 JButton,根据 CellState 设置相应图标。
 */
public class MineCell extends JButton {
    private int row, col;                // 格子的行和列索引
    private MineField mineField;         // 引用游戏数据模型

    /**
     * 构造函数
     * @param r         行索引
     * @param c         列索引
     * @param mineField 游戏数据模型引用
     */
    public MineCell(int r, int c, MineField mineField) {
        this.row = r;
        this.col = c;
        this.mineField = mineField;
        initialize();
    }

    /**
     * 初始化格子外观与属性
     */
    private void initialize() {
        setPreferredSize(new Dimension(24, 24));
        setBackground(Color.LIGHT_GRAY);
        setBorder(BorderFactory.createBevelBorder(0));
        setFocusPainted(false);
        updateAppearance();
    }

    /**
     * 更新格子外观:根据 MineField 中对应位置的 CellState 与 周围雷数设置图标
     */
    public void updateAppearance() {
        CellState state = mineField.getCellState(row, col);
        switch (state) {
            case HIDDEN:
                setIcon(ResourceLoader.getIcon("hidden.png"));
                setEnabled(true);
                break;
            case REVEALED:
                int count = mineField.getAdjacentMineCount(row, col);
                if (count == -1) {
                    // 地雷
                    setIcon(ResourceLoader.getIcon("mine.png"));
                } else if (count > 0) {
                    // 数字
                    setIcon(ResourceLoader.getIcon("num_" + count + ".png"));
                } else {
                    // 空白
                    setIcon(ResourceLoader.getIcon("empty.png"));
                }
                setEnabled(false);
                break;
            case FLAGGED:
                setIcon(ResourceLoader.getIcon("flag.png"));
                setEnabled(true);
                break;
            case QUESTIONED:
                setIcon(ResourceLoader.getIcon("question.png"));
                setEnabled(true);
                break;
            default:
                setIcon(ResourceLoader.getIcon("hidden.png"));
                setEnabled(true);
                break;
        }
    }

    /**
     * 获取该格子对应的行索引
     */
    public int getRow() {
        return row;
    }

    /**
     * 获取该格子对应的列索引
     */
    public int getCol() {
        return col;
    }
}

 

// =======================================================
// 文件:MinePanel.java
// 包名:com.example.minesweeper.view
// 功能:游戏网格面板,管理多个 MineCell 组件
// 作者:YourName
// 日期:2025-06-03
// =======================================================

package com.example.minesweeper.view;

import com.example.minesweeper.model.MineField;

import javax.swing.JPanel;
import java.awt.GridLayout;

/**
 * MinePanel:扫雷游戏的主网格面板,使用 GridLayout 管理
 */
public class MinePanel extends JPanel {
    private MineCell[][] cells;  // 二维数组存储所有 MineCell

    /**
     * 构造函数
     * @param mineField 游戏数据模型
     */
    public MinePanel(MineField mineField) {
        int rows = mineField.getRows();
        int cols = mineField.getCols();
        setLayout(new GridLayout(rows, cols, 1, 1));
        setBackground(java.awt.Color.GRAY);

        cells = new MineCell[rows][cols];
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                MineCell cell = new MineCell(r, c, mineField);
                cells[r][c] = cell;
                add(cell);
            }
        }
    }

    /**
     * 刷新所有格子外观,根据当前模型状态重新设置图标
     */
    public void refreshAllCells() {
        int rows = cells.length;
        int cols = cells[0].length;
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                cells[r][c].updateAppearance();
            }
        }
    }

    /**
     * 获取所有 MineCell 引用,用于 Controller 添加事件监听
     */
    public MineCell[][] getCells() {
        return cells;
    }
}

 

// =======================================================
// 文件:StatusBar.java
// 包名:com.example.minesweeper.view
// 功能:状态栏组件,显示剩余地雷数、计时器、重置按钮
// 作者:YourName
// 日期:2025-06-03
// =======================================================

package com.example.minesweeper.view;

import com.example.minesweeper.model.MineField;
import com.example.minesweeper.util.ResourceLoader;

import javax.swing.JLabel;
import javax.swing.JButton;
import javax.swing.JPanel;
import javax.swing.Timer;
import java.awt.FlowLayout;
import java.awt.Font;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

/**
 * StatusBar:扫雷游戏的状态栏,包含剩余地雷数、计时器与重置按钮
 */
public class StatusBar extends JPanel {
    private JLabel lblMines;      // 剩余地雷数标签
    private JLabel lblTime;       // 计时标签
    private JButton btnReset;     // 重置游戏按钮
    private Timer timer;          // Swing 定时器,每秒更新一次
    private int elapsedTime;      // 已用时间,单位秒

    /**
     * 构造函数
     * @param initialMines 初始地雷数
     */
    public StatusBar(int initialMines) {
        setLayout(new FlowLayout(FlowLayout.CENTER, 20, 5));

        lblMines = new JLabel(String.format("雷数: %03d", initialMines));
        lblMines.setFont(new Font("Consolas", Font.BOLD, 18));
        add(lblMines);

        lblTime = new JLabel("时间: 000");
        lblTime.setFont(new Font("Consolas", Font.BOLD, 18));
        add(lblTime);

        btnReset = new JButton(ResourceLoader.getIcon("reset.png"));
        btnReset.setToolTipText("重置游戏");
        add(btnReset);

        elapsedTime = 0;
        timer = new Timer(1000, new ActionListener() {
            @Override
            public void actionPerformed(ActionEvent e) {
                elapsedTime++;
                lblTime.setText(String.format("时间: %03d", elapsedTime));
            }
        });
        timer.setInitialDelay(0);
        timer.start();
    }

    /**
     * 更新剩余地雷数显示
     * @param remainingMines 剩余地雷数
     */
    public void updateRemainingMines(int remainingMines) {
        lblMines.setText(String.format("雷数: %03d", remainingMines));
    }

    /**
     * 停止计时器
     */
    public void stopTimer() {
        if (timer.isRunning()) {
            timer.stop();
        }
    }

    /**
     * 重置计时器与时间显示,调用 reset 时执行
     */
    public void resetTimer() {
        elapsedTime = 0;
        lblTime.setText("时间: 000");
        timer.restart();
    }

    /**
     * 获取重置按钮,用于 Controller 绑定事件
     */
    public JButton getResetButton() {
        return btnReset;
    }
}

 

// =======================================================
// 文件:GameController.java
// 包名:com.example.minesweeper.controller
// 功能:游戏控制器,监听鼠标与菜单事件,协调 Model 与 View,刷新界面
// 作者:YourName
// 日期:2025-06-03
// =======================================================

package com.example.minesweeper.controller;

import com.example.minesweeper.model.CellState;
import com.example.minesweeper.model.MineField;
import com.example.minesweeper.view.MineCell;
import com.example.minesweeper.view.MinePanel;
import com.example.minesweeper.view.StatusBar;

import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import java.awt.Frame;
import java.awt.Point;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;

/**
 * GameController:游戏控制器类,负责监听用户操作,调用 MineField 更新数据模型,
 * 并与 MinePanel、StatusBar 交互刷新界面。
 */
public class GameController {
    private MineField mineField;
    private MinePanel minePanel;
    private StatusBar statusBar;

    /**
     * 构造函数:注入模型与视图
     * @param mineField 游戏模型
     * @param minePanel 游戏网格视图
     * @param statusBar 状态栏视图
     */
    public GameController(MineField mineField, MinePanel minePanel, StatusBar statusBar) {
        this.mineField = mineField;
        this.minePanel = minePanel;
        this.statusBar = statusBar;
        attachCellListeners();
        attachResetListener();
    }

    /**
     * 给每个 MineCell 添加鼠标监听,用于响应左键打开与右键标记
     */
    private void attachCellListeners() {
        MineCell[][] cells = minePanel.getCells();
        int rows = cells.length;
        int cols = cells[0].length;
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                MineCell cell = cells[r][c];
                cell.addMouseListener(new MouseAdapter() {
                    @Override
                    public void mousePressed(MouseEvent e) {
                        // 左键按下时高亮相邻格子
                        if (e.getButton() == MouseEvent.BUTTON1 &&
                            !mineField.isLost() && !mineField.isWin()) {
                            highlightNeighbors(cell.getRow(), cell.getCol(), true);
                        }
                    }
                    @Override
                    public void mouseReleased(MouseEvent e) {
                        // 鼠标松开时,取消高亮并执行操作
                        if (!mineField.isLost() && !mineField.isWin()) {
                            highlightNeighbors(cell.getRow(), cell.getCol(), false);
                            if (e.getButton() == MouseEvent.BUTTON1) {
                                mineField.openCell(cell.getRow(), cell.getCol());
                            } else if (e.getButton() == MouseEvent.BUTTON3) {
                                mineField.toggleFlag(cell.getRow(), cell.getCol());
                            }
                            updateViewAndStatus();
                        }
                    }
                });
            }
        }
    }

    /**
     * 给重置按钮绑定事件,点击时重置游戏
     */
    private void attachResetListener() {
        statusBar.getResetButton().addActionListener(e -> resetGame());
    }

    /**
     * 高亮/取消高亮指定格子及其周围 8 个格子
     * @param r         行索引
     * @param c         列索引
     * @param highlight true 表示高亮,false 表示恢复
     */
    private void highlightNeighbors(int r, int c, boolean highlight) {
        int rows = mineField.getRows();
        int cols = mineField.getCols();
        for (int dr = -1; dr <= 1; dr++) {
            for (int dc = -1; dc <= 1; dc++) {
                int nr = r + dr, nc = c + dc;
                if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
                    MineCell neighbor = minePanel.getCells()[nr][nc];
                    if (mineField.getCellState(nr, nc) == CellState.HIDDEN) {
                        if (highlight) {
                            neighbor.setBackground(java.awt.Color.GRAY.brighter());
                        } else {
                            neighbor.setBackground(java.awt.Color.LIGHT_GRAY);
                        }
                    }
                }
            }
        }
    }

    /**
     * 刷新视图与状态栏,根据当前模型状态更新格子图标、剩余地雷、计时器与胜负提示
     */
    private void updateViewAndStatus() {
        minePanel.refreshAllCells();
        statusBar.updateRemainingMines(mineField.getRemainingMines());
        if (mineField.isLost()) {
            statusBar.stopTimer();
            JOptionPane.showMessageDialog(null,
                "很遗憾,您点中地雷,游戏失败!", "游戏失败",
                JOptionPane.INFORMATION_MESSAGE);
        } else if (mineField.isWin()) {
            statusBar.stopTimer();
            JOptionPane.showMessageDialog(null,
                "恭喜!您已成功排除所有地雷,游戏胜利!", "游戏胜利",
                JOptionPane.INFORMATION_MESSAGE);
        }
    }

    /**
     * 重置游戏:调用模型重置,刷新视图与状态栏,重置计时
     */
    public void resetGame() {
        mineField.resetField();
        minePanel.refreshAllCells();
        statusBar.updateRemainingMines(mineField.getRemainingMines());
        statusBar.resetTimer();
    }

    /**
     * 根据指定难度启动新游戏:指定行、列、地雷数
     * @param rows     行数
     * @param cols     列数
     * @param numMines 地雷数
     */
    public void startNewGame(int rows, int cols, int numMines) {
        MineField newField = new MineField(rows, cols, numMines);
        // 重建 MinePanel 与状态栏、定时器
        java.awt.Window win = SwingUtilities.getWindowAncestor(minePanel);
        if (win instanceof java.awt.Frame) {
            java.awt.Frame frame = (java.awt.Frame) win;
            frame.getContentPane().removeAll();
            MineField oldField = this.mineField;
            this.mineField = newField;
            MinePanel newPanel = new MinePanel(newField);
            StatusBar newStatus = new StatusBar(newField.getRemainingMines());
            GameController newController = new GameController(newField, newPanel, newStatus);

            javax.swing.JToolBar toolBar = new javax.swing.JToolBar();
            javax.swing.JButton btnNew = new javax.swing.JButton(
                com.example.minesweeper.util.ResourceLoader.getIcon("reset.png"));
            btnNew.setToolTipText("新游戏");
            btnNew.addActionListener(e -> newController.resetGame());
            toolBar.add(btnNew);

            frame.add(toolBar, java.awt.BorderLayout.NORTH);
            frame.add(newPanel, java.awt.BorderLayout.CENTER);
            frame.add(newStatus, java.awt.BorderLayout.SOUTH);
            frame.pack();
            frame.setLocationRelativeTo(null);
        }
    }

    /**
     * 弹出自定义难度对话框,提示用户输入行、列、地雷数并启动新游戏
     * @param parent 父窗口
     */
    public void showCustomDialog(Frame parent) {
        javax.swing.JTextField txtRows = new javax.swing.JTextField();
        javax.swing.JTextField txtCols = new javax.swing.JTextField();
        javax.swing.JTextField txtMines = new javax.swing.JTextField();
        Object[] fields = {
            "行数:", txtRows,
            "列数:", txtCols,
            "地雷数:", txtMines
        };
        int option = JOptionPane.showConfirmDialog(parent, fields,
            "自定义难度", JOptionPane.OK_CANCEL_OPTION);
        if (option == JOptionPane.OK_OPTION) {
            try {
                int r = Integer.parseInt(txtRows.getText().trim());
                int c = Integer.parseInt(txtCols.getText().trim());
                int m = Integer.parseInt(txtMines.getText().trim());
                if (r <= 0 || c <= 0 || m <= 0 || m >= r * c) {
                    JOptionPane.showMessageDialog(parent,
                        "输入参数不合法,请检查行数、列数与地雷数。", "输入错误",
                        JOptionPane.ERROR_MESSAGE);
                } else {
                    startNewGame(r, c, m);
                }
            } catch (NumberFormatException ex) {
                JOptionPane.showMessageDialog(parent,
                    "请输入有效的整数值。", "输入错误",
                    JOptionPane.ERROR_MESSAGE);
            }
        }
    }
}

 

// =======================================================
// 文件:ResourceLoader.java
// 包名:com.example.minesweeper.util
// 功能:加载游戏图标资源的工具类
// 作者:YourName
// 日期:2025-06-03
// =======================================================

package com.example.minesweeper.util;

import javax.swing.ImageIcon;
import java.awt.Image;
import java.net.URL;

/**
 * ResourceLoader:统一加载游戏所需图标资源,避免硬编码路径。
 * 资源文件放在 classpath 的 /icons/ 目录下,文件名如 hidden.png 等。
 */
public class ResourceLoader {

    /**
     * 根据文件名加载位于 /icons/ 目录下的图标,并缩放到 24×24 像素
     * @param name 图标文件名,例如 "hidden.png"
     * @return ImageIcon 对象,加载失败返回 null
     */
    public static ImageIcon getIcon(String name) {
        URL url = ResourceLoader.class.getResource("/icons/" + name);
        if (url != null) {
            Image img = new ImageIcon(url).getImage()
                    .getScaledInstance(24, 24, Image.SCALE_SMOOTH);
            return new ImageIcon(img);
        } else {
            return null;
        }
    }
}

 

// =======================================================
// 文件:MineSweeperApp.java
// 包名:com.example.minesweeper
// 功能:扫雷游戏主程序入口,构造 JFrame、菜单栏、工具栏、组合 MVC 各组件
// 作者:YourName
// 日期:2025-06-03
// =======================================================

package com.example.minesweeper;

import com.example.minesweeper.model.MineField;
import com.example.minesweeper.view.MinePanel;
import com.example.minesweeper.view.StatusBar;
import com.example.minesweeper.controller.GameController;
import com.example.minesweeper.util.ResourceLoader;

import javax.swing.*;
import java.awt.BorderLayout;

/**
 * MineSweeperApp:扫雷游戏的主类,包含 main 方法,用于初始化并展示主界面。
 */
public class MineSweeperApp {
    public static void main(String[] args) {
        SwingUtilities.invokeLater(() -> {
            new MineSweeperApp().createAndShowUI();
        });
    }

    /**
     * 创建并展示游戏主界面
     */
    private void createAndShowUI() {
        JFrame frame = new JFrame("Java 扫雷游戏");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        frame.setLayout(new BorderLayout());

        // 默认使用初级难度:9×9、10 地雷
        MineField mineField = new MineField(9, 9, 10);
        MinePanel minePanel = new MinePanel(mineField);
        StatusBar statusBar = new StatusBar(mineField.getRemainingMines());
        GameController controller = new GameController(mineField, minePanel, statusBar);

        // 构造菜单栏
        JMenuBar menuBar = new JMenuBar();
        JMenu gameMenu = new JMenu("游戏");
        JMenuItem newGameItem = new JMenuItem("新游戏 (F2)");
        JMenuItem beginnerItem = new JMenuItem("初级 (9×9, 10 雷)");
        JMenuItem intermediateItem = new JMenuItem("中级 (16×16, 40 雷)");
        JMenuItem expertItem = new JMenuItem("高级 (16×30, 99 雷)");
        JMenuItem customItem = new JMenuItem("自定义难度...");
        JMenuItem exitItem = new JMenuItem("退出");
        gameMenu.add(newGameItem);
        gameMenu.addSeparator();
        gameMenu.add(beginnerItem);
        gameMenu.add(intermediateItem);
        gameMenu.add(expertItem);
        gameMenu.addSeparator();
        gameMenu.add(customItem);
        gameMenu.addSeparator();
        gameMenu.add(exitItem);
        menuBar.add(gameMenu);
        frame.setJMenuBar(menuBar);

        // 菜单事件绑定
        newGameItem.addActionListener(e -> controller.resetGame());
        beginnerItem.addActionListener(e -> controller.startNewGame(9, 9, 10));
        intermediateItem.addActionListener(e -> controller.startNewGame(16, 16, 40));
        expertItem.addActionListener(e -> controller.startNewGame(16, 30, 99));
        customItem.addActionListener(e -> controller.showCustomDialog(frame));
        exitItem.addActionListener(e -> System.exit(0));

        // 工具栏(可选)
        JToolBar toolBar = new JToolBar();
        JButton btnNew = new JButton(ResourceLoader.getIcon("reset.png"));
        btnNew.setToolTipText("新游戏 (F2)");
        btnNew.addActionListener(e -> controller.resetGame());
        toolBar.add(btnNew);

        frame.add(toolBar, BorderLayout.NORTH);
        frame.add(minePanel, BorderLayout.CENTER);
        frame.add(statusBar, BorderLayout.SOUTH);

        frame.pack();
        frame.setResizable(false);
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    }
}

 

// =======================================================
// 文件:MineFieldTest.java
// 包名:com.example.minesweeper.model
// 功能:对 MineField 类的单元测试,验证布雷、统计、连开与胜负判定等逻辑
// 作者:YourName
// 日期:2025-06-03
// =======================================================

package com.example.minesweeper.model;

import org.junit.Test;
import java.awt.Point;
import java.util.HashSet;
import java.util.Set;

import static org.junit.Assert.*;

/**
 * MineFieldTest:测试 MineField 数据模型的各项功能
 */
public class MineFieldTest {

    @Test
    public void testPlaceMinesRandomly() {
        int rows = 5, cols = 5, numMines = 5;
        MineField mf = new MineField(rows, cols, numMines);
        int[][] mines = mf.mines;  // 通过反射或暴露 getter 获取

        // 统计地雷数
        int count = 0;
        Set<Point> positions = new HashSet<>();
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (mines[r][c] == -1) {
                    count++;
                    positions.add(new Point(r, c));
                }
            }
        }
        assertEquals(numMines, count);
        assertEquals(numMines, positions.size());  // 保证没有重复位置
    }

    @Test
    public void testComputeAdjacentCounts() {
        int rows = 3, cols = 3, numMines = 1;
        MineField mf = new MineField(rows, cols, numMines);
        // 将地雷固定在 (1,1)
        mf.mines = new int[rows][cols];
        mf.mines[1][1] = -1;
        mf.computeAdjacentCounts();
        int[][] expected = {
            {1,1,1},
            {1,-1,1},
            {1,1,1}
        };
        assertArrayEquals(expected, mf.mines);
    }

    @Test
    public void testFloodFillOpen() {
        int rows = 3, cols = 3, numMines = 0;
        MineField mf = new MineField(rows, cols, numMines);
        // 清空所有地雷
        mf.mines = new int[rows][cols];
        mf.states = new CellState[rows][cols];
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                mf.states[r][c] = CellState.HIDDEN;
            }
        }
        // 连开 (1,1)
        mf.openCell(1,1);
        // 所有格子都应被打开
        int openedCount = 0;
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                if (mf.states[r][c] == CellState.REVEALED) {
                    openedCount++;
                }
            }
        }
        assertEquals(rows * cols, openedCount);
        assertTrue(mf.isWin());
    }

    @Test
    public void testLoseCondition() {
        int rows = 2, cols = 2, numMines = 1;
        MineField mf = new MineField(rows, cols, numMines);
        // 将雷放在 (0,0)
        mf.mines = new int[rows][cols];
        mf.mines[0][0] = -1;
        mf.states = new CellState[rows][cols];
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                mf.states[r][c] = CellState.HIDDEN;
            }
        }
        // 点击地雷 (0,0)
        mf.openCell(0,0);
        assertTrue(mf.isLost());
    }

    @Test
    public void testFlagging() {
        int rows = 2, cols = 2, numMines = 1;
        MineField mf = new MineField(rows, cols, numMines);
        // 初始剩余雷数应等于 numMines
        assertEquals(numMines, mf.getRemainingMines());
        // 标记 (0,0)
        mf.toggleFlag(0,0);
        assertEquals(CellState.FLAGGED, mf.states[0][0]);
        assertEquals(numMines - 1, mf.getRemainingMines());
        // 标记改为问号
        mf.toggleFlag(0,0);
        assertEquals(CellState.QUESTIONED, mf.states[0][0]);
        assertEquals(numMines, mf.getRemainingMines());
        // 恢复隐藏
        mf.toggleFlag(0,0);
        assertEquals(CellState.HIDDEN, mf.states[0][0]);
        assertEquals(numMines, mf.getRemainingMines());
    }

    @Test(expected = IllegalArgumentException.class)
    public void testInvalidConstructor() {
        new MineField(0, 5, 10);  // 行数 <= 0,抛异常
    }

    @Test(expected = IndexOutOfBoundsException.class)
    public void testOpenInvalidCell() {
        MineField mf = new MineField(3, 3, 1);
        mf.openCell(5, 5);  // 越界
    }
}

 

5、代码详细解读

下面对各个核心类及方法进行功能说明与实现思路讲解,不重复完整代码。

5.1 model/MineField.java

  1. 构造函数与重置

    • 构造函数校验传入参数:行、列与地雷数必须正整数且 numMines < rows*cols,否则抛 IllegalArgumentException

    • resetField() 方法:

      1. 重建 minesstates 数组,重置计数器 openedCount=0flaggedCount=0,重置游戏状态 isLost=false, isWin=false

      2. 将所有 states[r][c] 设为 CellState.HIDDEN

      3. 调用 placeMinesRandomly() 随机布雷,将 mines[r][c] = -1 表示地雷;

      4. 调用 computeAdjacentCounts() 计算每个非雷格子周围地雷数并赋值。

  2. placeMinesRandomly()

    • 创建 List<Integer>,将 0~rows*cols-1 全部添加后调用 Collections.shuffle

    • 取前 numMines 个索引,转换为 (r = idx/cols, c = idx%cols) 放置地雷,避免重复。

  3. computeAdjacentCounts()

    • 遍历 mines 数组,若 mines[r][c] == -1 跳过;否则统计周围八个方向中 == -1 个数,并将结果赋值给 mines[r][c]

  4. openCell(int r, int c)

    • 根据 (r,c) 坐标:

      1. isLostisWin 已经结束,或索引越界,或该格状态不为 HIDDEN(可能已打开或已标记),直接返回;

      2. 如果 mines[r][c] == -1(地雷),设置 isLost=true 并调用 revealAllMines() 展示所有地雷;

      3. 否则调用 floodFillOpen(r, c) 根据周围数字连击打开并更新 openedCount

      4. 如果 openedCount == rows*cols - numMines,则全部非雷格子都打开,设置 isWin=true

  5. floodFillOpen(int r, int c)

    • 使用 Queue<Point> 实现广度优先搜索:

      1. 初始将 (r,c) 加入队列;

      2. 弹出队列头 p,检查索引合法性及状态是否为 HIDDEN,否则跳过;

      3. 将其状态 states[row][col] = REVEALEDopenedCount++

      4. 如果该格 mines[row][col] == 0,说明周围无雷,将其周围 8 个格子中状态为 HIDDEN 且索引合法的加入队列;

      5. 重复直到队列为空,完成“0 区域”连锁打开与周边数字格单独打开。

  6. toggleFlag(int r, int c)

    • isLost || isWin,或索引越界,或该格已打开(REVEALED),直接返回;否则根据当前 states[r][c]

      • HIDDEN → FLAGGED,标记旗帜,flaggedCount++

      • FLAGGED → QUESTIONED,拆除旗帜,显示问号,flaggedCount--

      • QUESTIONED → HIDDEN,恢复隐藏。

  7. revealAllMines()

    • 失败时遍历 mines 数组,将所有 mines[r][c] == -1 的格子 states[r][c] = REVEALED,供界面显示所有地雷。

  8. getCellState、getAdjacentMineCount、isLost、isWin、getRemainingMines、getRows、getCols

    • 一系列访问器方法,供 Controller 与 View 获取模型状态以更新界面。例如 getRemainingMines() = numMines - flaggedCount

5.2 view/MineCell.java

  1. 构造函数

    • 接收 (r, c, mineField) 参数,调用 initialize()

      1. setPreferredSize(new Dimension(24,24)) 固定格子大小;

      2. setBackground(Color.LIGHT_GRAY) 设置初始背景色;

      3. setBorder(BorderFactory.createBevelBorder(0)) 设置浮雕边框;

      4. setFocusPainted(false) 取消焦点框;

      5. updateAppearance() 根据模型状态初次设置图标。

  2. updateAppearance()

    • 调用 mineField.getCellState(row, col) 获取当前 CellState

    • 根据状态切换图标:

      • HIDDENhidden.png,并 setEnabled(true)

      • REVEALED:先调用 mineField.getAdjacentMineCount(row, col),若返回 -1,为地雷,则 mine.png;若>0,使用 num_x.png;若=0,使用 empty.png;并 setEnabled(false) 禁用按钮;

      • FLAGGEDflag.png,并 setEnabled(true)

      • QUESTIONEDquestion.png,并 setEnabled(true)

  3. getRow()、getCol()

    • 返回该格子在网格中的行列索引,供 Controller 中事件处理使用。

5.3 view/MinePanel.java

  1. 构造函数

    • 接收 MineField mineField,根据 rows=mineField.getRows()cols=mineField.getCols()

      1. setLayout(new GridLayout(rows, cols, 1, 1)),创建网格布局并留 1px 间隙;

      2. setBackground(Color.GRAY) 设置面板背景,以显示格子间隙;

      3. 初始化 cells = new MineCell[rows][cols],嵌套循环 (r,c)

MineCell cell = new MineCell(r, c, mineField);
cells[r][c] = cell;
add(cell);  // 将 JButton 添加到网格中

 

    • 这样所有 MineCell 按行列顺序添加到 MinePanel

  1. refreshAllCells()

    • 遍历 cells[i][j],调用 cell.updateAppearance() 更新图标,保证界面与模型同步。

  2. getCells()

    • 返回 MineCell[][] 引用,供 Controller 添加鼠标监听与与格子交互。

5.4 view/StatusBar.java

  1. 构造函数

    • 接收 initialMines,设置布局为 new FlowLayout(FlowLayout.CENTER, 20, 5)

    • 创建 lblMines = new JLabel(String.format("雷数: %03d", initialMines)),并 setFont(new Font("Consolas", Font.BOLD, 18))

    • 创建 lblTime = new JLabel("时间: 000"),并相同方式设置字体;

    • 创建 btnReset = new JButton(ResourceLoader.getIcon("reset.png")),并 setToolTipText("重置游戏")

    • elapsedTime = 0timer = new Timer(1000, e -> { elapsedTime++; lblTime.setText(String.format("时间: %03d", elapsedTime)); }); timer.setInitialDelay(0); timer.start();

    • lblMineslblTimebtnReset 按顺序 add(...) 添加到状态栏面板。

  2. updateRemainingMines(int remainingMines)

    • lblMines.setText(String.format("雷数: %03d", remainingMines)); 更新地雷剩余计数。

  3. stopTimer()

    • 如果 timer.isRunning(),调用 timer.stop() 停止计时。

  4. resetTimer()

    • 重置 elapsedTime = 0lblTime.setText("时间: 000"),并调用 timer.restart() 重新开始。

  5. getResetButton()

    • 返回 btnReset 用于 Controller 绑定监听,点击重置游戏。

5.5 controller/GameController.java

  1. 构造函数

    • 注入 MineField mineField, MinePanel minePanel, StatusBar statusBar

    • 调用 attachCellListeners()attachResetListener() 分别为格子与重置按钮绑定事件。

  2. attachCellListeners()

    • 获取 MineCell[][] cells = minePanel.getCells()

    • 双重循环每个 MineCell cell = cells[r][c]

      • cell.addMouseListener(new MouseAdapter() { mousePressed, mouseReleased })

        • mousePressed:鼠标左键按下且游戏进行中,用 highlightNeighbors(r,c,true) 高亮当前及周围 8 格;

        • mouseReleased:游戏进行中时,highlightNeighbors(r,c,false) 恢复原状;根据鼠标按钮:

          • BUTTON1(左键):调用 mineField.openCell(r,c)

          • BUTTON3(右键):调用 mineField.toggleFlag(r,c)

          • 然后调用 updateViewAndStatus()

  3. highlightNeighbors(int r, int c, boolean highlight)

    • 计算 rows=mineField.getRows(), cols=mineField.getCols()

    • 遍历 dr=-1..1, dc=-1..1,跳过 (0,0);计算 nr=r+dr, nc=c+dc;如果在有效范围且 mineField.getCellState(nr,nc) == HIDDEN

      • highlightneighbor.setBackground(Color.GRAY.brighter())

      • 否则,neighbor.setBackground(Color.LIGHT_GRAY)

  4. updateViewAndStatus()

    • minePanel.refreshAllCells() 调用每个 MineCell.updateAppearance()

    • statusBar.updateRemainingMines(mineField.getRemainingMines()) 更新剩余地雷数;

    • 如果 mineField.isLost()

      • statusBar.stopTimer() 停止定时;

      • JOptionPane.showMessageDialog(null, "很遗憾...","游戏失败", INFORMATION_MESSAGE) 弹出失败提示;

    • 否则如果 mineField.isWin()

      • 停止定时并弹出胜利提示。

  5. attachResetListener()

    • statusBar.getResetButton().addActionListener(e -> resetGame());,重置游戏。

  6. resetGame()

    • 调用 mineField.resetField() 重置模型;

    • minePanel.refreshAllCells() 刷新格子;

    • statusBar.updateRemainingMines(mineField.getRemainingMines()) 更新地雷数;

    • statusBar.resetTimer() 重置计时器。

  7. startNewGame(int rows, int cols, int numMines)

    • 创建新的 MineField newField = new MineField(rows, cols, numMines);

    • 获取当前窗口 Window win = SwingUtilities.getWindowAncestor(minePanel),若为 Frame,则移除原有内容,构造新的 MinePanel newPanel = new MinePanel(newField),新的 StatusBar newStatus = new StatusBar(newField.getRemainingMines()),并创建新的 GameController(newField, newPanel, newStatus);添加工具栏、newPanelnewStatusframe,并 frame.pack()frame.setLocationRelativeTo(null)

  8. showCustomDialog(Frame parent)

    • 创建三个 JTextField txtRows, txtCols, txtMines

    • 使用 JOptionPane.showConfirmDialog(parent, fields, "自定义难度", OK_CANCEL_OPTION) 弹出对话框;

    • 确认后解析输入的行、列、地雷数,如果合法调用 startNewGame(r,c,m),否则弹出错误提示;

5.6 util/ResourceLoader.java

  1. getIcon(String name)

    • 使用 ResourceLoader.class.getResource("/icons/" + name) 获取资源路径;

    • 如果不为 null,使用 new ImageIcon(url) 加载图像,再调用 .getImage().getScaledInstance(24,24,Image.SCALE_SMOOTH) 缩放为 24×24 像素,返回 new ImageIcon(img)

    • 否则返回 null

5.7 MineSweeperApp.java

  1. main 方法

    • SwingUtilities.invokeLater(() -> createAndShowUI()) 确保 Swing 在 EDT 上启动;

  2. createAndShowUI()

    • 新建 JFrame frame = new JFrame("Java 扫雷游戏")setDefaultCloseOperation(EXIT_ON_CLOSE)setLayout(new BorderLayout())

    • 默认初级难度 MineField mineField = new MineField(9, 9, 10)MinePanel minePanel = new MinePanel(mineField)StatusBar statusBar = new StatusBar(mineField.getRemainingMines())GameController controller = new GameController(mineField, minePanel, statusBar)

    • 构造菜单栏:JMenuBar menuBar = new JMenuBar()JMenu gameMenu = new JMenu("游戏"),添加 JMenuItem newGameItem, beginnerItem, intermediateItem, expertItem, customItem, exitItem

    • 绑定事件:newGameItem.addActionListener(e -> controller.resetGame())beginnerItem.addActionListener(e -> controller.startNewGame(9,9,10)) 等;

    • 工具栏:JToolBar toolBar = new JToolBar()JButton btnNew = new JButton(ResourceLoader.getIcon("reset.png"))btnNew.setToolTipText("新游戏 (F2)")btnNew.addActionListener(e -> controller.resetGame())toolBar.add(btnNew)

    • toolBar 放北、minePanel 放中、statusBar 放南;frame.pack()frame.setResizable(false)frame.setLocationRelativeTo(null)frame.setVisible(true)

5.8 model/MineFieldTest.java

  1. testPlaceMinesRandomly()

    • 构造 MineField mf = new MineField(5,5,5)

    • 通过反射或添加 getter 获取 mf.mines 数组,统计 -1 出现次数是否为 5 且位置不重复;

  2. testComputeAdjacentCounts()

    • 构造 MineField(3,3,1),手动将地雷放在 (1,1),调用 computeAdjacentCounts(),预期所有非地雷位置数字都为 1;

  3. testFloodFillOpen()

    • 构造 MineField(3,3,0)(无雷),手动将 mines 全设为 0,并 states 全设为 HIDDEN;调用 openCell(1,1),预期所有格子都变为 REVEALEDisWin() 返回 true

  4. testLoseCondition()

    • 构造 MineField(2,2,1),手动将 (0,0) 设为地雷,statesHIDDEN;调用 openCell(0,0)isLost()true

  5. testFlagging()

    • 构造 MineField(2,2,1),校验 getRemainingMines()==1

    • 调用 toggleFlag(0,0),验证 states[0][0]==FLAGGED && getRemainingMines()==0

    • 再次 toggleFlag(0,0),验证 states[0][0]==QUESTIONED && getRemainingMines()==1

    • 再次 toggleFlag(0,0),验证 states[0][0]==HIDDEN && getRemainingMines()==1

  6. testInvalidConstructor()

    • 调用 new MineField(0,5,10),索引非法,预期抛 IllegalArgumentException

  7. testOpenInvalidCell()

    • 调用 openCell(5,5) 越界,抛 IndexOutOfBoundsException


6、项目详细总结

通过本篇“Java 实现扫雷游戏”项目,从 MVC 架构设计到核心算法,再到完整代码与测试用例,我们完成了一个从零实现经典桌面扫雷游戏的全过程。以下几点值得总结:

6.1 项目架构与分层设计

  • Model(模型)MineField 只负责保存游戏状态(地雷分布、格子状态)、提供打开、标记、重置等核心逻辑。数据与业务逻辑与界面完全解耦,便于后续单元测试与扩展。

  • View(视图)MinePanelMineCell 只关注如何将当前模型状态可视化为格子图标,不包含任何业务逻辑;StatusBar 显示计时与剩余地雷数、提供重置按钮界面。利用 Swing 组件与布局管理器,构建整洁界面。

  • Controller(控制器)GameController 负责监听用户鼠标事件与菜单项、按钮点击,调用 MineField 更新模型,调用 MinePanelStatusBar 刷新视图,实现消息驱动(Model→View)与命令驱动(View→Model)的双向交互。

  • 此种 MVC 分层可使代码各部分职责单一、耦合度低,有利于后续功能增加或界面改版。

6.2 关键算法与实现技巧

  1. 随机布雷:通过洗牌法一次性随机打乱所有格子索引,并取前 numMines 个位置放雷,保证了随机性与效率;

  2. 周围数字统计:对每个非雷格子遍历其相邻 8 个格子,统计雷数并赋值;此操作时间复杂度为 O(N×8)≈O(N),N=总格子数;

  3. 递归连开/广度优先(Flood Fill):当点击 mines[r][c] == 0 时,需要连锁打开周围所有无雷区域,采用广度优先队列法避免深度递归栈溢出;

  4. 状态机与枚举:借助 CellState 枚举记录每格状态,简化逻辑分支;在 toggleFlag 方法中通过枚举循环切换 HIDDEN→FLAGGED→QUESTIONED→HIDDEN

  5. 视图更新MineCell.updateAppearance() 根据 CellStatemines 数组动态切换图标,并禁用已打开格子按钮;

  6. 高亮效果:当鼠标左键按下时,通过改变背景色高亮显示当前及周围格子,提供即时视觉反馈;松开后恢复原状并执行打开操作;

6.3 GUI 交互与用户体验

  • 菜单栏与工具栏提供“新游戏”“难度切换”“退出”等常用操作,方便用户进行游戏流程管理;

  • 状态栏实时显示剩余地雷数与计时器,玩家可清晰掌握游戏进度;

  • 鼠标左键与右键功能区分明确:左键打开,右键标记;

  • 游戏失败或胜利时弹窗提示并停止计时,用户可快速重置或切换难度;

  • 数字使用不同颜色区分,更具可读性;格子间留 1 像素缝隙,增强视觉层次感;

6.4 单元测试覆盖

  • 通过对 MineField 的单元测试可保证模型层逻辑(布雷、统计、连开、标记、胜负判定)正确;

  • 测试覆盖了正常场景与边界条件(非法参数、索引越界),提高了模型的鲁棒性;

  • 后续如需修改业务逻辑,只需重新运行测试,对比行为是否符合预期。

6.5 开发与调试经验

  • 在开发初期,先设计好 MineField 类,完成随机布雷与连开算法,经在控制台中打印数据结构验证正确性;

  • 再逐步开发 MineCellMinePanel,最初可使用不同背景色或文本标识代替图标,快速完成功能验证;

  • 在完成基本功能后,再替换为精美图标,并添加高亮与美化操作;

  • 最后完善菜单、工具栏、状态栏、单元测试与自定义难度对话框等附加功能,提升游戏体验;


7、项目常见问题及解答

  1. 问:为什么要使用广度优先(Queue)而不是递归调用来实现连开?
    答:

    • 递归方式在连开深度过大(例如大面积空格)时,容易出现调用栈深度过多导致 StackOverflowError

    • 广度优先使用 Queue 手动维护待打开的格子列表,避免递归调用,内存开销较小且更稳定;

  2. 问:如何避免随机放雷时重复地雷位置?
    答:

    • 最好使用“洗牌法”:先将所有 rows*cols 个格子索引装入 List,打乱顺序后前 numMines 个唯一;

    • 另一种方法是利用 Set<Point> 检查重复:循环 rand.nextInt(rows), rand.nextInt(cols) 直到得到 numMines 个不重复坐标;

    • 洗牌法的时间复杂度固定为 O(N),而随机取值法在雷密度高时可能重复较多,效率较低;

  3. 问:为什么要使用 CellState 枚举?直接用整数表示状态不行吗?
    答:

    • 枚举能让代码更具可读性与可维护性,例如 CellState.REVEALEDstate[r][c] = 2 更直观;

    • 枚举可限制状态值范围、防止非法赋值,并且在 switch 语句中可对所有枚举值强制写入分支,避免遗漏;

  4. 问:如何动态调整格子大小或窗口大小?
    答:

    • 当前实现中格子大小固定为 24×24 像素,MineCell 调用 setPreferredSize(new Dimension(24,24))

    • 如果需要动态缩放,可将图标改为矢量图或动态调用 getScaledInstance 根据面板大小重新计算像素;

    • 也可以使用 frame.setResizable(true) 允许窗口缩放,在 MineCell 中根据 getWidth()getHeight() 动态绘制图标,需重写 paintComponent

  5. 问:为什么 toggleFlag 方法中要判断 states[r][c] == CellState.REVEALED 才不允许标记?
    答:

    • 如果格子已打开并显示数字,玩家不能再对其进行旗帜或问号标记,否则逻辑上会造成混乱;

    • 典型 Windows 扫雷中,已打开的格子不允许右键标记,只允许对未打开或已标记的格子点击进行状态切换;

  6. 问:胜利条件为什么不直接判断 flaggedCount == numMines
    答:

    • 仅仅旗帜数量等于地雷数并不保证玩家准确标记了所有地雷,可能出现错误标记;

    • 正确判断胜利的两种方式:

      1. 所有非地雷格子都已打开:当 openedCount == rows*cols - numMines,说明只剩下的格子全是地雷,不需要玩家全部标记出雷;

      2. 所有地雷格子都被标记:若要通过标记判定胜利,需要同时检查 flaggedCount == numMines 并且对所有 mines[r][c] == -1 都有 states[r][c] == FLAGGED

    • 通常使用第一个条件,因为玩家只需打开所有安全区域就可获得胜利,无需准确标记每个地雷。

  7. 问:为何在 GameController.startNewGame() 中要重建 MinePanel 与 StatusBar?
    答:

    • 不同难度下 rowscols 数量变化,原有 GridLayout 大小不再匹配,需要创建新的 MinePanel(rows,cols)

    • 同样地,StatusBar 中的初始剩余地雷数也需根据新 numMines 更新;

    • 整个 JFrame 内容清空、重新添加也能确保组件布局正常、避免旧组件事件监听残留;

  8. 问:如何防止用户在游戏过程中点击重复格子消耗性能?
    答:

    • openCell(int r,int c) 方法首先检查 states[r][c] != CellState.HIDDEN 时直接返回,避免在已打开或已标记格子执行重复计算;

    • floodFillOpen 也会检查 states[row][col] != CellState.HIDDEN 跳过重复入队;

    • 这样可以避免连开算法对同一格子多次操作,保证性能。

  9. 问:资源加载失败时怎么办?ResourceLoader.getIcon 返回 null 会怎样?
    答:

    • 如果图标资源放置不正确或路径错误,url == nullgetIcon 返回 null

    • 此时 JButton.setIcon(null) 不会抛异常,但格子按钮不会显示图标;可在开发中在控制台打印错误日志,便于排查资源路径问题;

    • 最好在项目打包时确保 resources/icons/ 内所有图标正确打入 classpath。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值