2026/10/14 12:01:28

扫雷加英雄榜:Java完整练手项目从算法实现到防作弊设计

扫雷加英雄榜:Java完整练手项目从算法实现到防作弊设计 简介面向Java学习者的扫雷游戏完整源码工程覆盖经典扫雷玩法与英雄榜、等级设置。资源共19个文件含7个Java源文件MineGame、MineArea、BlockView、Record、LayMines、ShowRecord、Block等及对应class文件另附mine.gif、mark.gif图标和英雄榜.txt文本压缩包仅23KB轻量方便。已有672人学习浏览适合正在练习Swing界面、事件处理、二维数组与文件读写的初学者。通过阅读并运行代码可掌握雷区随机布雷、邻居计数、左键翻开、右键标记、胜负判定以及计时和不同等级难度切换等核心逻辑还能看到英雄榜排名如何通过txt文件实现持久化保存为后续扩展菜单栏、音效或排行榜界面提供清晰基础。整体代码结构分层明确是一个适合课程设计或自学实践的完整示例。1. 扫雷加英雄榜一个被低估的 Java 练手完整项目如果你以为扫雷只是大学课本里事件监听 二维数组的课后作业那这个标题值得你重新看一眼。把「扫雷」和「英雄榜」放在一起意味着一个真正有始有终的完整项目前半段你要解决布雷算法、递归展开、右键插旗这些经典逻辑后半段你还要面对排行榜持久化——数据存哪、怎么排序、重名怎么办、榜单会不会被作弊刷爆。很多自学 Java 的人卡在写了游戏没法收尾这个项目恰好把最后一段补上了。更实际地说这个项目能同时练到 Swing/JavaFX 界面、核心算法设计、文件 I/O 或 JDBC 持久化、以及排序与去重策略。适合已经学完 Java SE 基础、正想找一个能写进简历又能跑给面试官看的完整项目的开发者。我见过有人用这个题目做了三版第一版纯控制台第二版上了 Swing第三版加了数据库排行榜并做了防作弊校验。每一版的深度都不一样但核心的坑基本一致。2. 扫雷核心逻辑从布雷到自动展开的实现与参数设计2.1 布雷算法如何保证首击安全且不卡死布雷是整个游戏的地基。最常见的方案是Random生成坐标但直接随机有两个隐患地雷数量少时可能生成重复坐标导致雷数不足玩家第一步就踩雷的体验非常糟糕。我一般的做法是首击保护——第一步点击前不布雷等玩家点下第一格后以该格为中心九宫格之外的区域布雷。public class MineField { private final int rows; private final int cols; private final int mineCount; private final boolean[][] mines; private final int[][] neighborMines; public MineField(int rows, int cols, int mineCount) { this.rows rows; this.cols cols; this.mineCount mineCount; this.mines new boolean[rows][cols]; this.neighborMines new int[rows][cols]; } public void placeMines(int safeRow, int safeCol) { Random random new Random(); int placed 0; while (placed mineCount) { int r random.nextInt(rows); int c random.nextInt(cols); // 不允许布雷在首击格及其九宫格内 if (Math.abs(r - safeRow) 1 Math.abs(c - safeCol) 1) { continue; } if (!mines[r][c]) { mines[r][c] true; placed; } } computeNeighborCounts(); } private void computeNeighborCounts() { 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 (mines[r][c]) { continue; } int count 0; for (int i 0; i 8; i) { int nr r dr[i]; int nc c dc[i]; if (nr 0 nr rows nc 0 nc cols mines[nr][nc]) { count; } } neighborMines[r][c] count; } } } }这段代码里dr和dc是八方向偏移数组配合边界检查就可以避免每个格子写八次判断。placeMines里的九宫格排除范围用的是 1判断如果你的首击安全区想扩大到两圈比如地狱难度改成 2即可。另外注意Random实例在方法内新建避免并发问题——虽然 Swing 是单线程模型但养成习惯没坏处。2.2 递归展开深度优先展开与栈溢出边界扫雷点击空白格时要自动展开所有相邻的空白格。经典实现是递归 DFS但递归在极端情况下有栈溢出风险——比如一个 30x30 的棋盘上空白连成一片可能有数百格递归深度虽然一般达不到溢出阈值但为了稳妥我习惯用显式栈做 BFS。public void expand(int row, int col, boolean[][] revealed) { if (row 0 || row rows || col 0 || col cols) { return; } if (revealed[row][col]) { return; } revealed[row][col] true; if (neighborMines[row][col] ! 0) { return; // 数字格展开到此为止 } // 空白格入队继续扩散 Queueint[] queue new LinkedList(); queue.offer(new int[]{row, col}); while (!queue.isEmpty()) { int[] cell queue.poll(); int r cell[0]; int c cell[1]; for (int i 0; i 8; i) { int nr r dr[i]; int nc c dc[i]; if (nr 0 nr rows nc 0 nc cols !revealed[nr][nc] !mines[nr][nc]) { revealed[nr][nc] true; if (neighborMines[nr][nc] 0) { queue.offer(new int[]{nr, nc}); } } } } }注意 BFS 和递归在结果上是完全一致的但 BFS 没有栈深度的问题。这里的mines[nr][nc]判断会把雷格挡住所以你不需要担心展开时踩到雷——雷是永远不会被 BFS 展开的只有玩家主动点击才会触发。边界检查一定要放在数组访问之前这是新手最容易翻车的地方。2.3 胜利判定揭开所有非雷格才算赢而不是猜雷很多人写扫雷的胜利判定是所有雷都被标了旗这是错误设计。正确的判定是所有非雷格全部被揭开。因为玩家可以插错旗、可以不插旗直接点开所有安全格。所以正确做法是在每次revealed状态变化后统计已揭开的非雷格数量是否等于rows * cols - mineCount。public boolean checkWin(boolean[][] revealed) { int exposed 0; for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (revealed[r][c] !mines[r][c]) { exposed; } } } return exposed rows * cols - mineCount; }统计方式在 30x30 棋盘上每次点击后全量遍历 900 个格子完全没压力没必要做增量计数代码可读性优先。踩雷就直接结束不需要判定输赢函数。3. 游戏交互层右键插旗、双键快开与计时器的正确姿势3.1 三种格子状态与右键鼠标事件扫雷的格子状态至少有三种未揭开、已揭开、插旗。如果还想支持标问号那就是四种。Swing 里的做法是给每个格子绑定一个自定义JButton用setIcon切换图标或者覆写paintComponent自己画。我推荐后者——性能更好也更容易做动画。button.addMouseListener(new MouseAdapter() { Override public void mousePressed(MouseEvent e) { if (gameOver || gameWon) { return; } int row gridRow(button); int col gridCol(button); if (SwingUtilities.isRightMouseButton(e)) { toggleFlag(row, col); } else if (SwingUtilities.isLeftMouseButton(e)) { // 左键逻辑如果是插旗格忽略点击 if (isFlagged(row, col)) { return; } revealCell(row, col); } } });这里gridRow和gridCol是根据按钮在容器中的位置换算成棋盘行列的辅助方法。右键插旗的规则是未揭开 → 插旗已插旗 → 取消旗如果是问号模式则是三态循环。关键点在于左键点击插旗格时一定要忽略否则会出现旗子底下踩雷的诡异情况。3.2 双击快开数字格双击的连锁反应当格子已揭开且数字等于周围旗子数时双击/双键同按可以快速打开周围未插旗的格子。这一步是扫雷体验的核心没有它游戏速度会慢一半。private void quickOpen(int row, int col) { if (!revealed[row][col] || neighborMines[row][col] 0) { return; } int flagCount 0; for (int i 0; i 8; i) { int nr row dr[i]; int nc col dc[i]; if (inBounds(nr, nc) isFlagged(nr, nc)) { flagCount; } } if (flagCount ! neighborMines[row][col]) { return; // 旗子数不对不允许快开 } for (int i 0; i 8; i) { int nr row dr[i]; int nc col dc[i]; if (inBounds(nr, nc) !isFlagged(nr, nc) !revealed[nr][nc]) { revealCell(nr, nc); } } }快开前一定要校验旗子数量是否等于数字这是防止误操作的核心防线。如果玩家旗子插错了快开会导致点开雷区直接爆炸——有些版本的扫雷会先弹提示阻止这次操作我建议不做阻止直接炸这样玩家才能记住插错旗的教训。3.3 计时器逻辑从第一次点击开始赢了要停表计时器是英雄榜数据来源的基础规则很简单第一次左键点击后才开始计时踩雷或胜利时停止。Swing 里用javax.swing.Timer而不是Thread.sleep避免阻塞事件分发线程。Timer timer new Timer(1000, e - { elapsedSeconds; timeLabel.setText(formatTime(elapsedSeconds)); }); timer.setInitialDelay(1000);启动时机放在revealCell方法里用一个布尔标志位gameStarted控制。第一次点击后调用timer.start()游戏结束时timer.stop()。这里有一个隐蔽的坑Timer的默认构造是不重复的必须调用timer.setRepeats(true)否则它只触发一次就停了。4. 英雄榜设计与实现数据存哪里、怎么排序、如何防作弊4.1 三种持久化方案对比文件、序列化、数据库英雄榜本质是玩家名 用时 难度 时间戳的集合。存储方案有三条路按项目规模递增纯文本文件CSV/JSON、Java 对象序列化、嵌入式数据库如 SQLite/H2。方案一、CSV 文本文件 优点可读性好能用文本编辑器直接改适合作业展示 缺点并发写入要自己加锁防作弊要自己做 方案二、ObjectOutputStream 序列化 优点实现最简单对象直接落盘 缺点类结构变更后老文件读不了版本兼容性差 方案三、SQLite/H2 嵌入式数据库 优点查询、排序、去重交给 SQL防重名和分页都很方便 缺点需要引入 JDBC 驱动项目体积变大我给大多数人的建议是课程设计或简历项目用 CSV 或 JSON如果写到第二版且有时间就换成 SQLite。英雄榜的读写本身不是难点难点在于排序规则和防作弊这两点做好才能叫英雄榜而不是记录表。4.2 英雄榜排序主排用时、次排时间戳不是一个 ORDER BY 就完事排序看似简单——用时短的排前面。但实际问题很具体两个人用时相同怎么办谁排前面答案是按先达成者在前排也就是elapsedSeconds升序相同时按写入时间戳升序。如果你用 CSV 存储排序就要自己写。public class ScoreEntry implements ComparableScoreEntry { private final String playerName; private final int seconds; private final long timestamp; private final String difficulty; public ScoreEntry(String playerName, int seconds, long timestamp, String difficulty) { this.playerName playerName; this.seconds seconds; this.timestamp timestamp; this.difficulty difficulty; } Override public int compareTo(ScoreEntry other) { int cmp Integer.compare(this.seconds, other.seconds); if (cmp ! 0) { return cmp; } return Long.compare(this.timestamp, other.timestamp); } // getter 略 }compareTo只处理排序规则不处理这个榜单最多保留多少条。我一般把榜单长度限制在 10 条保存时先读旧榜单合并新纪录排序后截断前 10 条写回。但要注意不是所有难度共用一个榜初级、中级、高级的用时没有可比性必须分开存、分开显示。4.3 防作弊的三条防线开局时间、系统时钟、文件篡改英雄榜最容易出现的问题是作弊不是恶意攻击而是存档被手动改和计时器提前启动。我做过的方案里比较有效的三条防线如下第一计时器从第一次有效点击才开始不能在界面初始化时就走表第二落盘时记录一个校验字段比如用playerName seconds salt做一次SHA-256读取时校验对不上就标记为数据异常第三记录系统时间戳而非只用相对秒数这样能发现用时 1 秒但开局时间对不上的异常记录。public static String checksum(String name, int seconds, String difficulty, String salt) { try { MessageDigest digest MessageDigest.getInstance(SHA-256); String raw name | seconds | difficulty | salt; byte[] hash digest.digest(raw.getBytes(StandardCharsets.UTF_8)); StringBuilder hex new StringBuilder(); for (byte b : hash) { hex.append(String.format(%02x, b)); } return hex.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } }salt可以写死在程序里也可以每次启动随机生成并写入配置文件。如果你是课程设计写死就够如果想让别人觉得你考虑到了安全问题就用配置文件方案。这条防线挡住的是手改文本文件级别的作弊挡不住反编译改字节码的但对这个量级的项目已经足够。5. 避坑指南扫雷英雄榜最常见的五个翻车现场5.1 数字格周围没算雷数就展开界面全是 0现象棋盘点开后一大片空白边界上的格子显示 0正常的数字 1/2/3 全都不出现。原因computeNeighborCounts()没有被调用或者调用的时机在布雷之前。若你在棋盘初始化时就计算邻居数此时mines数组全为false算出来的数字自然全是 0等placeMines()布雷后没有重算。解决把computeNeighborCounts()强制放在placeMines()的末尾执行并且只在布雷完成后调用一次。可以在placeMines里加一行调用也可以在面板刷新前调用一次——但无论如何不能在构造器里提前算。调试时在数字计算后加一条System.out.println输出整个neighborMines矩阵肉眼比对是否与布雷图一致。5.2 递归展开导致栈溢出不是算法问题而是漏了访问标记现象中等以上难度开局点空白格时程序直接抛出StackOverflowError。原因递归 DFS 展开时没有在入栈前设置revealed标记导致同一个格子被重复入栈多次。拿 30x30 棋盘举例一个空白格周围最多 8 个邻格如果每个格子被 8 个方向重复访问递归深度是指数级增长的。解决在expand入口处先判断revealed并立即置true确保每个格子最多被处理一次。如果你已经写了这个判断还溢出检查一下判断条件是不是写成了if (revealed[r][c]) return;而不是if (!revealed[r][c]) return;——我见过有人把两个写反结果所有格子都已揭开然后全部跳过。5.3 右键插旗后左键仍能点开旗子底下藏雷现象插了旗的格子左键点击后直接爆炸玩家认为这是 Bug。原因鼠标事件监听器没有拦截已插旗状态的左键点击。revealCell方法里只检查了mines数组没有检查flagged状态。解决在mousePressed里加一句if (isFlagged(row, col)) return;或者在revealCell方法开头统一拦截已插旗、已揭开、游戏已结束三件事都直接返回。统一拦截更稳妥因为不仅能挡住鼠标还能挡住键盘快捷键触发的点击。5.4 英雄榜保存时文件被占用或写入不完整现象第一次写入榜单一切正常第二次玩完存档时报IOException: File is locked或者榜单文件里的最后一行是残缺的。原因Windows 下多个 JVM 实例同时运行导致文件锁或者写入过程中程序崩溃没有做原子写。很多新手用FileWriter直接写没有 flush 也没有 close数据还在缓冲区就断电了。解决使用临时文件 原子重命名方案。先写到一个.tmp文件写完 flush 并 close然后用Files.move替换原文件。这样即使写入中途崩溃原文件也不会坏最多丢一份上次的榜单。public void saveScores(ListScoreEntry scores, Path target) throws IOException { Path temp target.resolveSibling(target.getFileName() .tmp); try (BufferedWriter writer Files.newBufferedWriter(temp, StandardCharsets.UTF_8)) { for (ScoreEntry entry : scores) { writer.write(entry.toCsvLine()); writer.newLine(); } } Files.move(temp, target, StandardCopyOption.REPLACE_EXISTING); }5.5 英雄榜里出现0 秒通关的假记录现象榜单上有人用时 0 秒或 1 秒明显不可能。原因计时器没有正确停止或启动。常见情况是玩家第一击之前计时器已走表但写榜时只把第一次点击后的秒数存了进去或者游戏结束时忘记timer.stop()玩家在结算弹窗停留的时间里秒数还在涨。解决写榜时用elapsedSeconds的副本而不是实时读取的标签文本。我建议在停止计时后立刻把elapsedSeconds赋值给一个finalScore变量所有后续逻辑都基于finalScore。如果玩家的finalScore 1除非棋盘只有 1 个格子不可能否则直接判定为无效记录拒绝写入并弹提示。6. 让英雄榜更有说服力校验逻辑与数据导出的进阶玩法英雄榜做完之后有一个细节能让整个项目的完成度明显上一个台阶把榜单细节导出来做人工校验。我通常会在英雄榜旁边加一个详情按钮——点击后弹窗显示当前记录的难度、用时、格子的详细坐标甚至可以把每一步操作序列导成文本文件。这个功能对普通玩家没用但对你自己调试和给面试官展示非常有效它能证明你的用时记录不是编的。public void exportGameLog(Path outputPath, int elapsedSeconds, boolean won) { ListString lines new ArrayList(); lines.add(result (won ? WIN : LOSE)); lines.add(elapsed_seconds elapsedSeconds); lines.add(difficulty currentDifficulty.displayName()); for (Operation op : operationLog) { lines.add(op.type , op.row , op.col , op.timestamp); } Files.write(outputPath, lines, StandardCharsets.UTF_8); }operationLog是一个ArrayListOperation每次点击时记录这个日志本身就是天然的调试工具。例如玩家报告我明明赢了但没上榜你让 TA 导出日志对照检查是不是胜利判定瞬间还漏了最后一格没揭开。在多数情况下你会发现是标完所有雷就算赢的错误逻辑导致的——改成非雷格全部揭开后问题消失。另一个值得做的功能是同难度最佳成绩对比。把英雄榜的前三名和玩家最近十局的平均用时放到同一个面板上左边是历史最佳右边是当前均值。玩家能看到自己有没有进步而你也能拿到一份有说服力的数据如果一个人的平均值远小于榜单最低成绩那这条记录大概率是改出来的。这个思路不需要复杂图表两张JTable甚至JLabel就能展示。做这个项目时我自己的最深体会是排行榜不难但让人相信排行榜可信很难。如果你只是把Collections.sort一写就觉得完事那面试官随便问一句文件里手改成 1 秒怎么办就会露馅。把校验、日志、异常数据标记都做好整个项目的含金量完全不在一个量级。最后说句实在话——我第一版英雄榜就是被人用文本编辑器改成了 0 秒记录才意识到要加校验的。这种坑踩过一次就不会忘希望这篇笔记也能帮你在动手前就绕开它。本文还有配套的精品资源点击获取