资讯详情

Java扫雷英雄榜开发实战:从算法到持久化的完整项目经验

📅 2026/10/25 12:12:50 | 华诺云谱 👁 阅读
Java扫雷英雄榜开发实战:从算法到持久化的完整项目经验
简介这是一份基于Java语言实现的扫雷游戏完整代码包面向Java初学者、GUI编程学习者以及有课程设计需求的学生。游戏在经典扫雷玩法基础上加入了等级难度设置并内置英雄榜记录与展示功能便于查看历史成绩排行。压缩包共19个文件包含7个Java源文件、7个class编译文件分别对应游戏主逻辑、雷区布局、方块显示、英雄榜记录等模块另有2张GIF图片、1个英雄榜文本文件以及project和classpath工程配置解压后可直接导入IDE运行或二次修改。整个包仅23KB轻量紧凑。目前已有672人学习下载适合借助源码理解事件监听、二维数组、文件读写等典型知识点也可作为Java小游戏项目实战的参考与拓展素材。1. Java 扫雷加英雄榜为什么说排行榜才是这个项目的分水岭扫雷的游戏本体对一个能独立写 Java 的人来说其实撑不过一个晚上二维数组、随机布雷、递归翻开、鼠标监听满打满算三百行。真正把项目从「课设能交」拉到「有点意思」的是那张英雄榜。时间怎么计时才准成绩按什么规则排序重启程序之后榜单数据怎么还在本地文件读写遇到中文乱码怎么处理这一连串问题几乎把 Java 基础、集合、IO、序列化全部串了一遍。下面我就按自己做这个项目的顺序从雷盘算法讲到英雄榜落库再讲清楚我踩过的坑适合正在走 Java 学习路线、想手写项目巩固基础的读者也适合拿它当课设但不想只交一个光秃秃闪存的你。2. 先把雷盘立住布雷算法、递归翻开与 Swing 事件绑定2.1 布雷算法与首击保护两种取样方式怎么选经典扫雷的第一条规则是首击必安全所以布雷必须在玩家第一次点击之后做而不是在窗口构造时完成。这样实现里自然多了一个「游戏未开始」状态不需要额外布尔变量维护。最简单的布雷写法是 while 循环随机命中public class MineField { private final int rows; private final int cols; private final int mineCount; private final boolean[][] mines; public MineField(int rows, int cols, int mineCount) { this.rows rows; this.cols cols; this.mineCount mineCount; this.mines new boolean[rows][cols]; // 默认全 false } // firstRow / firstCol 是玩家第一次左键点击的坐标 public void placeMinesAfterFirstClick(int firstRow, int firstCol) { int placed 0; while (placed mineCount) { int r (int) (Math.random() * rows); int c (int) (Math.random() * cols); if (mines[r][c]) { continue; // 已经放过雷重新抽 } // 避开首击点周围 3x3保证第一下绝不会踩雷 if (Math.abs(r - firstRow) 1 Math.abs(c - firstCol) 1) { continue; } mines[r][c] true; placed; } } }这里有两个关键点。第一外层必须用 while 而不是固定 for 循环随机数会重复命中已布雷的格子固定循环会导致实际布雷数少于 mineCount。第二避开 3x3 邻域这个条件会让一部分随机样本作废所以雷密度越高while 的期望循环次数越大但高级棋盘 30x16 共 480 格只放 99 雷密度约 0.2实际跑起来没有任何性能问题。更严谨的替代方案是「洗牌取前 N」把候选格子的编号放进列表shuffle 后直接拿前 mineCount 个。这个方案我更喜欢因为它的时间稳定性好也更容易在答辩里讲清楚「不重复抽样」这件事。public void placeMinesAfterFirstClick(int firstRow, int firstCol) { ListInteger candidates new ArrayList(); for (int i 0; i rows * cols; i) { int r i / cols; int c i % cols; if (Math.abs(r - firstRow) 1 Math.abs(c - firstCol) 1) { continue; // 安全区永不布雷 } candidates.add(i); } if (candidates.size() mineCount) { throw new IllegalArgumentException(棋盘太小无法容纳要求的雷数); } Collections.shuffle(candidates); for (int i 0; i mineCount; i) { int idx candidates.get(i); mines[idx / cols][idx % cols] true; } }一维索引折算二维坐标时除数和取模的对象一定是列数 cols不是行数 rows。这个坐标换算错位是我见过最容易翻车的地方一旦写反雷的位置全部落在镜像坐标上排查起来极其反直觉。两种方式的取舍其实就一句话while 版代码短但极端雷密度下有幂等风险洗牌版多几行换来稳定的执行时间。课程设计场景我会选洗牌版因为「Collections.shuffle 加一次全排列取样」比「while 死等随机数」好解释得多。2.2 数字格与递归展开0 区域的泛洪填满布雷结束后要生成每个格子的邻雷数。我建议先维护一个 Cell 对象把格子状态封装起来而不是用三四个平行的二维数组。这样递归翻开、插旗、绘制都只是改对象字段。public class Cell { public boolean isMine; public boolean isRevealed; public boolean isFlagged; public int count; // 0~8 表示周围雷数9 表示本身是雷 }邻雷统计的代码要处理边界最简单的写法是双重循环扫周围八格用坐标范围判断兜底public int[][] buildCounts() { int[][] counts new int[rows][cols]; for (int r 0; r rows; r) { for (int c 0; c cols; c) { if (mines[r][c]) { counts[r][c] 9; // 标记为雷绘制时用独立样式 continue; } int cnt 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 mines[nr][nc]) { cnt; } } } counts[r][c] cnt; } } return counts; }用 9 表示雷是为了让绘制逻辑统一走「先取 count 再决定画法」的分支1 到 8 画数字9 画雷0 什么都不画。这个约定比单独再建一个雷图标列表要省事。递归翻开是扫雷的核心体验点到数字 0 时要把整片相邻的 0 区域一次性展开直到边界遇到数字格。递归写法最直观public void reveal(int r, int c) { if (r 0 || r rows || c 0 || c cols) return; Cell cell cells[r][c]; if (cell.isRevealed || cell.isFlagged) return; // 已翻开或插旗的格子不再处理 cell.isRevealed true; revealedCount; // 这个字段后面判定胜负要用 if (cell.count 0) { for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; reveal(r dr, c dc); } } } }递归的两个终止条件缺一不可出界检查防止数组越界isRevealed 检查防止同一格被重复入栈。对于标准三档棋盘递归最大深度不会超过格子总数初级 81、高级 480远没到栈溢出阈值只有把棋盘做到上千格时才需要换成显式栈。插旗格跳过是整个扫雷交互里容易被忽略的细节——玩家插了旗的格子点左键不应该翻开这个 if 必须放在 isRevealed 判断旁边。2.3 从鼠标事件到 UI 刷新坐标系换算与状态机GUI 方案我选 Swing 自绘 JPanel而不是放一堆 JButton。原因是格子状态在翻牌前后差异很大JButton 的背景色、边框、点击态管理起来比画矩形繁琐得多自绘只需要一个 paintComponent所有格子按坐标循环绘制事件也只需要一个 MouseListener。private class MinePanel extends JPanel { private final int cellSize 28; 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; int y r * cellSize; Cell cell cells[r][c]; if (cell.isRevealed) { g.setColor(Color.LIGHT_GRAY); g.fillRect(x, y, cellSize, cellSize); if (cell.count 0 cell.count 9) { g.setColor(new Color(0, 0, 120)); g.drawString(String.valueOf(cell.count), x 10, y 18); } } else { g.setColor(new Color(190, 190, 190)); g.fillRect(x, y, cellSize, cellSize); if (cell.isFlagged) { g.setColor(Color.RED); g.drawString(旗, x 8, y 18); } } g.setColor(Color.GRAY); g.drawRect(x, y, cellSize, cellSize); } } } }绘制顺序是背景、内容、网格线三层网格线最后画可以保证格子边缘清晰。count 为 0 时不画数字这是还原经典扫雷的视觉习惯也能减少视觉噪音。鼠标事件是整个游戏的入口。用 mousePressed 而不用 mouseClicked因为右键点击在部分平台上的 mouseClicked 响应不一致而且按下瞬间就该给玩家反馈panel.addMouseListener(new MouseAdapter() { Override public void mousePressed(MouseEvent e) { if (gameOver || gameWin) return; // 终局统一拦截 int col e.getX() / cellSize; int row e.getY() / cellSize; if (row 0 || row rows || col 0 || col cols) return; if (SwingUtilities.isLeftMouseButton(e)) { handleLeftClick(row, col); } else if (SwingUtilities.isRightMouseButton(e)) { handleRightClick(row, col); } } });cellSize 选 28 是个折中值低于 22 时格子太密鼠标点击容易误触邻格高于 36 时高级棋盘 30x16 会占掉约 1080x580 像素加上边框和顶部信息栏在 1366x768 的笔记本上已经很挤。坐标换算是 e.getX() 除以 cellSize取整就是列号行号同理这个映射不需要考虑面板内边距只要 JPanel 没有额外 border。每个格子本质上是一个小状态机未翻开可以翻到翻开或插旗插旗可以取消翻开不可逆游戏终局后所有状态变更被拦截。这些规则分散在三个方法里最容易被遗漏的是 handleRightClick 对已翻开格子的拦截private void handleRightClick(int row, int col) { Cell cell cells[row][col]; if (cell.isRevealed) return; // 已翻开的格子不允许插旗 if (cell.isFlagged) { cell.isFlagged false; flagCount--; } else { cell.isFlagged true; flagCount; } panel.repaint(); }flagCount 只用于界面显示剩余雷数不参与胜负判定。这一点非常重要后面避坑章节会专门展开。3. 英雄榜数据链路计时精度、难度分级与 JSON 持久化3.1 计时精度两个时间戳比一个秒表可靠英雄榜的第一要素是公平公平的前提是计时准。新手最容易写的是每秒 seconds 的显示计时但把它当成绩依据就错了。正确做法是只记录开局和结束两个时间戳显示层的秒数永远从这两个时间戳换算public class GameTimer { private long startTime; private long finishTime; public void start() { startTime System.currentTimeMillis(); finishTime 0; } public void stop() { finishTime System.currentTimeMillis(); } public long elapsedMillis() { // 没停表时返回当前时间 - 开始时间停过就返回定格值 return (finishTime 0 ? System.currentTimeMillis() : finishTime) - startTime; } }不要在 GUI 里维护一个累计秒数变量。Swing Timer 的回调可能被事件队列阻塞线程 sleep 本身也存在调度误差累加久了偏差会越来越大。只存时间戳之后即使界面卡了两秒最终成绩依然按真实时间戳计算不会虚报。这个类同时服务界面显示和最终结算stop 之后 elapsedMillis 返回值被冻结不会一边显示一边变。界面刷新用 javax.swing.Timer每秒记一次 repaint显示值取 elapsedMillis() / 1000。这个 Timer 只负责画秒数不负责积累时间职责分得很干净。3.2 难度分级与完成判定英雄榜不能只放一张表三档难度对应经典扫雷的规则雷密度控制在 0.15 到 0.25 之间太高推理会失效太低又缺乏挑战难度棋盘雷数非雷格数初级9x91071中级16x1640216高级30x1699381英雄榜必须按难度分组否则中级玩家永远被高级玩家压在底下榜单失去参考意义。我的做法是 MapInteger, List key 是难度编号value 是各难度的 Top 10 列表。胜利判定是整个游戏里最容易写错的地方public boolean checkWin() { // 翻开数等于非雷格总数就算赢跟插了几面旗没有任何关系 return revealedCount rows * cols - mineCount; }这个条件的语义是「所有安全格全部被翻开」不是「所有雷被标出」。反过来想玩家插对了 10 面旗但棋盘上还剩 30 个安全格没点开这局显然没结束。revealedCount 必须在递归翻开方法里累计每次点击成功后 1踩雷时直接爆炸重置。成绩记录实体除了耗时还需要保存完成时刻和棋盘参数public class HeroRecord { private String playerName; private int difficulty; // 0 初级 / 1 中级 / 2 高级 private long elapsedMillis; private long finishTime; // 完成时的系统时间戳用于同秒并列排序 private int rows; private int cols; private int mineCount; }把 rows、cols、mineCount 存进记录而不是只存难度编号是为了以后加自定义难度时英雄榜不需要改表结构。finishTime 的用途是两个人耗时完全相同先完成的人排在前面。排序时 elapsedMillis 做主 keyfinishTime 做次 key这个顺序不能反。3.3 JSON 落榜Gson 序列化与存储选型对比单机游戏的英雄榜存储常见选择有三种方案优点缺点适用场景JSON 文件人类可读、调试容易并发写需自己加锁本机单机小游戏Properties简单直接不适合存 List只存键值对SQLite结构化查询、跨进程安全要引驱动写 SQL数据量大、需要汇总这个项目最多也就三个难度各 10 条记录JSON 文件完全够用。Gson 把 List 序列化成数组打开文件一眼能看明白以后要做全局榜单JSON 还能当导入导出格式用。核心实现如下public class HeroBoard { private static final int MAX_RECORDS_PER_LEVEL 10; private final MapInteger, ListHeroRecord boardByLevel new HashMap(); public synchronized void addRecord(HeroRecord record) { boardByLevel.computeIfAbsent(record.difficulty, k - new ArrayList()) .add(record); ListHeroRecord list boardByLevel.get(record.difficulty); list.sort(Comparator.comparingLong(HeroRecord::getElapsedMillis) .thenComparingLong(HeroRecord::getFinishTime)); if (list.size() MAX_RECORDS_PER_LEVEL) { list.subList(MAX_RECORDS_PER_LEVEL, list.size()).clear(); } } public synchronized void save(Path path) throws IOException { Files.createDirectories(path.getParent()); String json new GsonBuilder().setPrettyPrinting().create() .toJson(boardByLevel); Files.writeString(path, json, StandardCharsets.UTF_8); } public synchronized void load(Path path) throws IOException { if (!Files.exists(path)) return; String json Files.readString(path, StandardCharsets.UTF_8); Type type new TypeTokenMapInteger, ListHeroRecord(){}.getType(); MapInteger, ListHeroRecord loaded new Gson().fromJson(json, type); if (loaded ! null) { boardByLevel.clear(); boardByLevel.putAll(loaded); } } }这段代码里有几个值得展开的细节。所有读写方法都加了 synchronized防止「游戏线程结算成绩」和「玩家打开榜单窗口读数据」同时发生时 HashMap 并发修改抛 ConcurrentModificationException。单机程序里这看似多余但 Swing 的事件线程和执行线程可能交错加锁成本几乎为零。computeIfAbsent 是 Java 8 以后的 Map 扩展方法把「取出某个难度的列表为空就新建」从三行缩成一行。subList(10, size).clear() 是裁剪列表尾部最省内存的写法比重新 new 一个 ArrayList 少一次整表拷贝。Path 由调用方传入存储位置与游戏逻辑解耦IDE 调试时用临时目录打成 jar 后指向用户主目录——这个设计会在避坑章节看到价值。提示反序列化集合时Gson 必须配 TypeToken直接 fromJson(json, List.class) 会拿回一串 LinkedHashMap赋值给 List 会在第一次访问时抛 ClassCastException。这是 Gson 的经典八股坑。4. 英雄榜避坑指南5 个我踩过才跑顺的坑4.1 重启后榜单变空相对路径与工作目录现象在 IDEA 里运行一切正常英雄榜存了又读得到把项目打成可执行 jar双击运行排行榜数据像是从来没写过。原因Paths.get(heroboard.json) 是相对于 JVM 工作目录解析的。IDEA 默认把工作目录设到项目根目录而双击 jar 时工作目录可能是桌面或 jar 所在目录两边读到的根本不是同一个文件。load 方法遇到文件不存在又直接返回空列表表现得就像数据全丢了。解决先打印两个路径看清楚问题再把数据挪到用户主目录。System.out.println(System.getProperty(user.dir)); // 当前工作目录 System.out.println(System.getProperty(user.home)); // 用户主目录 Path boardPath Paths.get( System.getProperty(user.home), .minesweeper, heroboard.json);user.home 在 Windows 上解析为 C:\Users\xxx在 Linux 和 macOS 上分别是 /home/xxx 和 /Users/xxx。save 方法里已经写了 Files.createDirectories(path.getParent())第一次运行时目录不存在也会自动创建。核心原则持久化文件地址必须和「程序从哪里启动」无关。4.2 中文乱码文件编码不统一现象英雄榜里玩家输入「张三」重启后变成「å¼ ä¸‰」名字全部不可读。原因保存时用了 FileWriter 或 getBytes() 无参版本这两个 API 依赖 JVM 默认字符集中文 Windows 上通常是 GBK读取时用 new String(bytes) 又按 UTF-8 解码两边不一致中文全变乱码。更隐蔽的一种是 Gson 序列化时字符串在内存里是 UTF-16写文件时没显式指定编码读文件时却按 UTF-8 解同样翻车。解决所有文件读写一律显式 StandardCharsets.UTF_8禁止使用 FileWriter、FileReader 这类没有编码参数的旧 IO 类。就是 3.3 节那两行 Files.writeString(path, json, StandardCharsets.UTF_8) 和 Files.readString(path, StandardCharsets.UTF_8)。这个问题的难点在于它不一定立刻暴露如果你的系统默认编码恰好是 UTF-8可能开发几个月都没事换一台机器就崩。4.3 计时漂移Thread.sleep 不是计时器现象游戏内计时到 30 秒时实际秒表已经 31 秒玩得越久偏差越大英雄榜排名被那些「界面不卡」的机器不公平地拉开。原因一段很常见的错误实现是单独起一个线程每秒让 seconds 加一new Thread(() - { while (running) { Thread.sleep(1000); seconds; label.setText(seconds 秒); } }).start();一秒一次 sleep 的精度受线程调度影响而且每次循环里 label.setText 会触发 EDT 重绘如果这一帧卡了 50 毫秒误差就累积下来了。玩十分钟误差可能超过十几秒。解决只记起点和终点显示层每次都从时间戳换算。这就是 3.1 节 GameTimer 的设计。UI 卡顿只会让显示延迟刷新最终成绩永远等于 finishTime - startTime真实且稳定。4.4 快速双击翻车同一格被翻开两次现象玩家在格子边缘快速双击一个雷被直接翻开或者翻开计数被重复累加导致 checkWin 提前触发。原因mousePressed 在按下瞬间触发快速双击产生的两个事件会先后进入处理方法。第一个事件把格子状态改成 isRevealed但第二个事件可能已经排队到达。如果 handleLeftClick 开头没有做状态拦截会对同一格执行两次 reveal如果第一击踩雷爆炸后没设置 gameOver第二击还会再次进入爆炸逻辑把已结束的棋盘画花。解决在事件监听器和处理方法两层都加终局拦截构成双保险private void handleLeftClick(int row, int col) { if (gameOver || gameWin) return; // 终局后不再响应任何点击 Cell cell cells[row][col]; if (cell.isRevealed || cell.isFlagged) return; reveal(row, col); if (cell.isMine) { gameOver true; // 触发爆炸后立刻锁死状态 return; } if (checkWin()) { gameWin true; finishGame(); // 这里才结算英雄榜成绩 } }监听器里的 if (gameOver || gameWin) 挡住了绝大多数脏事件但处理方法内再写一次是必要的某些平台会把一次快速双击折叠成两个事件两个事件都能通过监听器第二个进入时靠 cell.isRevealed 拦截。最危险的是踩雷场景第一个事件把 gameOver 置 true第二个事件如果没有同一道拦截就会重复执行爆炸绘制棋盘画面直接花掉。4.5 插旗影响成绩胜利判定别数旗子现象玩家把所有雷的位置插上旗系统提示胜利并写入英雄榜但棋盘上还留着大量没翻开的安全格。原因胜利判定写成了 flagCount mineCount allFlagsOnMine。这个条件只能说明旗子插对了位置不能说明雷区已彻底排除。扫雷的规则是「所有非雷格全部翻开」不是「所有雷被标出」。旗子只是玩家的辅助记忆工具游戏引擎不该把它当作完成条件。解决统一用 3.2 节的 checkWin 写法判定只看 revealedCount rows * cols - mineCount。界面上「剩余雷数」可以照常显示成 mineCount - flagCount方便玩家数雷但它永远不进入胜负逻辑。这个坑我之所以放在最后是因为它最容易在考试和面试的代码 review 里被称为「逻辑漏洞」——判断任务完成度时一定要看目标状态而不是看辅助工具状态。5. 往英雄榜里加「复盘」操作序列成了成绩的验真凭证5.1 存每一步回放整盘顺手校验成绩英雄榜如果只存一个秒数会有两个问题一是玩家怀疑「这成绩是不是改出来的」二是想学习高手怎么开局却无从下手。我给项目加了一个不算复杂但提升很大的功能把整盘游戏的操作序列也写进英雄榜目录形成一份 replay 数据。ListMoveRecord moves new ArrayList(); public void recordMove(int row, int col, String action) { moves.add(new MoveRecord(moves.size() 1, row, col, action, System.currentTimeMillis() - startTime)); }MoveRecord 是 HeroRecord 的姊妹类字段只有序号、行、列、动作类型、时间偏移。动作类型就三种reveal、flag、unflag。回放时用一个 Swing Timer每取一条记录就把棋盘恢复到那一步的状态玩家可以完整看到高手从第一击到收官的下法。这个功能顺带解决了成绩验真。把英雄榜的 elapsedMillis 和 moves 列表最后一条的 offsetMillis 对比如果两者差距超过 1000 毫秒说明计时有猫腻如果一条 reveal 记录落在一个预先标记的雷格上而游戏没有结束那一定是数据被手改过。我养成的习惯是榜单页面加「查看回放」按钮点开先校验这两个值再有疑问就按 replay 逐步跑一遍比空口争论快得多。做这个项目最大的教训有两个任何持久化数据先想清楚「重启之后从哪读」再动手写读写代码任何计时逻辑只存时间戳绝不存累计秒数。这两条习惯后来帮我少踩了很多类似项目的坑。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑