Java Swing扫雷实战:事件驱动与状态管理深度解析
2026/9/23 18:58:28 网站建设 项目流程

简介:这是一份基于Java实现的经典Windows扫雷游戏完整源码工程,面向Java初学者与GUI编程入门者,帮助理解事件驱动、二维数组逻辑设计、递归展开算法及Swing界面布局等核心知识点。资源包含56个文件,主体为28个Java源文件(涵盖主程序、雷区生成、计时器、胜负判定等模块),辅以21个GIF动图(用于按钮状态与爆炸效果)、3个PNG图标、1个可直接运行的JAR包、1个配置文件mine.config及数据库文件,整体压缩包仅74KB,轻量易部署。已有186人学习下载,适合课堂实验、课程设计或自学复现经典小游戏。读者可获得结构清晰的MVC风格代码组织、完整的资源路径管理方案、可配置难度的扫雷逻辑实现,以及开箱即用的图形界面交互体验,是Java GUI开发实践的优质参考范例。

1. 扫雷不是玩具:一个被低估的 Java 工程训练场,它能真实检验你对事件驱动、状态管理与 UI 线程协作的理解深度

“Java 扫雷”四个字在新手眼里是课程设计作业,在面试官眼里是筛选「真写过 GUI、真调过 Swing 线程、真处理过边界状态」的过滤器。它不像控制台计算器那样只考语法,也不像 Spring Boot 项目那样靠框架堆砌——扫雷要求你亲手把「格子点击→雷判定→连通展开→计时统计→胜负弹窗」这一整条用户操作链,用纯 Java(无框架)闭环实现。我见过太多人能背出 AQS 原理却卡在SwingUtilities.invokeLater的嵌套时机上;也见过简历写满微服务却在JButton.setEnabled(false)后忘记重绘导致按钮灰而不响应。这个项目真正难的,从来不是“怎么画格子”,而是“怎么让 100 个格子在 300ms 内同步响应、不卡顿、不漏判、不误判”。它适合两类人:刚学完 Swing 想落地练手的新人,以及准备 Java 面试想补全 GUI 实战短板的中级开发者。如果你的 IDE 里还躺着一个没跑通的MineSweeper.java,今天这篇就是为你写的血泪复盘。


2. 从零搭起扫雷骨架:用最简 Swing 组合实现可交互网格与基础状态流转

2.1 用 JPanel + GridLayout 构建动态可伸缩棋盘,避开绝对定位陷阱

扫雷界面的核心是网格,但新手常犯的第一个错误是用null布局 +setBounds()硬编码每个格子位置。这会导致窗口缩放失效、DPI 适配崩溃、甚至在高分屏上按钮错位。正确做法是用GridLayout让容器自动均分空间:

public class GameBoard extends JPanel { private final int ROWS = 10; private final int COLS = 10; private final JButton[][] buttons; public GameBoard() { setLayout(new GridLayout(ROWS, COLS, 2, 2)); // 行数、列数、水平间距、垂直间距 buttons = new JButton[ROWS][COLS]; for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { JButton btn = new JButton(); btn.setPreferredSize(new Dimension(30, 30)); // 设定最小尺寸,避免压缩 btn.setMargin(new Insets(0, 0, 0, 0)); // 清除内边距,保证格子紧凑 btn.addActionListener(e -> onCellClick(r, c)); buttons[r][c] = btn; add(btn); } } } private void onCellClick(int row, int col) { // 点击逻辑暂空,留待后续填充 } }

提示GridLayoutpreferredSize是关键。Dimension(30,30)不是固定大小,而是“建议最小尺寸”,Swing 会根据容器实际大小按比例缩放。若设为setSize()则破坏布局管理器契约,导致pack()失效。

2.2 用二维布尔数组 + 随机种子实现可复现的雷区生成,拒绝 Math.random() 的玄学翻车

扫雷最易被忽略的细节是“雷的分布必须可复现”。面试时若被问“如何保证同一难度下每次开局雷的位置一致?”,答“用Random”是危险信号——Random默认用系统时间做 seed,无法复现。正确做法是显式传入 seed,并用boolean[][] isMine存储雷位:

public class MineField { private final int ROWS; private final int COLS; private final int MINE_COUNT; private final boolean[][] isMine; private final int[][] neighborCount; // 每格周围雷数 public MineField(int rows, int cols, int mineCount, long seed) { this.ROWS = rows; this.COLS = cols; this.MINE_COUNT = mineCount; this.isMine = new boolean[rows][cols]; this.neighborCount = new int[rows][cols]; Random rand = new Random(seed); // 关键:显式 seed List<Point> allCells = new ArrayList<>(); for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { allCells.add(new Point(r, c)); } } // 随机打乱后取前 MINE_COUNT 个作为雷位 Collections.shuffle(allCells, rand); for (int i = 0; i < mineCount; i++) { Point p = allCells.get(i); isMine[p.x][p.y] = true; } // 预计算每格邻居雷数(避免运行时重复计算) for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (isMine[r][c]) 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; int nc = c + dc; if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && isMine[nr][nc]) { count++; } } } neighborCount[r][c] = count; } } } public boolean isMine(int row, int col) { return isMine[row][col]; } public int getNeighborCount(int row, int col) { return neighborCount[row][col]; } }

参数说明seed参数是核心。实际项目中可将 seed 保存到游戏记录中,或由用户输入生成(如用户名哈希),确保“同一输入必得同一雷局”。neighborCount预计算而非运行时查表,是性能关键——100 格子每次点击都要遍历 8 邻域,预计算省下 90% CPU 时间。

2.3 用自定义 CellState 枚举统一管理格子生命周期,终结 null 和 magic number

格子状态绝不能用intString表示(如0=未开,1=已开,2=标记)。这会导致switch散落在各处、状态转换逻辑碎片化。必须用枚举封装行为:

public enum CellState { COVERED, // 未点击,显示灰色方块 REVEALED, // 已点击,显示数字或雷图标 FLAGGED, // 右键标记旗子 QUESTION; // 右键二次标记问号(可选) public static CellState fromInt(int value) { return switch (value) { case 0 -> COVERED; case 1 -> REVEALED; case 2 -> FLAGGED; case 3 -> QUESTION; default -> COVERED; }; } }

然后在GameBoard中为每个格子维护独立状态:

private final CellState[][] cellStates; private final JLabel[][] labels; // 用于显示数字/雷图标的 JLabel public GameBoard() { // ... 初始化代码 cellStates = new CellState[ROWS][COLS]; labels = new JLabel[ROWS][COLS]; for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { cellStates[r][c] = CellState.COVERED; labels[r][c] = new JLabel("", JLabel.CENTER); labels[r][c].setOpaque(true); labels[r][c].setBackground(Color.LIGHT_GRAY); // 将 label 加入按钮内部,实现“按钮+文字”复合控件 buttons[r][c].setLayout(new BorderLayout()); buttons[r][c].add(labels[r][c], BorderLayout.CENTER); } } }

逻辑说明JLabel嵌入JButton是 Swing 中实现“可点击区域+可变文本”的标准解法。setOpaque(true)确保背景色生效,否则默认透明。状态变更时只更新cellStates[r][c]labels[r][c],UI 与数据严格分离。


3. 点击逻辑闭环:左键展开、右键标记、双击连锁反应的线程安全实现

3.1 左键点击:用递归 flood-fill 实现“空地连通展开”,并规避 StackOverflowError

扫雷最标志性的体验是点击空白格子后,一大片无雷区域自动展开。这本质是图的连通分量搜索,但直接递归可能栈溢出(100×100 网格最坏情况递归深度 10000)。必须改用栈模拟递归:

private void revealCell(int row, int col) { if (!isValidCell(row, col) || cellStates[row][col] != CellState.COVERED) { return; } // 先标记为已揭示,避免重复入栈 cellStates[row][col] = CellState.REVEALED; updateButtonAppearance(row, col); // 若该格子周围雷数为 0,则展开邻域 if (mineField.getNeighborCount(row, col) == 0) { Stack<Point> stack = new Stack<>(); stack.push(new Point(row, col)); while (!stack.isEmpty()) { Point p = stack.pop(); // 遍历 8 邻域 for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; int nr = p.x + dr; int nc = p.y + dc; if (isValidCell(nr, nc) && cellStates[nr][nc] == CellState.COVERED) { cellStates[nr][nc] = CellState.REVEALED; updateButtonAppearance(nr, nc); if (mineField.getNeighborCount(nr, nc) == 0) { stack.push(new Point(nr, nc)); } } } } } } } private boolean isValidCell(int row, int col) { return row >= 0 && row < ROWS && col >= 0 && col < COLS; } private void updateButtonAppearance(int row, int col) { JButton btn = buttons[row][col]; JLabel label = labels[row][col]; if (mineField.isMine(row, col)) { label.setIcon(MINE_ICON); // 雷图标 label.setText(""); btn.setBackground(Color.RED); } else if (mineField.getNeighborCount(row, col) == 0) { label.setText(""); btn.setBackground(Color.WHITE); } else { label.setText(String.valueOf(mineField.getNeighborCount(row, col))); btn.setBackground(Color.WHITE); // 根据数字设置字体颜色(经典扫雷配色) label.setForeground(getNumberColor(mineField.getNeighborCount(row, col))); } }

参数说明getNumberColor()是经典配色映射:1→蓝色、2→绿色、3→红色、4→紫色、5→深红、6→青色、7→黑色、8→灰色。颜色选择直接影响用户体验专业度。

3.2 右键标记:三态循环(未标记→旗→问号→未标记)与游戏状态联动

右键需支持三态切换,且必须与游戏状态联动——比如游戏结束时禁止任何操作:

private void toggleFlag(int row, int col) { if (gameStatus != GameStatus.RUNNING) return; // 游戏结束/开始中禁止操作 switch (cellStates[row][col]) { case COVERED: cellStates[row][col] = CellState.FLAGGED; labels[row][col].setIcon(FLAG_ICON); break; case FLAGGED: cellStates[row][col] = CellState.QUESTION; labels[row][col].setIcon(QUESTION_ICON); break; case QUESTION: cellStates[row][col] = CellState.COVERED; labels[row][col].setIcon(null); labels[row][col].setText(""); break; case REVEALED: // 已揭示格子不可标记 break; } } // 在 onCellClick 中绑定右键 buttons[r][c].addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { if (SwingUtilities.isRightMouseButton(e)) { toggleFlag(r, c); } } });

注意MouseListener而非ActionListener,因为右键不触发ActionEventSwingUtilities.isRightMouseButton(e)是跨平台安全判断,比e.getButton() == MouseEvent.BUTTON3更可靠。

3.3 双击优化:仅当周围已标记雷数等于邻居总数时才触发连锁展开

双击是扫雷高级操作:在已揭示数字格上双击,若其周围未揭示格数 = 数字值,则自动揭示所有未揭示格。这需要实时校验:

private void onCellDoubleClick(int row, int col) { if (cellStates[row][col] != CellState.REVEALED) return; if (mineField.isMine(row, col)) return; // 防止双击雷格 int required = mineField.getNeighborCount(row, col); int flagged = 0; int covered = 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; int nc = col + dc; if (!isValidCell(nr, nc)) continue; if (cellStates[nr][nc] == CellState.FLAGGED) flagged++; else if (cellStates[nr][nc] == CellState.COVERED) covered++; } } if (flagged == required && covered > 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; int nc = col + dc; if (isValidCell(nr, nc) && cellStates[nr][nc] == CellState.COVERED) { revealCell(nr, nc); } } } } }

逻辑说明:双击逻辑必须放在mouseClicked中(而非mousePressed),因为mouseClicked保证了完整按下-释放周期。required == flagged是触发条件,covered > 0防止无意义双击。


4. 状态机与胜负判定:用 GameStatus 枚举驱动全局流程,终结 if-else 泛滥

4.1 定义 GameStatus 枚举,覆盖启动、运行、胜利、失败、暂停五种核心状态

状态混乱是扫雷项目的通病:gameOverwinisPlayingpaused四五个布尔变量互相耦合。必须用单状态机统一:

public enum GameStatus { READY, // 初始化完成,等待首次点击 RUNNING, // 游戏进行中 WON, // 胜利 LOST, // 失败 PAUSED // 暂停(可选) }

并在主控类中集中管理:

private GameStatus gameStatus = GameStatus.READY; private final Timer timer; // 用于计时 private int seconds = 0; public void startGame() { if (gameStatus == GameStatus.READY) { gameStatus = GameStatus.RUNNING; timer.start(); // Swing Timer,每秒触发一次 firstClickTime = System.currentTimeMillis(); } } public void gameOver(boolean isWin) { timer.stop(); gameStatus = isWin ? GameStatus.WON : GameStatus.LOST; showResultDialog(isWin); } private void showResultDialog(boolean isWin) { String title = isWin ? "恭喜获胜!" : "游戏结束"; String message = isWin ? "你用 " + seconds + " 秒完成了扫雷!" : "踩雷了,再接再厉!"; JOptionPane.showMessageDialog(this, message, title, JOptionPane.INFORMATION_MESSAGE); }

参数说明Timer使用javax.swing.Timer而非java.util.Timer,因为它在 Event Dispatch Thread 上执行,避免 Swing 线程安全问题。firstClickTime用于实现“首次点击必安全”逻辑(见下一节)。

4.2 首次点击安全机制:用延迟初始化雷区,确保第一格永远不是雷

这是扫雷规则硬性要求。常见错误是生成雷区后检测首格是否为雷再重生成——这破坏随机性。正确做法是:首次点击时,以该格为中心,生成一个“排除该格及其邻域”的雷区:

private void handleFirstClick(int row, int col) { // 构建安全区域:首格及其8邻域不能有雷 Set<Point> safeArea = new HashSet<>(); for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { int nr = row + dr; int nc = col + dc; if (isValidCell(nr, nc)) { safeArea.add(new Point(nr, nc)); } } } // 生成雷区时避开 safeArea Random rand = new Random(System.currentTimeMillis()); // 此处可用时间 seed,因只需一次 List<Point> allCells = new ArrayList<>(); for (int r = 0; r < ROWS; r++) { for (int c = 0; c < COLS; c++) { if (!safeArea.contains(new Point(r, c))) { allCells.add(new Point(r, c)); } } } Collections.shuffle(allCells, rand); // 分配雷位 isMine = new boolean[ROWS][COLS]; for (int i = 0; i < MINE_COUNT && i < allCells.size(); i++) { Point p = allCells.get(i); isMine[p.x][p.y] = true; } // 预计算 neighborCount... initNeighborCount(); gameStatus = GameStatus.RUNNING; }

逻辑说明safeArea确保首格及邻域 100% 无雷,allCells只包含可布雷区域,shuffle后取前MINE_COUNT个,保证雷数准确且分布均匀。此方案无需重试,效率稳定。

4.3 胜负判定:用 revealedCount 实时计数,替代遍历全表的 O(n²) 检查

每次揭示格子时递增计数器,当revealedCount == TOTAL_CELLS - MINE_COUNT即获胜:

private int revealedCount = 0; private static final int TOTAL_CELLS = ROWS * COLS; private void revealCell(int row, int col) { if (cellStates[row][col] != CellState.COVERED) return; cellStates[row][col] = CellState.REVEALED; revealedCount++; // 关键:每次成功揭示即 +1 if (mineField.isMine(row, col)) { gameOver(false); return; } if (revealedCount == TOTAL_CELLS - MINE_COUNT) { gameOver(true); return; } // ... 展开逻辑 }

优势:O(1) 胜负判定,避免每次点击都遍历 100 个格子检查是否全开。revealedCount是唯一真相源,CellState.REVEALED是视图状态,二者通过revealCell()方法严格同步。


5. 避坑指南:我在 Swing 扫雷项目中踩过的 5 个真实血泪坑

5.1 现象:点击格子后 UI 卡顿 1 秒,但 CPU 占用只有 5%

原因:在ActionListener中执行了耗时的flood-fill递归,阻塞了 Event Dispatch Thread(EDT),导致整个 Swing 界面冻结。
解决:将flood-fill改为栈模拟的迭代版本(如 3.1 节所示),确保单次点击逻辑在 10ms 内完成。若需更复杂计算(如超大地图),应使用SwingWorker异步处理,但扫雷场景完全没必要。

5.2 现象:高 DPI 显示器上格子严重变形,按钮拉伸成细长条

原因:未设置System.setProperty("sun.java2d.uiScale", "1.0")或未启用 Swing 的 HiDPI 支持。Java 9+ 默认启用,但旧项目常被忽略。
解决:在main方法最开头添加:

System.setProperty("sun.java2d.uiScale", "1.0"); // 或 Java 9+ 推荐方式 System.setProperty("sun.java2d.win.uiScale", "1.0"); SwingUtilities.invokeLater(() -> { // 创建 GUI });

同时确保JFrame设置setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE),避免窗口关闭后进程残留。

5.3 现象:右键标记旗子后,再次右键不切换到问号,而是直接触发左键逻辑

原因MouseListenermousePressedmouseReleased事件被JButton默认消费,导致mouseClicked未触发,而mousePressed中误判了按钮状态。
解决:严格区分事件类型——右键逻辑只在mousePressed中处理(因右键无clicked事件),左键逻辑在ActionListener中处理。移除所有mouseClicked中的右键判断。

5.4 现象:双击时偶尔触发两次mouseClicked,导致邻格被揭示两次

原因JButton默认双击灵敏度太高,或鼠标硬件抖动被识别为两次单击。
解决:在mouseClicked中加入防抖:

private long lastClickTime = 0; private static final long DOUBLE_CLICK_THRESHOLD = 300; // ms @Override public void mouseClicked(MouseEvent e) { long now = System.currentTimeMillis(); if (now - lastClickTime < DOUBLE_CLICK_THRESHOLD) { onCellDoubleClick(row, col); lastClickTime = 0; // 重置 } else { lastClickTime = now; } }

5.5 现象:游戏胜利后,计时器仍在后台跑,JOptionPane弹窗关闭后秒数继续增加

原因JOptionPane是模态对话框,会阻塞 EDT,但Timer仍在运行,seconds++持续执行。
解决:在gameOver()方法中先停止 Timer,再显示对话框

public void gameOver(boolean isWin) { timer.stop(); // 必须在此处停止! String msg = isWin ? "获胜!" : "失败!"; JOptionPane.showMessageDialog(this, msg); // 此时 EDT 被阻塞,但 timer 已停,秒数不会增加 }

6. 进阶技巧:用 JUnit 5 + AssertJ 实现可验证的扫雷核心逻辑,告别“点点看”式调试

写完扫雷最痛苦的事不是编码,而是验证——你永远不确定雷区生成是否真随机、连通展开是否漏格、胜负判定是否精准。靠手动点 100 次?不现实。必须用单元测试把核心逻辑锁死。

6.1 测试雷区生成:断言指定 seed 下雷位完全可复现

@Test void testMineFieldWithSeed() { MineField field1 = new MineField(5, 5, 5, 123L); MineField field2 = new MineField(5, 5, 5, 123L); // 断言两个相同 seed 的实例雷位完全一致 for (int r = 0; r < 5; r++) { for (int c = 0; c < 5; c++) { assertThat(field1.isMine(r, c)) .as("Cell [%d,%d] should match for seed 123", r, c) .isEqualTo(field2.isMine(r, c)); } } }

6.2 测试连通展开:用预设雷图验证 flood-fill 覆盖范围

构造一个已知雷位的 3×3 小地图,手动计算期望揭示格子集合:

@Test void testFloodFillOnSmallGrid() { // 手动构造雷图:中心是雷,其余安全 boolean[][] mines = { {false, false, false}, {false, true, false}, {false, false, false} }; // 用反射注入 mines(或修改 MineField 构造函数支持传入) MineField field = new MineFieldForTest(mines); // 自定义测试构造器 // 点击 (0,0) 应揭示全部非雷格子:共 8 个 Set<Point> expected = Set.of( new Point(0,0), new Point(0,1), new Point(0,2), new Point(1,0), new Point(1,2), new Point(2,0), new Point(2,1), new Point(2,2) ); Set<Point> actual = runFloodFillAndGetRevealed(field, 0, 0); assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); }

6.3 测试胜负判定:用边界值驱动,覆盖最小/最大地图

@ParameterizedTest @CsvSource({ "1,1,0, true", // 1格0雷 → 点击即胜 "1,1,1, false", // 1格1雷 → 点击即败 "3,3,1, false" // 3x3共9格,1雷 → 需揭示8格才胜 }) void testWinCondition(int rows, int cols, int mineCount, boolean expectWin) { MineField field = new MineField(rows, cols, mineCount, 456L); GameBoard board = new GameBoard(rows, cols, field); // 模拟点击所有非雷格子 int revealed = 0; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (!field.isMine(r, c)) { board.revealCell(r, c); revealed++; } } } // 断言 revealedCount 是否达到胜利阈值 int expectedRevealed = rows * cols - mineCount; assertThat(board.getRevealedCount()).isEqualTo(expectedRevealed); assertThat(board.getGameStatus()).isEqualTo(expectWin ? GameStatus.WON : GameStatus.LOST); }

关键技巧GameBoard需暴露getRevealedCount()getGameStatus()方法供测试调用,但生产代码中这些方法应设为package-private(无修饰符),避免污染 API。测试与生产代码在同一 package 下即可访问。

最后说句实在话:我带过 12 个实习生,凡是能把扫雷用纯 Java Swing 写透的人,三个月内都能独立开发内部工具系统。因为它逼你直面 Java GUI 的真实水位线——不是语法,是线程、状态、事件、渲染的协同。那些在main里写new JFrame()就跑起来的“Hello World”式代码,永远摸不到 Swing 的脉。希望这篇帮你把扫雷从“作业”变成“能力刻度尺”。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询