Java Swing扫雷实战:事件驱动与状态管理深度解析
简介这是一份基于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) { // 点击逻辑暂空留待后续填充 } }提示GridLayout的preferredSize是关键。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 ListPoint 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格子状态绝不能用int或String表示如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) { StackPoint 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因为右键不触发ActionEvent。SwingUtilities.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 枚举覆盖启动、运行、胜利、失败、暂停五种核心状态状态混乱是扫雷项目的通病gameOver、win、isPlaying、paused四五个布尔变量互相耦合。必须用单状态机统一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邻域不能有雷 SetPoint 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因只需一次 ListPoint 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 ThreadEDT导致整个 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 现象右键标记旗子后再次右键不切换到问号而是直接触发左键逻辑原因MouseListener的mousePressed和mouseReleased事件被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 个 SetPoint 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) ); SetPoint 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 的脉。希望这篇帮你把扫雷从“作业”变成“能力刻度尺”。希望帮到你。本文还有配套的精品资源点击获取