扫雷这个游戏,估计没人没玩过。但用Java Swing亲手实现一个能玩、能赢、手感还顺滑的版本,绝对比点开系统自带扫雷点掉一个下午有价值得多。我前段时间把完整的 javaswing实现扫雷游戏 从零写完,从二维数组建模、布雷算法、递归展开,到界面交互、事件监听、重绘优化,一路踩了不少坑,也把每个环节的原理摸得透透的。这篇就用实际项目复盘的方式,把整体设计、核心算法、界面搭建、踩坑实录全部摊开来讲,附带可以直接用的实现思路和关键代码。无论你是要做课程设计、巩固Java基础,还是单纯想搞懂扫雷背后的逻辑,这篇都值得收藏。
1. 项目整体设计与思路拆解
1.1 为什么选Swing而不是JavaFX或AWT
很多第一次接触图形界面的同学,选框架时会在Swing和JavaFX之间摇摆。我的结论很明确:扫雷这个项目,Swing是最合适的。
- 依赖天然:Swing是JDK自带的GUI工具包,不需要引入任何第三方依赖,环境配置成本几乎为零。JavaFX虽然界面更现代,但需要额外安装模块(Java 11之后还要单独配置),对课设和入门项目来说反而增加了一大堆不必要的复杂度。
- 事件模型匹配:扫雷的核心交互是鼠标点击——左键开格、右键标旗、左右键同时按做和弦判定。Swing的MouseListener天然支持区分鼠标按键,事件分发机制也很清晰,非常适合这种高频点击交互的游戏。
- 渲染足够用:有人担心Swing重绘慢,扫雷一个棋盘最多也就是30x30格,每格一个按钮或者绘制区域,用Swing的paintComponent完全扛得住。实测30x30的大师难度下,一个格子都不卡。
当然,如果是做3D效果或者复杂动画,那肯定要选JavaFX或游戏引擎。但扫雷这种2D网格类游戏,Swing属于用起来舒服、调起来简单的最优选。
1.2 扫雷核心逻辑的建模思路
写扫雷第一步不是画界面,而是想清楚数据模型。整个游戏本质上是在操作几个核心状态:
- 地雷分布:用一个二维数组记录哪些格子有雷,哪些没有。比如
boolean[][] isMine,true代表有雷。 - 相邻雷数:扫雷里每个数字格显示周围8个方向共几颗雷,需要另一个二维数组
int[][] hintNumber来存储。 - 格子的状态:每个格子当前是未翻开(covered)、已翻开(revealed)、被插旗(flagged)还是插错旗(误标),需要一个状态数组
int[][] cellState。 - 游戏状态:进行中、胜利、失败,用一个枚举或整型变量维护。
- 剩余地雷数:界面顶部通常显示剩余雷数,这是可以动态递减的。
这几个数据结构一旦想清楚,整个项目的骨架就立住了。我见过很多同学一上来就先画界面,画到一半发现逻辑不知道往哪塞,最后强行把业务代码堆在事件监听器里,一团乱麻。正确的顺序永远是:先建模,再实现逻辑,最后做界面。
1.3 游戏状态机的设计
扫雷的流程不是线性的,它是典型的“状态驱动”结构。我设计时用一个枚举来管理进度:
public enum GameState { RUNNING, // 游戏进行中 WIN, // 胜利 FAIL // 失败 }这里有一个很多人容易忽略的状态:游戏刚开始还没有点到第一个格子的时候,其实处于一个“预备”状态。很多实现把它和RUNNING合并,但这样在首次点击布雷时还得单独判断是否已经布过雷。更好的做法是在按下第一个有效左键时才触发布雷逻辑,并把状态从预备切到RUNNING。
为了简洁,很多代码里只用GameState就够了,但你要清楚这只是状态管理的简化方案。另一个维度是格子的状态,我用一个数组管理:
public static final int COVERED = 0; // 未翻开 public static final int REVEALED = 1; // 已翻开 public static final int FLAGGED = 2; // 已标旗标旗和覆盖是两回事,一定不能用同一个布尔量去表示。比如“已翻开”和“未翻开”用boolean,但“插旗”需要单独存。常见错误是:写了个isFlagged的布尔数组,结果翻格子的逻辑判断if (!isFlagged),导致插了旗的格子也能被翻开。这种边界细节必须提前想清楚。
2. 核心逻辑:布雷、计算与扫雷规则
2.1 随机布雷与“首点安全”实现
布雷是整个游戏最基础的逻辑。乍一看很简单——随机生成雷的位置就行了,但里面有一个必须处理的细节:用户第一次点击的格子不能是雷,否则游戏一开局就炸,体验极差。
常规做法有两种:
- 先把雷全部布好,再检测首次点击位置是不是雷。如果是,把雷移走或者重新生成。
- 延迟布雷法:在第一次点击之前不布任何雷,等用户点下第一个格子后,以这个格子为起点,排除掉它本身以及周围8个格子,在剩余区域布雷。
第二种更优雅,而且天然保证了“周边一圈安全”的入门体验。我也推荐按第二种来。具体逻辑:
public void layMines(int firstRow, int firstCol) { // 排除第一次点击的格子及其周围8格 boolean[][] block = new boolean[rows][cols]; for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { int nr = firstRow + dr, nc = firstCol + dc; if (inBounds(nr, nc)) block[nr][nc] = true; } } int mineCount = totalMines; Random rand = new Random(); while (mineCount > 0) { int r = rand.nextInt(rows); int c = rand.nextInt(cols); if (!isMine[r][c] && !block[r][c]) { isMine[r][c] = true; mineCount--; } } }雷数取多少?经典扫雷有三个难度:初级 9x9 共10雷,中级 16x16 共40雷,高级 16x30 共99雷。这些参数是Windows扫雷验证过的最佳配比,直接抄就行。想进阶一点可以在设置面板中让玩家自定义行列数和雷数,但雷的数量一般不超过格子总数的1/3,否则开局死局率太高。
2.2 周围雷数计算的两种方案
布雷完成后,下一步就是计算每个格子周围8格的雷数。这个计算有两条路:
- 方案A:遍历每个非雷格,扫描它周围8格,数有多少个是雷。每个格子要查8次,总时间复杂度 O(rows * cols * 8),实现直观,逻辑不易出错。
- 方案B:遍历每颗雷,对它周围的8个非雷格子执行“+1”操作。这个时间复杂度同样是 O(mines * 8),但通常雷的数量远小于格子数,所以实际运行更快。
方案B是我推荐的,因为它利用了“雷少”这一前提。演示一下:
public void calculateHints() { hint = new int[rows][cols]; int[] dr = {-1, -1, -1, 0, 0, 1, 1, 1}; int[] dc = {-1, 0, 1, -1, 1, -1, 0, 1}; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (!isMine[r][c]) continue; for (int k = 0; k < 8; k++) { int nr = r + dr[k], nc = c + dc[k]; if (inBounds(nr, nc) && !isMine[nr][nc]) { hint[nr][nc]++; } } } } }这段代码里出现了一个铁律:雷的格子上的hint值永远不计算。因为游戏里你永远不应该看到雷格上的数字,即使看到也是用图标覆盖,所以存什么都无所谓,但不计算可以防止调试时混淆。
2.3 递归展开空白区域
扫雷最爽的时刻是什么?点开一个空格,啪的一下连片展开大半个棋盘。这个效果的核心算法叫“泛洪填充”(Flood Fill),也叫洪泛展开。逻辑一句话解释:如果当前格子不是雷且周边雷数为0,则递归地翻开它周围8个格子;遇到数字格或插旗格就停下来。
public void reveal(int r, int c) { if (!inBounds(r, c) || state[r][c] == REVEALED || state[r][c] == FLAGGED) { return; } state[r][c] = REVEALED; if (hint[r][c] == 0) { for (int k = 0; k < 8; k++) { reveal(r + dr[k], c + dc[k]); } } }这个递归看起来简单,但有个隐形陷阱:它没检查isMine。如果用户点到的是雷,这个函数会被前面的逻辑挡住。我的习惯是把“点开雷则爆炸”的判断放在调用方,reveal方法本身只负责展开,不去管爆炸分支,这样职责更清晰。
另一个需要优化的问题是递归深度。30x30的地图在最坏情况下,递归深度可能达到900层,Java默认栈足够处理,不会爆。但如果你把递归改成BFS(广度优先)或者用显式栈的迭代方式,就更稳了。初版用递归完全OK,进阶再优化。
2.4 胜利与失败判定
- 失败判定:当用户点开的格子
isMine为真,游戏直接结束,把所有雷显示出来。 - 胜利判定:扫雷的胜利条件不是“把雷全部标出来”,而是翻开所有非雷格子。同样一个格子,不标旗但被数字排除推理出它是雷,也就等于玩家已经识别了它。所以判断逻辑是:
public boolean checkWin() { int revealedCount = 0; int safeCount = rows * cols - totalMines; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (state[r][c] == REVEALED) revealedCount++; } } return revealedCount == safeCount; }这里有个小技巧:每次翻开格子时维护一个revealedCount计数器,就不用每次全盘扫描。性能看起来差距不大,但30x30棋盘配合高频点击时,每次都做矩阵遍历会有明显卡顿,维护计数器才是正确姿势。
实战中最容易犯的错误是:把“插旗数量 == 总雷数”当成胜利条件。这样会造成一个哭笑不得的结果——玩家在不能确定雷的情况下乱标旗子,硬标够了数量就赢了,完全失去了推理的意义。所以一定要用“非雷格全部被翻开”作为唯一标准。
3. Swing界面搭建与交互实现
3.1 界面组件选型与布局设计
界面结构我采用的是经典的上下面板分离:
- 顶部信息栏:用JPanel承载剩余雷数标签、笑脸重开按钮、计时标签。用BorderLayout或FlowLayout都行,重点是让三个信息元素横向排列紧凑。
- 中央棋盘:用JPanel配合GridLayout,根据行列数动态设定。每一格可以用JButton来做,也可以用自定义paintComponent绘制。我用的是前者,因为JButton天然有按下、悬停的状态反馈,写起来快;不太依赖gif资源。
棋盘面板的代码大致如下:
JPanel boardPanel = new JPanel(); boardPanel.setLayout(new GridLayout(rows, cols)); for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { JButton btn = new JButton(); btn.setMargin(new Insets(0, 0, 0, 0)); btn.setFocusPainted(false); // 关联行列坐标 btn.putClientProperty("row", r); btn.putClientProperty("col", c); boardPanel.add(btn); } } mainFrame.add(boardPanel, BorderLayout.CENTER);特别注意putClientProperty存坐标这个技巧,它让事件监听器能拿到按钮对应的行列,不用手动维护“按钮->格子”的Map。比在按钮实例类里存row/col字段更素雅,也方便多个面板复用。
3.2 鼠标事件监听:左键、右键与和弦判定
扫雷鼠标交互有三类:
- 左键单击:翻开格子。
- 右键单击:标记或取消标记旗帜。
- 左右键同时按:经典扫雷里的“和弦”(chord)功能——如果一个已翻开的数字格周围旗子数量等于数字,左右键同时按下会将其余未旗子格子全部翻开。
Swing里区分左右键非常简单,在MouseListener.mouseClicked里用SwingUtilities.isLeftMouseButton/MiddleMouseButton判断。但左右键同时按的情况并不总能触发mouseClicked,稳妥的做法是监听mousePressed和mouseReleased,分别记录左右键按下的状态,再用一个状态机判断:
boolean leftPressed = false, rightPressed = false; public void mousePressed(MouseEvent e) { if (SwingUtilities.isLeftMouseButton(e)) leftPressed = true; if (SwingUtilities.isRightMouseButton(e)) rightPressed = true; if (leftPressed && rightPressed && cellState == REVEALED) { // 执行和弦逻辑 chordAction(row, col); } } public void mouseReleased(MouseEvent e) { if (SwingUtilities.isLeftMouseButton(e)) leftPressed = false; if (SwingUtilities.isRightMouseButton(e)) rightPressed = false; }这个逻辑看起来平平无奇,但有一个重要的前提:只有已经翻开的数字格才响应和弦。新手经常把和弦判断放在所有格子,结果在未翻开的格子上左右键同时按也触发逻辑,导致界面混乱。
3.3 面板刷新策略与性能调优
Swing是单线程的,所有UI操作必须运行在事件分发线程(EDT)上。因此,所有逻辑处理完之后,统一调用boardPanel.repaint()来触发重绘就行。这里有个性能细节:千万不要在循环里频繁调用repaint,比如在递归展开里每翻开一格就repaint一次,那样会导致界面大量重绘,肉眼可见地卡顿。
正确做法是全部状态更新到位后,一次性repaint:
// 递归展开期间只修改state数组 reveal(r, c); // 等递归全部结束后统一刷新 boardPanel.repaint();实测下来,30x30棋盘一次重绘耗时在十几毫秒内,即便连续快速点击多个格子,UI依然顺滑。如果发现重绘有残影或闪烁,可以重写JPanel的paintComponent时开启双缓冲,Swing默认已经开了,一般不用额外设置。
如果你是使用JButton实现的格子,那么你会发现 JButton 的更新需要逐个 setIcon 或者 setBackground。这里的性能瓶颈不在于重绘,而在于你在递归展开里对几百个按钮逐个调用setter方法,比较耗时。我当时为了方便用JButton,在初级难度下没问题,但高级难度连续翻出大片空白时,能感觉到轻微迟滞,后来果断换成自定义绘图方式才彻底解决。
3.4 计时器与剩余雷数显示
计时功能用javax.swing.Timer实现最方便,不会像Thread.sleep那样阻塞事件线程。核心逻辑是每秒触发一次ActionEvent,更新计时标签:
Timer timer = new Timer(1000, e -> { elapsedSeconds++; timeLabel.setText(String.format("%03d", elapsedSeconds)); });要注意的是,游戏开始计时的时间节点是第一次有效左键点击,不是窗口打开的时候。这也和延迟布雷逻辑配合——第一次点击同时触发布雷和启动计时器。雷数显示则是totalMines - flaggedCount,维护一个整数变量,每次标旗或取消标旗时更新即可。
还有个小技巧:把计时器的start和stop和游戏状态机绑定起来。游戏处于预备状态时计时器不启动,失败/胜利时stop。否则会出现游戏已经输了,计时还在跑的笑话。
4. 完整实操过程与核心代码实现
4.1 类结构设计
我建议一个完整项目拆成下面几个类,避免单文件过大:
MinesweeperModel:负责数据建模、布雷、hint计算、翻开/标旗/和弦逻辑、胜利判断。GamePanel:继承JPanel,负责绘制棋盘界面,接收鼠标事件,调用Model更新数据,再重绘。InfoPanel:顶部信息栏(雷数、笑脸按钮、计时器)。MainFrame:主窗口,用BorderLayout组合InfoPanel和GamePanel。
Model和View分离的好处是,如果后续想加“自动解谜”AI,只需要写一个算法类去操作Model的方法,完全不用碰界面代码。这在课程设计的答辩加分环节特别有用。
4.2 界面绘制:JButton还是自定义绘图
我上面提到换成了自定义绘图,这是扫雷项目从初中级进阶到一个档次的标志性改动。自定义绘图的思路是:GamePanel内部维护一个二维格子数组,每格去识别state单元格的状态,并用Graphics画出对应内容。
@Override protected void paintComponent(Graphics g) { super.paintComponent(g); for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { int x = c * cellSize, y = r * cellSize; drawCell(g, r, c, x, y); } } }drawCell里根据状态和hint值绘制不同图案:
- 覆盖时绘制灰色凸起边框。
- 插旗时在灰色底上绘制小红旗。
- 翻开时绘制浅色底,数字用不同颜色(1蓝色、2绿色、3红色、4深蓝、5深红、6青色、7黑色、8灰色)。
- 展示雷时绘制黑色地雷图案。
自定义绘图比较考验耐心,但换来的是流畅的交互体验和对Swing绘制机制更深入的理解。这里我给两个核心技巧:
第一,数字颜色可以定义为静态数组Color[] numberColors = {..., Color.BLUE, new Color(0, 128, 0), ...}然后g.setColor(numberColors[hintValue]),方便维护。
第二,凸起边框效果可以用g.draw3DRect实现,传入true表示凸起,翻开后传入false表示凹陷,视觉效果和原版扫雷几乎一样。
4.3 核心交互流程的实现细节
我把鼠标处理拆成了两个方法:handleLeftClick(row, col)和handleRightClick(row, col),思路更清晰。左键处理的完整流程如下:
public void handleLeftClick(int r, int c) { if (gameOver || state[r][c] == REVEALED || state[r][c] == FLAGGED) return; if (firstClick) { layMines(r, c); calculateHints(); firstClick = false; timer.start(); } if (isMine[r][c]) { // 游戏失败 gameOver = true; gameState = GameState.FAIL; revealAllMines(); timer.stop(); // 显示失败表情 } else { reveal(r, c); if (checkWin()) { gameOver = true; gameState = GameState.WIN; timer.stop(); // 自动给剩余雷插旗,显示胜利表情 } } repaint(); }这段代码把“第一次点击布雷”、“踩雷失败”、“翻开”、“胜利判定”四个分支全部集中在一个方法里,逻辑顺序一目了然。右键处理就简单多了,只做插旗与取消插旗,同时检查该格是否已经翻开。
4.4 键盘与无障碍细节
很多人忽略键盘支持,但加上了能明显提升作品完成度。我实现了几个快捷键:空格键模拟左键翻开当前聚焦格子,F键模拟插旗。在JPanel上设置Focusable,重写keyPressed即可。
不过要提醒一下:Swing默认的JButton会抢焦点,如果你是纯按钮方案,空格键可能和按钮自身的激活逻辑冲突。自定义绘图方案没有这个烦恼。
4.5 进阶功能扩展:难度切换、排行榜与动画
- 难度切换:在菜单栏或面板上加三个单选框,选择后重建Model和GamePanel,重置计时器。行列和雷数可以自定义。
- 排行榜:胜利时记录用时,存入本地文件(Properties或序列化对象都行)。这个玩法在课设答辩中很拉好感。
- 雷区动画:失败时雷爆炸然后逐渐变成红色背景,或者用简单的缩放绘制实现。Swing动画可以借助
javax.swing.Timer周期性改变状态并调用repaint,每次修改一个帧计数变量就可以。
我当时做了一版“失败后雷开始闪烁”的效果,用Timer驱动,效果很炫,但注意不能让定时器泄露——游戏结束时释放掉,否则玩家再次开局后会出现多个动画引擎同时运行,导致性能问题。
5. 常见问题与排查技巧实录
5.1 图标和图片加载不出来的经典坑
不少人喜欢在JButton上用ImageIcon显示旗子和雷,写代码时直接用相对路径"src/images/flag.png",结果打包运行后就找不到图片。这里有两个解决方案:
- 把图片放进resources目录,用
getResourceAsStream加载。 - 更稳妥的做法是完全不用外部图片,遇到旗帜、雷这些图案直接用Graphics绘制,也就是我前面说的自定义绘图方案。这样不但免去资源路径的坑,也让整个程序更轻量。
如果你就想用ImageIcon,记住一点:不要用文件路径,要用类路径:
new ImageIcon(App.class.getResource("/images/flag.png"));5.2 递归展开遇到大棋盘就卡顿甚至卡死
我之前说过用递归展开可能有性能隐患。但卡死还可能因为不是递归本身,而是展开了“不该展开的格子”。比如你的递归出口没有把已经翻开的格子挡住,导致A翻开B、B翻开A的无限循环。这就是死递归,栈越用越深,最终StackOverflow。
排查方法也很简单:在递归的第一行打印r, c,或者用System.out输出翻开顺序,一眼就能看出来哪里在反复横跳。平时规范起见,始终在reveal方法开头检查state状态,已经是REVEALED或FLAGGED就直接return,别给死循环留空间。
5.3 左右键同时按下时偶尔没反应
这个问题是事件顺序造成的。在某些系统上,左右键快速同时按下时,系统可能只派发了一个按键的mousePressed,另一个事件丢失或延迟。解决办法是把和弦逻辑放到mouseReleased里进行判断,并且同时检查两个按键的按下状态:
public void mouseReleased(MouseEvent e) { if (leftPressed && rightPressed && SwingUtilities.isLeftMouseButton(e)) { doChord(); } // 更新按键状态 }应用层建议不要依赖“完美的同时”,而是在任意一个按键释放时,如果另一个按键还处于按下状态,就执行和弦逻辑。这样用起来更跟手,不容易漏判。
5.4 界面缩放后布局错乱
有人把主窗口设置成setSize(400, 500)后直接使用绝对定位,最后发现分辨率一变界面就乱成一团。我的建议是全部使用布局管理器,要让 GridLayout 和 BorderLayout 去处理格子尺寸变化。棋盘格子的大小用 Math.min(panel.getWidth()/cols, panel.getHeight()/rows) 动态计算,重绘时重新取。
另一个坑是底部的信息面板用FlowLayout,窗口变宽后左右元素分散得很开,看起来就不紧凑。改成固定宽度或者用BoxLayout纵向排列,会好看很多。最终极的解决方案是禁止用户随意缩放窗口大小——直接setResizable(false),对扫雷这种固定棋盘的游戏非常合理。
5.5 计时器泄漏,开局后上次的计时器还在跑
这个问题很隐蔽。如果你用javax.swing.Timer并且没有在重建棋盘时stop旧的Timer,那旧Timer会继续触发监听器,更新一个已经不在界面上显示的标签,导致新游戏计时不但被干扰,有时还会每秒跳两下。经典表现:新游戏开局后计时数字自己每秒+2甚至跳得飞快。
排查方法:在重建GamePanel时务必确保Timer已经停止,并移除旧Timer绑定的ActionListener。最佳实践是让Timer只归InfoPanel管,重建棋盘时只重置TimeLabel的文本,而不是新new一个Timer。这样从源头避免了泄漏问题。
5.6 标旗数量与剩余雷数不对应
很多人把剩余雷数显示为totalMines - flaggedCount,但玩家可以标记任意多个旗子,比如雷还没找到10颗,就顺手标了20面旗,剩余雷数变成负的。这不算bug,但显示成负数很难看。合理的做法是允许负数显示(经典扫雷也是这么表现的),或者只显示成0。个人建议保持负数,因为它能直观提醒玩家:你已经标错了,得多想想再标旗。
6. 实操心得与经验补充
6.1 我踩过的最有价值的坑
第一版我用纯JButton实现,写起来很爽,但到了高级难度,棋盘上几百个按钮组件堆在一起,初始化耗时明显。后来全部换成自定义绘图,体验好了很多,也让我真正理解到Swing的绘制机制不是为十几万个组件设计的。如果你的目标是做一个“能流畅跑高级难度”的扫雷,自定义绘图是必由之路。
第二层体会是关于事件处理的。我把和弦逻辑绑在mousePressed上,结果在很多鼠标上它根本不触发。后来改成上面说的“释放时判断双键状态”,才真正稳定下来。这是常规书籍不会讲的细节,得靠实际使用各种鼠标才能体会到。
6.2 怎么让作品在课设中脱颖而出
如果只是想及格,写个能玩的扫雷就够了。但想拿高分,建议考虑这几个加分项:
- 自定义难度:允许输入任意行列数和雷数。
- 排雷器/自动标记:在一个已翻开的数字格上,如果周围旗子数量等于数字,单击自动翻开其他格子。
- 重开按钮的形象反馈:迷之时、踩雷时、胜利时,笑脸按钮分别显示不同表情。
- 存档与排行榜:用文件保存最佳时间和胜率。
- 双模式切换:经典模式与“无雷模式”(纯推理,不涉及踩雷),后者特别适合锻炼逻辑。
6.3 关于代码注释和结构的执念
我强烈建议把Model和View分离,哪怕只是个课设。不是因为“设计模式听起来高级”,而是当你需要排查一个雷数计算的bug时,你会感激自己当初把业务逻辑写成了纯Java类,而不是散落在监听器里的几百行泥团。我当时写的时候养成的习惯是:Model内所有改变状态的方法都返回void,由View在调用后统一repaint,逻辑非常集中,很好debug。
最后说个心态层面的小建议:扫雷这种经典小游戏,代码量不大,逻辑也不复杂,但它是为数不多需要真正调通“鼠标交互+TIMER+递归+状态管理”的练手项目。把这几块吃透,以后做任何Swing或Android界面项目,底子都稳得多。如果你正在做这个项目,把上面的关键点记下来,真的能少踩很多坑。