Java扫雷英雄榜:轻量级本地排行榜实现方案
简介:这是一份基于Java实现的经典扫雷游戏完整工程,面向Java初学者与课程设计实践者,帮助理解Swing图形界面开发、事件驱动编程及简单数据持久化逻辑。资源包含可直接运行的GUI程序,支持多等级难度切换,并集成英雄榜功能,用于记录和展示玩家最佳通关成绩,适用于算法课设、GUI实训或趣味编程入门项目。压缩包共19个文件,含7个核心Java源码(如MineGame.java、Record.java、LayMines.java等)、7个对应编译类文件、2个界面用GIF动图(mine.GIF、mark.GIF)、1个说明文本(英雄榜.txt)以及Eclipse项目配置文件(.project、.classpath),整体仅23KB,轻量易读。目前已有673人学习下载,代码结构清晰、模块职责分明——MineArea与Block负责地图与格子逻辑,BlockView实现渲染,ShowRecord与Record支撑榜单读写,是理解面向对象设计与小型游戏架构的优质参考范例。
1. 一个扫雷游戏,凭什么要搞英雄榜?——它真能撑起本地局域网多人对战的最小闭环
上周帮某高校实验室调试一个课程设计项目,学生交上来一个 Java 扫雷,界面清爽、逻辑完整,但一问“怎么比谁更快”,他愣了三秒:“……点开记事本把时间手输进去?”
这就是「java 扫雷 英雄榜」存在的真实理由:它不是又一个 Swing 练手 Demo,而是一个带持久化排行榜 + 时间精度到毫秒 + 支持多用户本地同机/局域网身份隔离 + 启动即用零配置的可交付游戏模块。核心能力就三点:
- 每局结束自动记录用户名、耗时(ms)、雷区尺寸(9×9 / 16×16 / 16×30)、雷数、胜负状态;
- 排行榜按“小雷区最快胜出”“大雷区无误通关”“总胜场TOP5”三类视图动态聚合,不靠数据库,纯文件读写 + 内存缓存;
- 启动时弹出轻量登录框(非强制),输入昵称后所有操作绑定该 ID,同一台机器多个终端启动互不干扰——这才是“英雄榜”不是摆设的关键。
适合两类人直接拿走就跑:一是 Java GUI 课程设计需要交“有数据、有对比、有交互”的硬核作业;二是嵌入到更大系统(比如某跨平台系统教学平台)中作为轻量级竞技小游戏模块。它没用 Spring Boot,没连 MySQL,甚至没加 Log4j——就是 JDK 8+ 一条java -jar minesweeper-leaderboard.jar能亮屏、能点、能记、能排。
2. 架构拆解:三层结构如何用 23 个类撑起“轻量但可靠”的游戏闭环
这个项目不是单文件堆砌,而是有明确分层意识的工程化组织。整个源码包共 23 个.java文件,按功能划分为Game Core(11 类)、Leaderboard System(7 类)、UI & Bootstrap(5 类)三大块。没有 Maven 多模块,但包结构清晰:game.core、game.leaderboard、game.ui三个顶层包,外加一个根包下的Main.java入口。这种结构让二次开发时能精准定位——你要改计时逻辑?去game.core.timer;想换排行榜存储格式?盯死game.leaderboard.storage;要加音效?只动game.ui.sound。
2.1 游戏内核:从 Cell 到 GameBoard 的对象建模
扫雷最易被忽略的其实是格子(Cell)的状态管理。本项目没用布尔数组暴力标记,而是定义了Cell类,封装了 5 种状态:
public enum CellState { HIDDEN, // 未翻开(默认) REVEALED, // 已翻开(显示数字或空) FLAGGED, // 插旗(用户标记) QUESTION, // 问号(用户不确定) MINE // 真实地雷(仅游戏结束时揭示) }关键在GameBoard初始化时的雷分布算法——它用的是拒绝采样法(Rejection Sampling),而非简单随机打乱索引:
// game.core.board.GameBoard.java private void placeMines(int firstClickRow, int firstClickCol) { Random rand = new Random(); int placed = 0; while (placed < totalMines) { int r = rand.nextInt(height); int c = rand.nextInt(width); // 首次点击位置及其八邻域禁止布雷(反直觉但必须!否则玩家第一点就炸,体验崩盘) if (isAdjacentTo(firstClickRow, firstClickCol, r, c) || board[r][c].isMine()) { continue; } board[r][c].setMine(true); placed++; } }提示:
isAdjacentTo()判断包含自身(即首次点击格子本身),这是扫雷规则的硬性要求。很多新手实现漏掉这点,导致首点必炸,被学生吐槽为“玄学扫雷”。
2.2 计时器:毫秒级精度与生命周期绑定
计时不是用System.currentTimeMillis()简单相减。项目定义了GameTimer类,内部使用System.nanoTime()获取纳秒级起点,并在GameController中严格控制启停时机:
- 启动时机:用户第一次左键点击非雷格子的瞬间(不是窗口打开时);
- 暂停时机:游戏胜利/失败弹窗出现时;
- 终止时机:用户关闭结果弹窗后,计时器对象被 GC 回收。
// game.core.timer.GameTimer.java public class GameTimer { private long startTime; private long pauseTime; private long accumulatedTime; private boolean isRunning; public void start() { if (!isRunning) { startTime = System.nanoTime(); isRunning = true; } } public long getElapsedMillis() { if (!isRunning) return accumulatedTime / 1_000_000L; // 纳秒→毫秒 return (accumulatedTime + System.nanoTime() - startTime) / 1_000_000L; } }参数说明:accumulatedTime用于累计暂停期间的耗时,避免多次暂停导致计时丢失。这是保证“暂停再继续”逻辑正确的关键设计,也是很多 DIY 扫雷翻车的黑匣子。
2.3 排行榜系统:文件存储 + 内存缓存的双保险
game.leaderboard包下 7 个类,核心是LeaderboardManager和LocalFileStorage。排行榜不依赖外部服务,数据落地为leaderboard.json,格式精简:
{ "entries": [ { "username": "ZhangSan", "elapsedMs": 12480, "boardSize": "9x9", "mineCount": 10, "win": true, "timestamp": "2024-06-15T14:22:33.123" } ] }LocalFileStorage实现了线程安全的读写:
- 读取:
Files.readString(path)→ Gson 解析,失败则返回空列表(防文件损坏); - 写入:先写临时文件
leaderboard.json.tmp,成功后再原子替换原文件(Files.move(tmp, real, REPLACE_EXISTING)),避免写到一半崩溃导致数据全丢。
注意:
LeaderboardManager在 JVM 启动时加载一次进内存,后续增删查均操作内存 List,最后统一刷盘。这是性能与可靠性平衡的常见做法——既避免每局都 IO,又保证异常退出时最多丢失最后一局数据。
3. 运行指南:从 JDK 安装到双击运行的完整链路(含 Windows/macOS/Linux 差异)
项目对环境要求极低:JDK 8u202 或更高版本即可,无需额外安装 JRE 或配置 CLASSPATH。但不同系统下启动方式和路径处理有细节差异,这里列全。
3.1 环境准备:确认 JDK 版本与路径
先验证 JDK 是否就位:
# Windows PowerShell java -version # 应输出类似:java version "1.8.0_361" # macOS / Linux 终端 /usr/libexec/java_home -V # 确保至少有一条 1.8+ 的路径提示:若
java -version报错,Windows 用户请下载 Adoptium Temurin 8u362(免费开源),macOS 用户用brew install openjdk@8,Linux 用户sudo apt install openjdk-8-jdk。别用 Oracle JDK 8 官方版——它已停止公开更新,部分证书链失效会导致javax.net.ssl.SSLHandshakeException(虽然本项目不用网络,但某些 Swing 组件会意外触发 HTTPS 请求)。
3.2 启动方式:三种姿势,任选其一
方式一:命令行直接运行(推荐,可控性强)
进入项目根目录(含minesweeper-leaderboard.jar的文件夹):
# Windows java -jar minesweeper-leaderboard.jar # macOS / Linux java -jar minesweeper-leaderboard.jar此时会弹出登录框,输入昵称(支持中文,最长 12 字符),点击确定后进入主游戏界面。
方式二:双击 jar 文件(Windows/macOS 图形化场景)
- Windows:确保
.jar关联到javaw.exe(非java.exe),否则会闪退黑窗。右键 jar → “属性” → “更改” → 选择 “Java(TM) Platform SE binary”; - macOS:需在“访达”中右键 → “显示简介” → “打开方式” 选 “Jar Launcher”,并勾选“始终以此方式打开”。
注意:Linux 桌面环境(GNOME/KDE)双击 jar 默认调用
java -jar,但部分发行版(如 Ubuntu 22.04)默认禁用 jar 双击,需手动右键 → “属性” → “权限” → 勾选“允许作为程序执行”。
方式三:IDE 内直接启动(开发调试首选)
以 IntelliJ IDEA 为例:
- File → Project Structure → Project Settings → Project → SDK 选 1.8;
- Run → Edit Configurations → + → Application;
- Main class 填
game.ui.Main; - Working directory 设为项目根目录(含
leaderboard.json的位置); - 点击 Run,等同于命令行启动,且可打断点调试
GameController流程。
3.3 配置文件位置与自定义
所有用户数据存在leaderboard.json,默认与 jar 包同目录。若想集中管理(比如部署到学校机房),可修改game.leaderboard.storage.LocalFileStorage中的路径:
// 原始代码(相对路径) private static final Path DEFAULT_PATH = Paths.get("leaderboard.json"); // 改为绝对路径(例如 Windows 机房统一存 D:\games\) private static final Path DEFAULT_PATH = Paths.get("D:/games/minesweeper/leaderboard.json");编译后重新打包 jar 即可。切勿用中文路径名——Paths.get()在部分 JDK 8 版本下对 UTF-8 路径解析不稳定,曾有学生反馈“存了数据但读不出来”,最后发现是路径含“扫雷”二字导致Files.exists()返回 false。
4. 避坑指南:那些让我重装三次 JDK 的真实翻车现场
这项目看着简单,但实际部署时踩过的坑,足够写半篇血泪经验。以下是 5 条高频问题,按现象→原因→解决三段式整理,全是真实复现过的。
4.1 现象:双击 jar 无反应,任务管理器里看不到 java 进程
原因:Windows 系统默认用java.exe启动 jar,它会打开控制台窗口;而本项目 UI 是纯 Swing,无控制台输出,窗口一闪即逝,用户以为“没启动”。
解决:右键 jar → “打开方式” → “选择其他应用” → 勾选“始终使用此应用打开” → 找到javaw.exe(通常在C:\Program Files\Java\jre1.8.0_XXX\bin\下)。javaw.exe是无控制台版 Java 启动器,专为 GUI 程序设计。
4.2 现象:排行榜显示“未知用户”,所有记录用户名都是 “guest”
原因:登录框弹出后,用户直接按回车跳过输入,代码中JOptionPane.showInputDialog()返回null,程序 fallback 到默认用户名"guest",但未做空值校验。
解决:在game.ui.LoginDialog.java的showLoginDialog()方法末尾加校验:
if (username == null || username.trim().isEmpty()) { JOptionPane.showMessageDialog(null, "用户名不能为空,请重新输入", "输入错误", JOptionPane.ERROR_MESSAGE); return showLoginDialog(); // 递归重试 } return username.trim().substring(0, Math.min(12, username.length())); // 截断防超长4.3 现象:游戏胜利后弹窗显示时间比实际长 2~3 秒
原因:GameTimer的stop()方法被调用两次——一次在GameController的胜利逻辑里,另一次在JFrame的windowClosing事件中。重复调用导致accumulatedTime被叠加计算。
解决:在GameTimer.stop()中加守卫:
public void stop() { if (!isRunning) return; // 防重复停止 accumulatedTime += System.nanoTime() - startTime; isRunning = false; }4.4 现象:macOS 上中文用户名显示为方块,排行榜里全是 “???”
原因:LocalFileStorage使用Files.writeString()时未指定字符集,默认用系统编码(macOS 是 UTF-8,但某些终端 locale 设为en_US.ISO8859-1会导致写入乱码)。
解决:显式指定 UTF-8:
// 替换原 writeString 调用 Files.writeString(path, json, StandardCharsets.UTF_8);同时确保Gson实例也强制 UTF-8:
Gson gson = new GsonBuilder() .setPrettyPrinting() .disableHtmlEscaping() // 防中文被转义 .create();4.5 现象:局域网内两台电脑同时运行,A 电脑修改了排行榜,B 电脑重启后看不到新记录
原因:leaderboard.json是本地文件,不支持网络共享。所谓“局域网多人”,是指多台机器各自独立运行、各自维护自己的排行榜,而非实时同步。标题里的“局域网”仅指“可部署在同一网段的多台教学终端上”,非分布式同步。
解决:明确预期——这不是一个在线排行榜。若真需共享,需自行扩展RemoteLeaderboardStorage接口,对接轻量 HTTP Server(如 SparkJava)或 SQLite 网络数据库。但项目原始设计就是离线优先,这是刻意为之的架构取舍。
5. 二次开发实战:给英雄榜加“难度系数”排序与导出 Excel 功能
现在你已经能跑通、能改配置、能避坑,下一步就是让它真正属于你。我以两个真实需求为例,展示如何在不破坏原有结构的前提下安全增强:增加“难度系数”维度排序(让 16×30 雷区的 30 秒胜出,自动排在 9×9 的 10 秒前面),以及一键导出排行榜为 Excel(方便教师统计课堂数据)。这两个改动加起来不到 80 行代码,却极大提升实用性。
5.1 难度系数:从硬编码到可配置的权重体系
当前排行榜按elapsedMs单一字段排序,但不同尺寸雷区难度天差地别。项目已在game.core.board.BoardConfig.java中定义了三档配置:
public enum BoardSize { SMALL(9, 9, 10), // 9x9, 10雷 MEDIUM(16, 16, 40), // 16x16, 40雷 LARGE(16, 30, 99); // 16x30, 99雷 }我们利用这个枚举,为每档尺寸赋予难度系数(基于人工实测平均通关时间归一化):
| 尺寸 | 平均通关时间(秒) | 难度系数 |
|---|---|---|
| SMALL | 25s | 1.0 |
| MEDIUM | 85s | 3.4 |
| LARGE | 220s | 8.8 |
改造LeaderboardEntry类,增加计算字段:
// game.leaderboard.model.LeaderboardEntry.java public class LeaderboardEntry { // ... 原有字段 private String boardSize; // 如 "9x9" // 新增:难度加权时间(毫秒) public long getWeightedTimeMs() { double weight = switch (boardSize) { case "9x9" -> 1.0; case "16x16" -> 3.4; case "16x30" -> 8.8; default -> 1.0; }; return Math.round(elapsedMs * weight); } }然后修改LeaderboardManager.getTopEntries()的排序逻辑:
// 原排序(按原始时间) return entries.stream() .filter(LeaderboardEntry::isWin) .sorted(Comparator.comparingLong(LeaderboardEntry::getElapsedMs)) .limit(limit) .collect(Collectors.toList()); // 改为按加权时间排序 return entries.stream() .filter(LeaderboardEntry::isWin) .sorted(Comparator.comparingLong(LeaderboardEntry::getWeightedTimeMs)) .limit(limit) .collect(Collectors.toList());提示:系数值不是魔法数字,而是根据
BoardSize枚举的getAverageSolveTimeSec()方法动态计算(你可以在枚举里加这个方法,返回预设均值),这样未来新增尺寸只需改枚举,无需动排序逻辑。
5.2 Excel 导出:用 Apache POI 实现零依赖兼容
项目不引入新依赖,但 Excel 导出必须用 POI。我们采用“最小侵入”方案:只在需要时动态加载,不修改构建脚本。
第一步:下载poi-5.2.4.jar(JDK 8 兼容版),放项目根目录;
第二步:在game.ui.menu.ExportMenu.java中添加导出按钮逻辑:
// game.ui.menu.ExportMenu.java private void exportToExcel() { try { // 动态加载 POI 类(避免启动时强依赖) Class<?> workbookClass = Class.forName("org.apache.poi.ss.usermodel.Workbook"); Class<?> xssfClass = Class.forName("org.apache.poi.xssf.usermodel.XSSFWorkbook"); Object workbook = xssfClass.getDeclaredConstructor().newInstance(); Object sheet = workbookClass.getMethod("createSheet").invoke(workbook); // 写表头 Object row0 = sheet.getClass().getMethod("createRow", int.class).invoke(sheet, 0); String[] headers = {"排名", "玩家", "耗时(秒)", "难度系数", "加权时间", "尺寸", "胜率"}; for (int i = 0; i < headers.length; i++) { Object cell = row0.getClass() .getMethod("createCell", int.class) .invoke(row0, i); cell.getClass().getMethod("setCellValue", String.class).invoke(cell, headers[i]); } // 写数据(此处简化,实际遍历 leaderboard.entries) List<LeaderboardEntry> top10 = leaderboardManager.getTopEntries(10); for (int i = 0; i < top10.size(); i++) { Object row = sheet.getClass().getMethod("createRow", int.class).invoke(sheet, i + 1); LeaderboardEntry e = top10.get(i); String[] data = { String.valueOf(i + 1), e.getUsername(), String.format("%.2f", e.getElapsedMs() / 1000.0), getDifficultyWeight(e.getBoardSize()), String.format("%.0f", e.getWeightedTimeMs() / 1000.0), e.getBoardSize(), e.isWin() ? "100%" : "0%" }; for (int j = 0; j < data.length; j++) { Object cell = row.getClass() .getMethod("createCell", int.class) .invoke(row, j); cell.getClass().getMethod("setCellValue", String.class).invoke(cell, data[j]); } } // 保存文件 JFileChooser fc = new JFileChooser(); fc.setDialogTitle("导出排行榜为 Excel"); fc.setSelectedFile(new File("minesweeper-rankings.xlsx")); if (fc.showSaveDialog(null) == JFileChooser.APPROVE_OPTION) { FileOutputStream out = new FileOutputStream(fc.getSelectedFile()); workbook.getClass().getMethod("write", OutputStream.class).invoke(workbook, out); out.close(); JOptionPane.showMessageDialog(null, "导出成功!", "完成", JOptionPane.INFORMATION_MESSAGE); } } catch (Exception e) { JOptionPane.showMessageDialog(null, "导出失败:缺少 poi-5.2.4.jar\n请将其放入程序同目录", "错误", JOptionPane.ERROR_MESSAGE); } }关键点:用Class.forName()动态加载 POI,失败时友好提示缺失 jar,而不是启动报NoClassDefFoundError。这样主程序仍可无依赖运行,Excel 功能按需启用。
5.3 我的开发习惯:每次改完必做的三件事
从那以后我每次给这类轻量 Java GUI 项目加功能,都强制走一遍这三步验证:
- 冷启动测试:关掉所有 Java 进程,双击 jar,从登录→游戏→胜利→看榜→导出,全程不碰 IDE;
- 边界压测:用户名输 13 个字符、时间故意拖到 10 分钟以上、连点 100 次插旗,看是否
NullPointerException或内存溢出; - 日志快照:在
LeaderboardManager.save()前加一行System.out.println("[DEBUG] Saving " + entries.size() + " entries");,启动时加-Dfile.encoding=UTF-8参数,确保中文路径和内容不乱码。
这三步做完,基本能覆盖 95% 的学生提交场景。希望帮到你。
本文还有配套的精品资源,点击获取