Java Swing扫雷游戏实战:从事件监听到英雄榜持久化
发布时间:2026/10/15 19:57:50 锦皓数字建站

简介这是一份基于Java的扫雷游戏完整源码面向Java学习者和课程设计实训在经典扫雷玩法上集成了英雄榜排行与多等级难度设置适合入门至中级开发者研读也可作为期末项目参考。压缩包共19个文件包含7个Java源文件如MineGame、MineArea、Record等对应主程序、布雷逻辑和成绩记录、7个class编译文件另有2个GIF图标、保存排行榜数据的TXT文件以及工程配置文件整体仅23KB结构精简。已有672人浏览学习。通过阅读源码可掌握扫雷核心算法相邻雷数统计、空白区域递归展开、鼠标左右键操作、界面布局以及文件读写持久化排行榜等实用技能工程虽小但模块划分清晰便于二次开发和功能扩展。英雄榜与等级设置相互衔接完整覆盖了从游戏逻辑到数据存储的实现路径。1. 不只是练手带英雄榜的 Java 扫雷到底在写什么扫雷这种上世纪就流行的玩法至今还有人用 Java 从头写一遍有人觉得是练手有人觉得是情怀。这个带英雄榜的 Java 扫雷项目实际上是把桌面程序开发里最常碰到的三件事全串起来了鼠标事件监听、游戏状态管理、本地数据持久化。你点开一个格子背后要处理右键标旗、递归扩散、胜利判定、计时停止最后弹出输入框把你的成绩写进本地 JSON 文件。项目不依赖数据库目录结构干净适合刚学完 Java 基础、想用一个完整项目把 Swing 和文件读写练熟的人也适合想看看别人怎么写一个带排行榜的桌面小游戏的人。整个项目拿到手就能编译跑起来就是一套完整的经典扫雷体验。2. 技术栈与整体架构Swing Maven Jackson 的落地组合2.1 为什么选 Swing 而不是其他方案先说明一点这个项目没有玩花活。UI 用的是 Java 自带的 Swing没有引入 JavaFX也没有包一层 Electron。背后原因很直接Swing 在 JDK 里开箱即用任何装了 JDK 的机器都能编译运行省掉一堆模块依赖的配置工作。对于扫雷这类以鼠标点击为主的棋盘式游戏JPanel 加监听器足够覆盖全部交互。用 JavaFX 也不是不行但 Scene、FXML 这些概念会让一个练手项目平白多出不少理解成本。某开发者在做这个项目时坚持“能少引一个包就少引一个包”最后只在英雄榜持久化上引入了一个 Jackson。构建工具选 Maven 是因为从中央仓库拉依赖、mvn package 打可执行 jar 都是约定俗成的玩法别人拿到项目后没有额外学习成本。项目里没有写一大堆自定义插件只有一个 shade 插件负责把 Jackson 打进可执行 jar这一块在第四章会细说。如果换成 Gradle 也可以但 Gradle 的封装和版本管理对刚入手的人反而更绕。另一个值得单独说的是线程模型。Swing 是单线程模型所有界面更新都必须发生在事件分发线程 EDT 上。这个项目里布雷算法和递归扩散都是在鼠标事件回调里直接执行的最大盘面 480 个格子一瞬间就算完不需要另开线程。如果以后要加“后台预生成随机盘面”这类耗时逻辑才需要考虑用 SwingWorker 把任务移到后台线程避免界面卡死。2.2 包结构与模块职责整个项目按 MVC 的思路拆成五个包。入口类 App 负责初始化窗口和控制器model 包里放的是游戏状态不关心界面view 包里的 GameFrame、BoardPanel 只负责绘制controller 包里的 GameController 和 MouseHandler 把鼠标事件翻译成游戏指令service 包单独负责英雄榜的读取与写入。这样拆的好处是改界面不会碰算法改算法也不会动界面。目录结构如下src/main/java/com/example/sweeper/ ├── App.java ├── model/ │ ├── Board.java │ ├── Cell.java │ ├── GameConfig.java │ └── Difficulty.java ├── controller/ │ ├── GameController.java │ └── MouseHandler.java ├── view/ │ ├── GameFrame.java │ ├── BoardPanel.java │ └── HeroBoardDialog.java ├── service/ │ ├── HeroBoardService.java │ └── ScoreRecord.java └── util/ └── ScoreTimeFormatter.javaBoard 是核心模型持有二维数组每个元素是一个 CellCell 内部记录是否布雷、是否被翻开、周围雷数等状态。GameController 负责在每次操作后向 BoardPanel 发送刷新指令。HeroBoardService 与 ScoreRecord 属于英雄榜模块ScoreRecord 对应一条成绩记录HeroBoardService 负责排序、截断和落盘。GameConfig 存放难度参数Difficulty 是枚举这个设计给后面加“自定义难度”留好了位置。从协作关系上看鼠标事件先落在 BoardPanel 上MouseHandler 把坐标换算成行和列再调用 controller 对应方法。Controller 修改 model 之后统一调用 view.refresh() 触发重绘而不是在鼠标回调里零零散散地改控件。这样整个界面更新的路径只有一条排查问题时会轻松很多。英雄榜数据流的终点是 HeroBoardDialog它打开时会重新读取一次 JSON 文件而不是用 Controller 内存里那份旧数据保证看到的一定是最新排名。2.3 难度参数与事件流游戏提供了三个标准难度参数与 Windows 扫雷保持对齐| 难度 | 行数 | 列数 | 雷数 | | 初级 | 8 | 8 | 10 | | 中级 | 16 | 16 | 40 | | 高级 | 16 | 30 | 99 |从一次点击到英雄榜更新的完整链路是MouseHandler 收到左键事件调用 GameController.openCell(row, col)GameController 先判断是否第一次点击是则延迟布雷并启动计时器随后调 Board.reveal(row, col)内部走扩散算法返回后检查剩余未翻开格子是否等于雷数如果是就进入胜利流程调用 HeroBoardService.load 读取历史成绩把新记录插进去排序再写回 JSON 文件最后把排行榜展示在 HeroBoardDialog 里。这段链路里最容易出问题的是“剩余未翻开格子”的判断。很多初版实现会统计 isRevealed 的格子数量再拿总数减它去和雷数比较。这个写法正确性没问题但有个更快的等价判断当 isRevealed 数量等于 rows * cols - mineCount 时就已经赢了根本不用数没翻开的格子。这行判断放在 Controller 里每次 reveal 完执行。双击数字也是事件流里一个被反复提起的细节。标准扫雷支持“当格子周围已标旗数等于数字时双击自动展开剩余格子”这个功能不能简单地在 mouseClicked 里判双击因为玩家往往会快速地对不同格子连续单击。这个项目用的方案是判断鼠标在同一格子上的两次点击时间差小于 300 毫秒就当作双击处理执行时还要校验该格子已经翻开且数字大于 0避免误触。2.4 默认配置与参数校验难度枚举的代码在 model/Difficulty.java 里想改默认难度只需要调整启动时的初始值。参数校验要单独说雷数不是随便填的游戏假设每个格子至少留出一定比例的安全区域。如果雷数超过格子总数的 85%随机布雷时安全首击区域可能找不到足够空位placeMines 会抛异常。GameConfig 里定义一个静态校验方法public static boolean isValid(int rows, int cols, int mineCount) { return mineCount 0 mineCount rows * cols * 0.85; }调用方在构造 Board 前先执行校验非法参数直接提示玩家重新选择难度。越早拦截这种参数错误越不会在游戏中途出现不可解释的翻车。提示这个 0.85 不是拍脑袋定的按 8x864 格算85% 大约是 54 颗雷已经远超现实中扫雷盘面的雷密度再高很容易出现“首击无法保证安全”的无解局面。3. 核心模块拆解布雷、扩散算法与英雄榜落盘3.1 模型层Board、Cell 与安全首击布雷Cell 是一个简单的状态类字段用 boolean 表示 isMine、isRevealed、isFlagged再加一个 int 的 surroundMineCount。用布尔值而不是用一个整型状态位是为了让代码读起来直白。Board 初始化时把所有格子铺满普通 Cell真正的布雷发生在玩家第一次点击之后。public class Board { private final int rows; private final int cols; private final int mineCount; private final Cell[][] cells; private boolean gameOver; private boolean firstClick true; public Board(int rows, int cols, int mineCount) { this.rows rows; this.cols cols; this.mineCount mineCount; this.cells new Cell[rows][cols]; for (int i 0; i rows; i) { for (int j 0; j cols; j) { cells[i][j] new Cell(); } } } public void placeMines(int safeRow, int safeCol) { int placed 0; Random random new Random(); while (placed mineCount) { int r random.nextInt(rows); int c random.nextInt(cols); if (cells[r][c].isMine()) { continue; } if (Math.abs(r - safeRow) 1 Math.abs(c - safeCol) 1) { continue; } cells[r][c].setMine(true); placed; } if (placed mineCount) { throw new IllegalStateException(雷数过多无法保证安全首击); } computeSurroundCounts(); } }这段代码的核心是“安全首击”placeMines 接收玩家第一次点击的坐标把 safeRow、safeCol 周围 3x3 范围内的格子排除在布雷区域外保证第一下翻开绝对安全。两种处理方式都见过一种是先布雷点击时把雷挪走一种是点击后再布雷。后者实现更简单这个项目用的就是它。safeRow、safeCol 是玩家第一次点击的行列索引Random 负责随机散布雷的位置让每局开局都不一样。注意 placeMines 内部用 while 循环找空位极端情况下可能一直找不到所以循环结束后加了一层校验雷数占比过高时直接抛异常宁可在开局时报错也不要让玩家玩到一半才发现盘面无解。布雷完成后要调用 computeSurroundCounts 计算每个格子周围雷的数量这个方法遍历所有非雷格检查周围 8 个邻居的 isMine 状态结果写进 Cell 的 surroundMineCount。这个数字是渲染时“格子是显示数字还是触发扩散”的判断依据。3.2 扩散算法递归 Flood Fill 与已访问标记当玩家点开的格子不是雷、周围雷数又是 0 时需要把整个相连的空白区域全部翻开这是扫雷手感的核心。常见实现是递归 Flood Fill也可以用队列做 BFS这个项目用的是递归版本但递归里用 visited 二维数组防止重复访问。public void revealEmpty(int row, int col, boolean[][] visited) { if (row 0 || row rows || col 0 || col cols) { return; } if (visited[row][col]) { return; } visited[row][col] true; Cell cell cells[row][col]; if (cell.isMine()) { return; } if (cell.getSurroundMineCount() 0) { cell.setRevealed(true); return; } cell.setRevealed(true); for (int i -1; i 1; i) { for (int j -1; j 1; j) { if (i 0 j 0) { continue; } revealEmpty(row i, col j, visited); } } }这个递归写法能跑通依赖两个前提。第一visited 数组必须在调用前用 false 初始化每个格子只能进入一次递归如果没有这个标记两个相邻的 0 格会互相反复把对方拉进递归小盘面卡顿大盘面直接栈溢出。第二递归深度等于空白连通区域的大小扫雷最大盘面只有 16 行 30 列共 480 个格子递归深度完全可控不需要额外改成循环。如果以后要做超大自定义盘面可以把递归改成显式栈或队列 BFS逻辑等价。到这里盘面的生成和翻开已经讲完了。还有一个容易被忽视的边界当玩家点到已经翻开的格子上时revealEmpty 会直接 return不会产生副作用。这个判断在 Controller 里也做了一遍双保险防止重复点击导致状态错乱。接下来看英雄榜这是“扫雷”之外这个项目最核心的增值部分。3.3 英雄榜模块排名、截断与 JSON 持久化英雄榜的数据结构由两个类承担ScoreRecord 表示一条成绩HeroBoardService 负责读写排序。ScoreRecord 的字段是玩家名、难度、用时秒数和完成时间完成时间用字符串存储方便直接显示在列表里。HeroBoardService 的逻辑分三步先读历史记录再把新记录按难度插入对应分组最后每组只保留前 10 名写回 JSON。有一类常见错误是先把所有记录混在一起排序再 filter 当前难度后 limit 10这样会把其他难度的记录误删。正确做法是按难度分组再分别截断public void saveScore(ScoreRecord record) throws IOException { ListScoreRecord all loadRecords(); all.add(record); MapString, ListScoreRecord grouped all.stream() .collect(Collectors.groupingBy(ScoreRecord::getDifficulty)); for (String difficulty : grouped.keySet()) { ListScoreRecord top grouped.get(difficulty).stream() .sorted(Comparator.comparingLong(ScoreRecord::getUseTimeSeconds)) .limit(10) .collect(Collectors.toList()); grouped.put(difficulty, top); } ListScoreRecord merged new ArrayList(); grouped.values().forEach(merged::addAll); ObjectMapper mapper new ObjectMapper(); mapper.writeValue(scoreFile.toFile(), merged); }排序用的 Comparator 直接按 useTimeSeconds 升序用时越短排名越靠前符合扫雷英雄榜的直觉。这里把 grouped 之后再 merged 写回保留了所有难度的历史数据只是把每个难度各自截断到 10 条。英雄榜对话框打开时再按当前难度过滤展示数据保留粒度按难度截断展示粒度按难度查看两层职责分开。loadRecords 方法要处理两种情况文件不存在时返回空列表文件存在但内容被手动改坏、解析失败时也返回空列表。正确的做法是统一用 ObjectMapper 读取捕获 IOException 后返回空数据不能让一个坏文件把整个游戏搞崩。public ListScoreRecord loadRecords() { try { if (!Files.exists(scoreFile)) { return new ArrayList(); } return mapper.readValue(scoreFile.toFile(), new TypeReferenceListScoreRecord() {}); } catch (IOException e) { return new ArrayList(); } }这里用 TypeReference 是因为 Jackson 反序列化泛型集合时不能直接读成 List.class会丢失元素类型信息。写入时统一由 ObjectMapper 负责默认使用 UTF-8 编码避免中文玩家名乱码。如果日志里看到解析异常最省事的处理是删除 heroboard.json 让它重新生成这个文件本身就是可以被安全重置的缓存型数据。3.4 界面层与计时器刷新与状态同步界面层由 GameFrame、BoardPanel、HeroBoardDialog 组成。BoardPanel 继承 JPanel重写 paintComponent 绘制格子每个格子根据 Cell 的状态画成普通方块、数字、红旗或地雷。这里有一个值得说的点整个盘面没有用 JButton而是 JPanel 自己画。用 JButton 实现扫雷在事件处理上更直接但大量按钮的绘制和布局开销明显而且 JButton 的外观跟扫雷的扁平风格不搭。Override protected void paintComponent(Graphics g) { super.paintComponent(g); for (int i 0; i rows; i) { for (int j 0; j cols; j) { Cell cell board.getCell(i, j); if (cell.isRevealed()) { // 画数字或空白根据 surroundMineCount 分支 } else if (cell.isFlagged()) { // 画红旗 } else { // 画未翻开的灰色方块 } } } }paintComponent 里只根据 Cell 的状态做分支绘制不处理任何业务逻辑。这也是 MVC 分层带来的好处model 变了界面只是一个纯展示层。所有重绘统一通过 controller 调 refresh 触发refresh 内部调用 repaintSwing 会把多个 repaint 合并成一次绘制性能足够。计时器用的是 javax.swing.Timer1 秒触发一次更新标签。这里必须注意停止时机胜利和失败都要停不能只写在胜利分支。而且除了 timer.stop()还要把 running 标志置 false否则在极端情况下窗口关闭后 Timer 可能还会继续触发回调private void stopTimer() { running false; if (timer ! null) { timer.stop(); } }running 是类里的一个 volatile boolean 字段用来防止 Timer 回调在 EDT 上重复执行。虽然 Swing 的 Timer 本身在主线程派发但加了标志位能让代码在任何启动方式下都具备兜底能力。HeroBoardDialog 展示时使用的是从文件重新读取的数据而不是内存里的临时变量这样从英雄榜切回游戏再切回来排名始终是持久化之后的一致结果。4. 编译、运行与参数调整从源码到跑起来4.1 环境准备与打包命令开始之前先确认 JDK 版本。项目用 Maven 的 compiler.release 参数锁定了 Java 11低于这个版本会在菱形推断和 var 关键字这些地方遇到编译报错。Maven 版本没有硬性要求3.6 以上的常用版本都可以。第一次跑的时候建议在命令行执行能看到控制台打印的异常堆栈如果直接双击 jar窗口一闪而过问题定位会非常难受。mvn clean package java -jar target/sweeper-heroboard.jarpom.xml 里配置了 maven-shade-plugin否则 java -jar 启动时会报 Jackson 相关类找不到的 ClassNotFoundException。下面这段配置把 Jackson 和项目代码一起打成一个 fat jarbuild finalNamesweeper-heroboard/finalName plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.2.4/version executions execution phasepackage/phase goalsgoalshade/goal/goals /execution /executions /plugin /plugins /buildfinalName 决定了 target 目录下生成的 jar 文件名改成别的名字后启动命令也要同步改。如果你用的是 IDEA直接运行 App.java 也是可以的IDE 会自动把依赖加进 classpath不依赖 shade 插件。命令行方式和 IDE 方式二选一不要混着用否则容易出现“IDE 里正常、命令行跑不了”的情况。注意JDK 8 用户需要把 pom.xml 里的 release 改成 8并把代码里凡是用了 var 的地方都换成显式类型。这个项目没有用到特别新的语法下沉成本不高。4.2 游戏操作与界面功能| 操作 | 按键/方式 | 行为 | | 打开格子 | 鼠标左键 | 翻开当前格子 | | 标记地雷 | 鼠标右键 | 无旗 - 红旗 - 问号 - 无旗 循环 | | 双击数字 | 左键快速点击数字格 | 当已标旗数等于数字时自动翻开周围未处理格子 | | 重新开始 | 菜单按钮 / 快捷键 R | 重置当前难度雷盘 | | 查看英雄榜 | 菜单按钮 | 展示当前难度前 10 名成绩 |双击数字是个很实用的操作它的实现不是直接调 reveal而是先统计周边已标旗数量只有等于格子数字时才把未标旗且未翻开的邻居逐个翻开。这里有个符合常规扫雷规则的细节如果玩家标旗标错了双击可能直接翻出地雷导致游戏结束。这个判定要在 Controller 里控制不能在 Board 内部做因为标旗状态属于玩家操作模型层不应反向依赖视图。英雄榜展示用的是独立对话框打开时按当前难度过滤数据用时升序排列前三名用高亮背景色区分后七名用普通样式。从使用流程看整个项目已经是一个完整的可玩游戏不是半成品的轮子。4.3 启动后的运行信号与日志排查启动 jar 后控制台如果出现英雄榜文件加载成功之类的日志说明读写路径正常如果出现中文乱码大概率是控制台编码问题不是英雄榜数据坏了可以在启动命令后加 -Dfile.encodingUTF-8 再试。项目根目录会生成 heroboard.json里面的结构是一个成绩对象的 JSON 数组用文本编辑器可以直接打开查看。开发时最方便的调试方式就是删除这个文件下次运行会自动重建空榜单。如果点开格子没有任何反应先看控制台有没有异常堆栈没有异常再看鼠标事件是否绑定到了正确组件。很多人犯过一个错把 MouseListener 加到了 JFrame 上而不是 BoardPanel 上。JFrame 的表层面板会拦截事件导致棋盘区域接收不到点击。这类问题控制台不会报错只能从事件绑定代码里找。4.4 改配置默认难度与雷数写在哪里Difficulty 枚举在 model 包下想调整默认难度或雷数直接改枚举常量即可。比如想把中级盘面改成 20 行 30 列 60 雷只需修改枚举中的参数不需要动其他代码public enum Difficulty { BEGINNER(8, 8, 10), INTERMEDIATE(20, 30, 60), EXPERT(16, 30, 99); private final int rows; private final int cols; private final int mineCount; Difficulty(int rows, int cols, int mineCount) { this.rows rows; this.cols cols; this.mineCount mineCount; } public int getRows() { return rows; } public int getCols() { return cols; } public int getMineCount() { return mineCount; } }改完雷数后务必检查是否满足 GameConfig.isValid 的校验条件否则会在第一次点击时触发 3.1 里的 IllegalStateException。这个异常信息会直接抛到控制台很多人会被吓到但它的含义很明确自定义盘面的雷太多了保证不了安全首击需要降低雷数或加大盘面。5. 常见问题排查运行与开发中踩过的几个坑5.1 右键标旗失效或变成了“翻开”现象右键点击格子没有插旗反而把格子翻开或者完全没有反应。原因事件监听用的是 ActionListener没有用 MouseListener。ActionListener 只响应“按钮被激活”区分不了左右键Swing 组件点击后还会二次触发翻开逻辑导致右键和左键行为混在一起。解决把格子绘制和事件处理都放在 BoardPanel 上MouseHandler 里按 getButton 区分分支右键只调用 controller.toggleFlag并且要调用 e.consume() 阻止后续默认处理public void mousePressed(MouseEvent e) { if (e.getButton() MouseEvent.BUTTON3) { controller.toggleFlag(row, col); e.consume(); } }5.2 第一次点击就踩雷爆炸现象玩家正准备展开盘面第一下点下去直接炸一片体验极差。原因布雷逻辑写在了 Board 构造函数里盘面初始化时雷就已经铺满安全首击根本没有实现。传统扫雷虽然也会在开局后随机布雷但正经产品都会有“第一击保护”。解决把布雷从构造函数中抽出去改为在第一次点击时才调用。GameController 里维护 firstClick 标志首次点击时不翻开而是先调 placeMines(clickRow, clickCol)再走正常 reveal 流程。这个方案代码量不大但对体验的提升是质的。5.3 英雄榜只保留最近一次成绩其他记录消失现象玩到第三盘打开英雄榜发现只剩刚才那盘的成绩之前两盘没了。原因saveScore 里先 filter 当前难度再 limit(10)把过滤后的列表直接写回文件属于整体覆盖把其他难度和其他旧成绩全丢了。这个错误在初版代码里很容易出现本质上是对“分组截断”和“全局排序”的语义没分清。解决按 3.3 里的写法先读全量数据按难度分组后各自截断到 10 条再把合并后的完整列表写回。这样初级、中级、高级各保留 10 条互不影响。5.4 中文玩家名乱码或 JSON 解析失败现象英雄榜输入“张三”后重启游戏看到乱码或者 ObjectMapper 读文件抛异常。原因用 FileWriter 手动拼 JSON 字符串依赖系统默认编码。Windows 默认 GBKLinux 默认 UTF-8同一个文件在不同系统上读出来内容直接错乱。解决统一用 ObjectMapper.writeValue 写文件、readValue 读文件Jackson 默认按 UTF-8 处理文本。如果异常栈里出现 Invalid UTF-8 或 InvalidFormatException说明文件已经被写坏删掉重新生成比尝试修复更省事。5.5 计时器在游戏结束后还在跳现象游戏输掉后界面顶部的时间还在继续累加看起来像卡死。原因gameOver 变量只在 Controller 里赋了值View 里的 Timer 没有收到停止信号。Timer 作为独立对象只要没有 stop就会按预定间隔一直触发回调。解决在 GameController 的胜负两个分支里都调用 stopTimerstopTimer 内部把 running 标志置 false 再调 timer.stop()。代码就是 3.4 节那段不要只在胜利分支调用失败分支同样要处理。6. 二次开发技巧存储、玩法与回归验证6.1 换掉英雄榜存储JSON 改 SQLite如果想把英雄榜改成真正的排名系统最简单的增量改造是把 HeroBoardService 的读写实现从 JSON 换成 SQLite。这个项目里只有这一个类与持久化耦合Controller 和 View 完全不需要感知底层存储。表结构按难度建索引查询时按用时升序取前 10 条比每次读全量 JSON 再到内存里排序更干净CREATE TABLE score ( id INTEGER PRIMARY KEY, player_name TEXT, difficulty TEXT, use_time_seconds INTEGER, finished_at TEXT ); CREATE INDEX idx_difficulty_time ON score(difficulty, use_time_seconds);改造完以后整个英雄榜模块看起来就是 Controller 调 serviceservice 内部决定走文件还是数据库。这个替换适合作为 JDBC 练习能让项目多一个数据库层又不影响游戏主逻辑。6.2 增强玩法剩余雷数提示在状态栏加一个“剩余雷数”显示值是总雷数减去已标旗数这个功能实现很简单但要留意一个边界问号标记不算已标旗只有红旗才计入标旗数。触发刷新的位置有三处右键标旗后、双击数字自动展开后、游戏结束显示全部雷区后。漏了任何一处状态栏数字都会和实际盘面对不上。6.3 验证核心逻辑不被改坏每次改造后都要先跑核心用例学阶段的验证重点是两个布雷数量正确、空白格展开正确。用 JUnit 给 Board 写测试比手动点几十局高效得多。下面是一个展开测试的骨架Test public void revealEmptyShouldExpandZeros() { Board board new Board(8, 8, 10); board.placeMines(0, 0); boolean[][] visited new boolean[8][8]; board.revealEmpty(0, 0, visited); // 断言 (0,0) 已被翻开且未踩雷 }注意 placeMines 已经对 (0,0) 周围做了保护所以这里点 (0,0) 一定不会踩雷。先从这种用例开始再补一条“被翻开的格子数等于连通区域大小”的断言就能拦住绝大多数扩散相关的回归问题。从那以后我每次改核心逻辑都强制走完三件事编译、跑测试、开界面手测确保布雷和扩散没有在加新功能时被改歪。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。