项目背景详细介绍
扫雷(Minesweeper)是一款经典的单人益智游戏,最早随 Windows 操作系统一同发布,凭借简单易上手却富含策略性的游戏体验,一度风靡全球。游戏规则并不复杂:在一个由方格组成的棋盘中,隐藏着若干地雷。玩家需要根据每个已打开方块显示的数字(代表周围 8 个格子中地雷的数量),推断出哪些方块安全可点,哪些方块可能藏雷,直到将所有非地雷格子全部打开或标记出所有地雷为止。
实现一款完整的扫雷游戏,对初学 Java 或中级开发者都有很大意义:
-
GUI 编程练习:典型的扫雷界面基于 Swing/AWT 构建,包含自定义绘制、事件监听、布局管理、定时器等要素,可以帮助学习者深入掌握 Java 桌面 GUI 编程。
-
游戏逻辑设计:扫雷核心在于二维数组的递归翻开(连击)算法、数字统计、旗帜标记、胜负判定等,有助于学习者巩固算法与数据结构基础。
-
MVC 分层思维:可以通过分离数据模型(Model)、界面视图(View)和业务逻辑(Controller),提升软件可维护性和可扩展性。
-
用户体验优化:要从界面布局、美术资源、用户交互反馈(如鼠标右键标记,左键打开),以及计时器、剩余地雷显示等细节入手,培养对用户体验的关注。
-
项目管理与可扩展性:通过本项目可学习如何组织一个较为完整的中型项目、如何划分包和类、如何撰写详细注释、如何设计可选难度模式和保存成绩等功能。
本项目旨在以“Java 实现扫雷游戏”为主题,提供一个从零开始的完整示例,从项目背景、需求分析、相关技术、实现思路、完整代码、代码详细解读、项目总结、常见问题及解答,以及扩展方向与性能优化等九个方面,做全方位、极其详细的介绍。文章全文约 10000 汉字,适合用作博客、课堂讲义或团队分享示例。所有代码集中在一个代码块中,并配有细致注释,便于复制、阅读与二次教学。
1、项目需求详细介绍
基于传统 Windows 版本“扫雷”游戏功能与玩法,本项目需要实现一个具有下列功能的桌面版扫雷游戏:
1.1 基本功能需求
-
难度设置
-
提供三种默认难度:
-
初级:9×9 方格,10 个地雷;
-
中级:16×16 方格,40 个地雷;
-
高级:30×16 方格,99 个地雷;
-
-
用户可选择或通过菜单切换难度。
-
可支持自定义行数、列数、地雷数。
-
-
游戏面板
-
使用 Swing 窗口(
JFrame
)创建主游戏窗口,窗口包含菜单栏、工具栏、状态栏和游戏网格区。 -
网格区由若干
Cell
(格子)组件构成,每个格子为JButton
或自定义绘制组件。 -
初始状态:所有格子均为“未打开”状态,显示灰色阴影或默认图标。
-
-
点击操作
-
左键点击:
-
如果点击的格子是地雷,游戏立即失败,自动打开所有地雷并显示“游戏失败”提示;
-
如果格子周围有数字(即相邻 8 个格子中有地雷),则显示该数字;
-
如果格子相邻没有地雷(数字为 0),则自动“连开”(递归/广度优先或深度优先方式)所有与之相连、且周围数字为 0 的格子边界,直到数字大于 0 的格子为止。
-
-
右键点击:
-
将当前格子标记为旗帜(旗子图标),表示“我认为这里有地雷”;
-
再次右键点击可以切换为问号状态(可选),再次右键恢复为未标记状态。
-
-
当所有非地雷格子都已打开时,游戏胜利,弹出“游戏胜利”提示并停止计时。
-
-
计时与地雷剩余提示
-
窗口上方或下方显示三个面板:
-
“剩余地雷数”计数器:初始为难度对应的地雷总数,每标记一个旗帜显示减 1,移除旗帜则加 1;
-
“计时器”:游戏开始时自动从零开始计时,每秒递增,在游戏结束后停止;
-
“新游戏按钮”:可随时重置游戏,重新随机布置地雷并清除计时与标记。
-
-
支持键盘快捷键(如按 F2 开启新游戏)或菜单选项“游戏 → 新游戏”“游戏 → 退出”等。
-
-
菜单与工具栏
-
菜单栏包含:
-
“游戏”菜单:新游戏、初级、中级、高级、自定义、退出;
-
“帮助”菜单:关于,显示作者信息与版本提示。
-
-
工具栏可放置“新游戏”、“提示”、“重置计时”等图标按钮。
-
-
用户体验细节
-
点击未打开格时,鼠标指针变为按下效果;游戏失败时,所有未标记地雷处显示扫雷炸弹标志,已标记但未放雷处显示错误标记(X);
-
格子数字使用不同颜色表示:1 为蓝色,2 为绿色,3 为红色,4 为深蓝,5 为棕色,6 为青色,7 为黑色,8 为灰色;
-
鼠标左键按下时将当前格及四周八个相邻格进行临时高亮显示;松开时执行实际点击操作(鼠标按下效果)。
-
支持游戏窗口大小自适应,格子大小固定(如 24×24 像素),在困难难度下横向滚动或缩放界面。
-
-
游戏状态保存(可选)
-
当窗口大小或应用最小化时,可将当前游戏状态(格子打开状态、标记状态、计时数)保存于内存中;
-
用户关闭后再次打开当前程序时,自动恢复上一次游戏(可在配置中设置是否启用该功能)。
-
-
辅助功能(可选)
-
“提示”按钮:点击后,随机打开一个未标记且无雷周围的格子,但每局只能使用一次或若干次;
-
排行榜:记录不同难度下的最快通关时间,可将记录保存在本地文件或序列化对象中,并在“帮助”菜单中显示。
-
-
性能要求
-
在最大难度(30×16、99 雷)下,点击连开操作需要瞬时完成,界面无明显卡顿;
-
资源占用尽量低,支持绝大多数主流桌面环境运行(JDK 1.8+)。
-
-
文档说明
-
本篇博文及示例代码提供完整的项目结构与依赖说明,便于读者一键复制、在 IDE(IntelliJ IDEA、Eclipse)中直接运行。
-
2、相关技术详细介绍
实现扫雷游戏需要掌握以下关键技术与概念:
2.1 Java Swing GUI 编程
-
顶层容器与窗口
-
JFrame
:顶层窗口容器,用于承载菜单栏、工具栏、游戏面板、状态栏等组件; -
JDialog
:弹出对话框,用于“游戏胜利”“游戏失败”“自定义难度”“关于”等信息提示;
-
-
布局管理器
-
BorderLayout
:将组件分布在北(N)、南(S)、东(E)、西(W)、中(Center)五个区域; -
GridLayout
:将面板划分为指定行列的网格,本项目用于将游戏格子面板划分为rows × cols
; -
FlowLayout
、BoxLayout
:用于工具栏、状态栏的水平或垂直排列;
-
-
组件
-
JPanel
:最常用的容器面板,支持设置不同布局管理器、边框、背景色; -
JButton
:可自定义背景图标,用于每个格子的交互;可以通过setIcon
、setDisabledIcon
、setBorderPainted(false)
等方法实现纯图形效果; -
JLabel
:用于显示文本或图像,例如状态栏中的剩余地雷数与计时; -
JMenuBar
、JMenu
、JMenuItem
:用于创建菜单栏、菜单项;可绑定ActionListener
响应菜单点击; -
JToolBar
:工具栏,可包含按钮图标,实现快速操作;
-
-
图标与图像处理
-
使用
ImageIcon
加载 PNG、GIF 等格式图像资源,用于未打开、已打开、旗帜、地雷、数字 1~8、问号等图标; -
采用
ClassLoader.getResource(...)
或绝对路径加载资源,确保跨平台兼容; -
对图标进行缩放(
getScaledInstance
)以适应格子大小;
-
-
事件监听
-
鼠标事件:给每个格子
Cell
组件添加MouseListener
(尤其关注mousePressed
、mouseReleased
、mouseClicked
);-
mousePressed
:鼠标按下时触发,用于实现鼠标按下高亮效果; -
mouseReleased
:鼠标抬起时触发,判断左键或右键,执行打开或标记操作;
-
-
动作事件:菜单项(
JMenuItem
)与工具栏按钮(JButton
)使用ActionListener
响应点击;
-
-
定时器
-
使用
javax.swing.Timer
创建 Swing 线程安全的定时器(参数为每 1 秒触发一次),在 actionPerformed 中更新状态栏上的计时数字; -
游戏开始时启动定时器,游戏结束(胜利或失败)时停止定时器;可在重置或新游戏时重置计时为 0;
-
-
自定义绘制(可选)
-
若需要更灵活的视觉效果,可对
Cell
写成继承自JPanel
或JComponent
,重写paintComponent(Graphics g)
方法自行绘制地雷、数字与背景; -
需要在
repaint()
与validate()
之间取舍,保证刷新效率与视觉效果。
-
2.2 数据模型与业务逻辑(Model)
-
二维数组表示棋盘
-
使用
int[][] mines
数组表示格子状态:-
-1
:该格为地雷; -
0~8
:该格周围 8 个格子中地雷的数量;
-
-
使用
boolean[][] opened
数组表示格子是否已打开; -
使用
boolean[][] flagged
数组表示格子是否已标记旗帜; -
使用
boolean[][] questioned
数组表示是否处于问号状态(可选);
-
-
随机布雷算法
-
在新游戏开始时,根据难度对应的地雷总数
numMines
,在rows × cols
总格子中随机选择numMines
个不同的坐标放置地雷; -
实现方式:
-
洗牌法:将所有
rows*cols
个序号保存到List<Integer>
中,打乱顺序,然后取前numMines
个索引;索引到(index / cols, index % cols)
放置雷; -
随机取值法:使用
Random rand = new Random()
,不断rand.nextInt(rows)
与rand.nextInt(cols)
随机生成坐标,若该格还没有地雷,则放雷;重复直到放满即可;
-
-
布雷完成后,根据每个格子,统计其周围 8 个格子(注意边界检查)包含地雷的个数,将数值存入
mines[r][c]
;
-
-
递归连开(Flood Fill)算法
-
当玩家点击一个
mines[r][c] == 0
的格子时,需要连开它周围所有未打开且非地雷的格子,递归或迭代方式均可:-
深度优先:若当前格周围没有地雷,则打开所有相邻 8 个格子,若相邻格子也是
mines[nr][nc] == 0
,继续递归; -
广度优先:使用
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
类中的以下逻辑进行单元测试:-
随机布雷测试:
-
给定固定
rows=5, cols=5, numMines=5
,调用resetField
(或构造函数),查看mines
数组中是否恰好有 5 个-1
; -
多次调用,确保每次地雷随机分布不同。
-
-
周围数字统计测试:
-
手动创建一个已知地雷位置的
mines
数组(可通过反射或特定setMines
方法),调用computeAdjacentCounts
,验证每个非地雷格子数字是否等于周围实际地雷数。
-
-
连开算法测试:
-
构造一个
3×3
无雷区域,调用openCell(r, c)
并判断opened[r][c]
及其周围是否全部打开; -
构造带有部分雷的位置,测试如果数字不为 0 是否仅打开该格。
-
-
胜负判定测试:
-
手动打开所有非雷格子时
isWin()
应返回true
; -
点击雷时
isLost()
应返回true
。
-
-
标记旗帜测试:
-
对同一格子多次调用
toggleFlag(r, c)
,检查flagged[r][c]
状态翻转,并且remainingMines
更新正确;
-
-
边界条件测试:
-
对超出范围索引调用
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
-
构造函数与重置
-
构造函数校验传入参数:行、列与地雷数必须正整数且
numMines < rows*cols
,否则抛IllegalArgumentException
。 -
resetField()
方法:-
重建
mines
与states
数组,重置计数器openedCount=0
、flaggedCount=0
,重置游戏状态isLost=false
,isWin=false
; -
将所有
states[r][c]
设为CellState.HIDDEN
; -
调用
placeMinesRandomly()
随机布雷,将mines[r][c] = -1
表示地雷; -
调用
computeAdjacentCounts()
计算每个非雷格子周围地雷数并赋值。
-
-
-
placeMinesRandomly()
-
创建
List<Integer>
,将0~rows*cols-1
全部添加后调用Collections.shuffle
; -
取前
numMines
个索引,转换为(r = idx/cols, c = idx%cols)
放置地雷,避免重复。
-
-
computeAdjacentCounts()
-
遍历
mines
数组,若mines[r][c] == -1
跳过;否则统计周围八个方向中== -1
个数,并将结果赋值给mines[r][c]
。
-
-
openCell(int r, int c)
-
根据
(r,c)
坐标:-
若
isLost
或isWin
已经结束,或索引越界,或该格状态不为HIDDEN
(可能已打开或已标记),直接返回; -
如果
mines[r][c] == -1
(地雷),设置isLost=true
并调用revealAllMines()
展示所有地雷; -
否则调用
floodFillOpen(r, c)
根据周围数字连击打开并更新openedCount
; -
如果
openedCount == rows*cols - numMines
,则全部非雷格子都打开,设置isWin=true
。
-
-
-
floodFillOpen(int r, int c)
-
使用
Queue<Point>
实现广度优先搜索:-
初始将
(r,c)
加入队列; -
弹出队列头
p
,检查索引合法性及状态是否为HIDDEN
,否则跳过; -
将其状态
states[row][col] = REVEALED
并openedCount++
; -
如果该格
mines[row][col] == 0
,说明周围无雷,将其周围 8 个格子中状态为HIDDEN
且索引合法的加入队列; -
重复直到队列为空,完成“0 区域”连锁打开与周边数字格单独打开。
-
-
-
toggleFlag(int r, int c)
-
若
isLost || isWin
,或索引越界,或该格已打开(REVEALED
),直接返回;否则根据当前states[r][c]
:-
HIDDEN → FLAGGED
,标记旗帜,flaggedCount++
; -
FLAGGED → QUESTIONED
,拆除旗帜,显示问号,flaggedCount--
; -
QUESTIONED → HIDDEN
,恢复隐藏。
-
-
-
revealAllMines()
-
失败时遍历
mines
数组,将所有mines[r][c] == -1
的格子states[r][c] = REVEALED
,供界面显示所有地雷。
-
-
getCellState、getAdjacentMineCount、isLost、isWin、getRemainingMines、getRows、getCols
-
一系列访问器方法,供 Controller 与 View 获取模型状态以更新界面。例如
getRemainingMines() = numMines - flaggedCount
。
-
5.2 view/MineCell.java
-
构造函数
-
接收
(r, c, mineField)
参数,调用initialize()
:-
setPreferredSize(new Dimension(24,24))
固定格子大小; -
setBackground(Color.LIGHT_GRAY)
设置初始背景色; -
setBorder(BorderFactory.createBevelBorder(0))
设置浮雕边框; -
setFocusPainted(false)
取消焦点框; -
updateAppearance()
根据模型状态初次设置图标。
-
-
-
updateAppearance()
-
调用
mineField.getCellState(row, col)
获取当前CellState
; -
根据状态切换图标:
-
HIDDEN
:hidden.png
,并setEnabled(true)
; -
REVEALED
:先调用mineField.getAdjacentMineCount(row, col)
,若返回-1
,为地雷,则mine.png
;若>0,使用num_x.png
;若=0,使用empty.png
;并setEnabled(false)
禁用按钮; -
FLAGGED
:flag.png
,并setEnabled(true)
; -
QUESTIONED
:question.png
,并setEnabled(true)
;
-
-
-
getRow()、getCol()
-
返回该格子在网格中的行列索引,供 Controller 中事件处理使用。
-
5.3 view/MinePanel.java
-
构造函数
-
接收
MineField mineField
,根据rows=mineField.getRows()
与cols=mineField.getCols()
:-
setLayout(new GridLayout(rows, cols, 1, 1))
,创建网格布局并留 1px 间隙; -
setBackground(Color.GRAY)
设置面板背景,以显示格子间隙; -
初始化
cells = new MineCell[rows][cols]
,嵌套循环(r,c)
:
-
-
MineCell cell = new MineCell(r, c, mineField);
cells[r][c] = cell;
add(cell); // 将 JButton 添加到网格中
-
-
这样所有
MineCell
按行列顺序添加到MinePanel
。
-
-
refreshAllCells()
-
遍历
cells[i][j]
,调用cell.updateAppearance()
更新图标,保证界面与模型同步。
-
-
getCells()
-
返回
MineCell[][]
引用,供 Controller 添加鼠标监听与与格子交互。
-
5.4 view/StatusBar.java
-
构造函数
-
接收
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 = 0
,timer = new Timer(1000, e -> { elapsedTime++; lblTime.setText(String.format("时间: %03d", elapsedTime)); }); timer.setInitialDelay(0); timer.start();
-
将
lblMines
、lblTime
、btnReset
按顺序add(...)
添加到状态栏面板。
-
-
updateRemainingMines(int remainingMines)
-
lblMines.setText(String.format("雷数: %03d", remainingMines));
更新地雷剩余计数。
-
-
stopTimer()
-
如果
timer.isRunning()
,调用timer.stop()
停止计时。
-
-
resetTimer()
-
重置
elapsedTime = 0
,lblTime.setText("时间: 000")
,并调用timer.restart()
重新开始。
-
-
getResetButton()
-
返回
btnReset
用于 Controller 绑定监听,点击重置游戏。
-
5.5 controller/GameController.java
-
构造函数
-
注入
MineField mineField, MinePanel minePanel, StatusBar statusBar
; -
调用
attachCellListeners()
与attachResetListener()
分别为格子与重置按钮绑定事件。
-
-
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()
。
-
-
-
-
-
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
:-
若
highlight
,neighbor.setBackground(Color.GRAY.brighter())
; -
否则,
neighbor.setBackground(Color.LIGHT_GRAY)
。
-
-
-
updateViewAndStatus()
-
minePanel.refreshAllCells()
调用每个MineCell.updateAppearance()
; -
statusBar.updateRemainingMines(mineField.getRemainingMines())
更新剩余地雷数; -
如果
mineField.isLost()
:-
statusBar.stopTimer()
停止定时; -
JOptionPane.showMessageDialog(null, "很遗憾...","游戏失败", INFORMATION_MESSAGE)
弹出失败提示;
-
-
否则如果
mineField.isWin()
:-
停止定时并弹出胜利提示。
-
-
-
attachResetListener()
-
statusBar.getResetButton().addActionListener(e -> resetGame());
,重置游戏。
-
-
resetGame()
-
调用
mineField.resetField()
重置模型; -
minePanel.refreshAllCells()
刷新格子; -
statusBar.updateRemainingMines(mineField.getRemainingMines())
更新地雷数; -
statusBar.resetTimer()
重置计时器。
-
-
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)
;添加工具栏、newPanel
、newStatus
到frame
,并frame.pack()
、frame.setLocationRelativeTo(null)
。
-
-
showCustomDialog(Frame parent)
-
创建三个
JTextField txtRows, txtCols, txtMines
; -
使用
JOptionPane.showConfirmDialog(parent, fields, "自定义难度", OK_CANCEL_OPTION)
弹出对话框; -
确认后解析输入的行、列、地雷数,如果合法调用
startNewGame(r,c,m)
,否则弹出错误提示;
-
5.6 util/ResourceLoader.java
-
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
-
main 方法
-
SwingUtilities.invokeLater(() -> createAndShowUI())
确保 Swing 在 EDT 上启动;
-
-
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
-
testPlaceMinesRandomly()
-
构造
MineField mf = new MineField(5,5,5)
; -
通过反射或添加 getter 获取
mf.mines
数组,统计-1
出现次数是否为 5 且位置不重复;
-
-
testComputeAdjacentCounts()
-
构造
MineField(3,3,1)
,手动将地雷放在(1,1)
,调用computeAdjacentCounts()
,预期所有非地雷位置数字都为 1;
-
-
testFloodFillOpen()
-
构造
MineField(3,3,0)
(无雷),手动将mines
全设为 0,并states
全设为HIDDEN
;调用openCell(1,1)
,预期所有格子都变为REVEALED
,isWin()
返回true
;
-
-
testLoseCondition()
-
构造
MineField(2,2,1)
,手动将(0,0)
设为地雷,states
全HIDDEN
;调用openCell(0,0)
,isLost()
为true
。
-
-
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
;
-
-
testInvalidConstructor()
-
调用
new MineField(0,5,10)
,索引非法,预期抛IllegalArgumentException
;
-
-
testOpenInvalidCell()
-
调用
openCell(5,5)
越界,抛IndexOutOfBoundsException
;
-
6、项目详细总结
通过本篇“Java 实现扫雷游戏”项目,从 MVC 架构设计到核心算法,再到完整代码与测试用例,我们完成了一个从零实现经典桌面扫雷游戏的全过程。以下几点值得总结:
6.1 项目架构与分层设计
-
Model(模型):
MineField
只负责保存游戏状态(地雷分布、格子状态)、提供打开、标记、重置等核心逻辑。数据与业务逻辑与界面完全解耦,便于后续单元测试与扩展。 -
View(视图):
MinePanel
与MineCell
只关注如何将当前模型状态可视化为格子图标,不包含任何业务逻辑;StatusBar
显示计时与剩余地雷数、提供重置按钮界面。利用 Swing 组件与布局管理器,构建整洁界面。 -
Controller(控制器):
GameController
负责监听用户鼠标事件与菜单项、按钮点击,调用MineField
更新模型,调用MinePanel
和StatusBar
刷新视图,实现消息驱动(Model→View)与命令驱动(View→Model)的双向交互。 -
此种 MVC 分层可使代码各部分职责单一、耦合度低,有利于后续功能增加或界面改版。
6.2 关键算法与实现技巧
-
随机布雷:通过洗牌法一次性随机打乱所有格子索引,并取前
numMines
个位置放雷,保证了随机性与效率; -
周围数字统计:对每个非雷格子遍历其相邻 8 个格子,统计雷数并赋值;此操作时间复杂度为 O(N×8)≈O(N),N=总格子数;
-
递归连开/广度优先(Flood Fill):当点击
mines[r][c] == 0
时,需要连锁打开周围所有无雷区域,采用广度优先队列法避免深度递归栈溢出; -
状态机与枚举:借助
CellState
枚举记录每格状态,简化逻辑分支;在toggleFlag
方法中通过枚举循环切换HIDDEN→FLAGGED→QUESTIONED→HIDDEN
; -
视图更新:
MineCell.updateAppearance()
根据CellState
和mines
数组动态切换图标,并禁用已打开格子按钮; -
高亮效果:当鼠标左键按下时,通过改变背景色高亮显示当前及周围格子,提供即时视觉反馈;松开后恢复原状并执行打开操作;
6.3 GUI 交互与用户体验
-
菜单栏与工具栏提供“新游戏”“难度切换”“退出”等常用操作,方便用户进行游戏流程管理;
-
状态栏实时显示剩余地雷数与计时器,玩家可清晰掌握游戏进度;
-
鼠标左键与右键功能区分明确:左键打开,右键标记;
-
游戏失败或胜利时弹窗提示并停止计时,用户可快速重置或切换难度;
-
数字使用不同颜色区分,更具可读性;格子间留 1 像素缝隙,增强视觉层次感;
6.4 单元测试覆盖
-
通过对
MineField
的单元测试可保证模型层逻辑(布雷、统计、连开、标记、胜负判定)正确; -
测试覆盖了正常场景与边界条件(非法参数、索引越界),提高了模型的鲁棒性;
-
后续如需修改业务逻辑,只需重新运行测试,对比行为是否符合预期。
6.5 开发与调试经验
-
在开发初期,先设计好
MineField
类,完成随机布雷与连开算法,经在控制台中打印数据结构验证正确性; -
再逐步开发
MineCell
与MinePanel
,最初可使用不同背景色或文本标识代替图标,快速完成功能验证; -
在完成基本功能后,再替换为精美图标,并添加高亮与美化操作;
-
最后完善菜单、工具栏、状态栏、单元测试与自定义难度对话框等附加功能,提升游戏体验;
7、项目常见问题及解答
-
问:为什么要使用广度优先(Queue)而不是递归调用来实现连开?
答:-
递归方式在连开深度过大(例如大面积空格)时,容易出现调用栈深度过多导致
StackOverflowError
; -
广度优先使用
Queue
手动维护待打开的格子列表,避免递归调用,内存开销较小且更稳定;
-
-
问:如何避免随机放雷时重复地雷位置?
答:-
最好使用“洗牌法”:先将所有
rows*cols
个格子索引装入List
,打乱顺序后前numMines
个唯一; -
另一种方法是利用
Set<Point>
检查重复:循环rand.nextInt(rows), rand.nextInt(cols)
直到得到numMines
个不重复坐标; -
洗牌法的时间复杂度固定为 O(N),而随机取值法在雷密度高时可能重复较多,效率较低;
-
-
问:为什么要使用
CellState
枚举?直接用整数表示状态不行吗?
答:-
枚举能让代码更具可读性与可维护性,例如
CellState.REVEALED
比state[r][c] = 2
更直观; -
枚举可限制状态值范围、防止非法赋值,并且在
switch
语句中可对所有枚举值强制写入分支,避免遗漏;
-
-
问:如何动态调整格子大小或窗口大小?
答:-
当前实现中格子大小固定为 24×24 像素,
MineCell
调用setPreferredSize(new Dimension(24,24))
; -
如果需要动态缩放,可将图标改为矢量图或动态调用
getScaledInstance
根据面板大小重新计算像素; -
也可以使用
frame.setResizable(true)
允许窗口缩放,在MineCell
中根据getWidth()
、getHeight()
动态绘制图标,需重写paintComponent
;
-
-
问:为什么
toggleFlag
方法中要判断states[r][c] == CellState.REVEALED
才不允许标记?
答:-
如果格子已打开并显示数字,玩家不能再对其进行旗帜或问号标记,否则逻辑上会造成混乱;
-
典型 Windows 扫雷中,已打开的格子不允许右键标记,只允许对未打开或已标记的格子点击进行状态切换;
-
-
问:胜利条件为什么不直接判断
flaggedCount == numMines
?
答:-
仅仅旗帜数量等于地雷数并不保证玩家准确标记了所有地雷,可能出现错误标记;
-
正确判断胜利的两种方式:
-
所有非地雷格子都已打开:当
openedCount == rows*cols - numMines
,说明只剩下的格子全是地雷,不需要玩家全部标记出雷; -
所有地雷格子都被标记:若要通过标记判定胜利,需要同时检查
flaggedCount == numMines
并且对所有mines[r][c] == -1
都有states[r][c] == FLAGGED
;
-
-
通常使用第一个条件,因为玩家只需打开所有安全区域就可获得胜利,无需准确标记每个地雷。
-
-
问:为何在 GameController.startNewGame() 中要重建 MinePanel 与 StatusBar?
答:-
不同难度下
rows
与cols
数量变化,原有GridLayout
大小不再匹配,需要创建新的MinePanel(rows,cols)
; -
同样地,
StatusBar
中的初始剩余地雷数也需根据新numMines
更新; -
整个
JFrame
内容清空、重新添加也能确保组件布局正常、避免旧组件事件监听残留;
-
-
问:如何防止用户在游戏过程中点击重复格子消耗性能?
答:-
openCell(int r,int c)
方法首先检查states[r][c] != CellState.HIDDEN
时直接返回,避免在已打开或已标记格子执行重复计算; -
floodFillOpen
也会检查states[row][col] != CellState.HIDDEN
跳过重复入队; -
这样可以避免连开算法对同一格子多次操作,保证性能。
-
-
问:资源加载失败时怎么办?
ResourceLoader.getIcon
返回 null 会怎样?
答:-
如果图标资源放置不正确或路径错误,
url == null
,getIcon
返回null
; -
此时
JButton.setIcon(null)
不会抛异常,但格子按钮不会显示图标;可在开发中在控制台打印错误日志,便于排查资源路径问题; -
最好在项目打包时确保
resources/icons/
内所有图标正确打入 classpath。
-