☰
Java扫雷英雄榜项目实战:从核心算法到本地排行榜持久化
2026/11/2 5:36:24 网站建设 项目流程

简介:这是一份面向Java初学者与课程设计学习者的扫雷游戏完整源码包,围绕经典扫雷玩法实现了雷区生成、方块视图、游戏主控与成绩记录等模块,并额外加入英雄榜与等级设定,适合用来练习Java面向对象编程、Swing界面绘制与基础算法逻辑。压缩包共19个文件,约23KB,以7个java源文件和对应的7个class编译文件为主体,另含2个gif图片素材、1个txt英雄榜数据文件以及project、classpath等工程配置,源码与可运行产物齐全,便于直接导入IDE阅读调试。目前已有674人学习下载。读者可从中获得一套结构清晰的扫雷实现参考,理解雷区布局、方块状态刷新、成绩排序与英雄榜持久化等具体做法,并借助等级机制体会难度分层的设计思路,适合作为课程作业或小型练手项目的对照范本。

1. 扫雷英雄榜:一个把“单机小游戏”做成可存档竞技场的 Java 桌面项目

很多人对扫雷的印象还停留在系统自带那个灰色小方块,点开就玩、关掉就忘。但真正做过扫雷的人都知道,难点从来不在画格子,而在三件事:雷区生成要保证首点必空、递归展开不能栈溢出、计时和成绩要能落盘。这份 Java 扫雷英雄榜资源,恰好把这三件事串成了一个完整闭环——它不只是能玩的扫雷,还带一套本地英雄榜,把每局用时、难度、玩家名记下来排序。适合谁?适合刚学完 Java 基础、想找一个“有界面、有状态、有持久化”的练手项目的人,也适合带课程设计、想拿一份能跑能改的桌面程序骨架的从业者。下面我按自己拆包复现的顺序,把结构、跑法、参数和坑一次讲透。

2. 拆开源码包:从入口类到英雄榜存储的四个关键层

拿到一个 Java 桌面项目,我一般不会急着点运行,而是先看目录和入口。这份资源的组织方式比较典型:界面层、游戏逻辑层、数据持久层分得不算严格,但边界清晰,改起来不费劲。先把整体结构摸清,后面调参数、加功能才不会牵一发动全身。

2.1 入口类与启动链路:main 方法之后发生了什么

入口类通常叫MineSweeperApp或类似名字,里面一个main方法,做两件事:初始化主窗口、加载英雄榜数据。启动链路大致是main → 主窗口构造 → 棋盘面板初始化 → 读取成绩文件。这里有个容易被忽略的点:成绩文件读取如果放在界面构造里同步做,文件不存在时会抛异常导致白屏。合格的做法是读取失败就返回空榜,而不是让程序起不来。

public class MineSweeperApp { public static void main(String[] args) { // 统一在事件调度线程里创建 Swing 界面,避免线程安全问题 SwingUtilities.invokeLater(() -> { ScoreBoard board = ScoreBoard.loadOrDefault("scores.dat"); // 读不到就给空榜 MainFrame frame = new MainFrame(board); frame.setVisible(true); }); } }

这段代码的逻辑说明:invokeLater保证所有 Swing 组件在 EDT(事件调度线程)上创建,这是 Swing 的硬性要求,很多新手直接在main里new JFrame(),跑起来偶尔卡死就是这个原因。loadOrDefault是防御式设计,文件缺失、损坏都退回空榜。参数上,scores.dat是相对路径,实际运行目录取决于你怎么启动,这一点后面避坑章节会专门说。

2.2 棋盘模型:二维数组、雷数与首点保护

棋盘模型是整个项目的核心,一般是一个int[][]或Cell[][]。用int[][]存状态更省事:-1 表示雷,0 到 8 表示周围雷数,另配一个boolean[][] revealed记录是否翻开、一个boolean[][] flagged记录插旗。雷的布置不是开局就定死,而是等玩家第一次点击后再生成,并且要保证首点及其周围八格无雷——这就是“首点必空”的实现基础。

public void placeMines(int safeRow, int safeCol) { Random rand = new Random(); int placed = 0; while (placed < mineCount) { int r = rand.nextInt(rows); int c = rand.nextInt(cols); // 首点周围 3x3 区域全部跳过,保证第一下必是空白展开 if (Math.abs(r - safeRow) <= 1 && Math.abs(c - safeCol) <= 1) continue; if (grid[r][c] == -1) continue; // 已有雷则重抽 grid[r][c] = -1; placed++; } computeNumbers(); // 遍历非雷格,统计周围 8 格雷数 }

逻辑说明:safeRow/safeCol是玩家第一次点击的坐标,循环里用曼哈顿式的行列差判断是否落在安全区。computeNumbers是第二遍遍历,对每个非雷格数它周围八格的雷。参数上,mineCount由难度决定,初级 9x9 配 10 雷、中级 16x16 配 40 雷、高级 16x30 配 99 雷是经典配置,资源里一般也是这套。这里有个性能细节:while重抽法在雷密度很高时(比如高级 99 雷铺 480 格)会多抽几次,但完全够用,不必上洗牌算法,除非你要做自定义超高密度。

2.3 递归展开:为什么必须用显式栈而不是裸递归

点开一个 0 格,要自动把相连的空白区全翻开,这是扫雷最爽的瞬间,也是最容易翻车的地方。裸递归写法在高级难度下可能展开几百格,递归深度逼近栈上限,轻则卡顿重则StackOverflowError。稳妥做法是用显式栈或队列做广度优先展开。

public void reveal(int startRow, int startCol) { Deque<int[]> stack = new ArrayDeque<>(); stack.push(new int[]{startRow, startCol}); while (!stack.isEmpty()) { int[] cur = stack.pop(); int r = cur[0], c = cur[1]; if (revealed[r][c] || flagged[r][c]) continue; revealed[r][c] = true; if (grid[r][c] == 0) { // 只有空白格才继续扩散 for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { int nr = r + dr, nc = c + dc; if (inBounds(nr, nc) && !revealed[nr][nc]) { stack.push(new int[]{nr, nc}); } } } } } }

逻辑说明:栈里存待处理坐标,弹出后先判重再翻开;只有当前格是 0 才把邻居压栈,数字格翻开即止,这正是扫雷的展开规则。参数上inBounds做边界裁剪,别省这一步,越界异常是这类项目最高频的崩溃来源。用ArrayDeque而不是Stack类,是因为前者性能更好且官方推荐。

2.4 英雄榜持久化:对象序列化还是文本格式

英雄榜要跨进程存活,就得落盘。资源里常见两种做法:Java 对象序列化(ObjectOutputStream写.dat)或纯文本(每行玩家名,难度,秒数,时间戳)。序列化写起来最省事,但版本兼容差——你改了Score类的字段,旧文件反序列化直接报InvalidClassException。文本格式啰嗦一点,但可读、可手改、可迁移,我一般倾向后者。

public static List<Score> load(String path) { List<Score> list = new ArrayList<>(); File f = new File(path); if (!f.exists()) return list; // 首次运行没有文件,返回空榜 try (BufferedReader br = new BufferedReader(new FileReader(f))) { String line; while ((line = br.readLine()) != null) { String[] p = line.split(","); if (p.length != 4) continue; // 脏行直接跳过,不让整榜崩掉 list.add(new Score(p[0], p[1], Integer.parseInt(p[2]), Long.parseLong(p[3]))); } } catch (IOException | NumberFormatException e) { // 读失败就当作空榜,保证游戏能继续玩 } return list; }

逻辑说明:逐行解析,字段数不对或数字解析失败就跳过该行,这是“坏数据不传染”的思路。参数上四个字段分别是玩家名、难度标识、用时秒数、完成时间戳,排序时按难度分组、组内按秒数升序。写入端对应BufferedWriter追加一行即可,注意写完flush,否则程序异常退出会丢成绩。

3. 跑起来并改得动:编译、难度参数与界面联调

结构看清了,接下来是让它跑起来、并且能按自己需要改。这一步的目标不是“能启动”就完事,而是你能改难度、改配色、改英雄榜规则,改完还不崩。下面按实际操作顺序走。

3.1 编译与运行:命令行和 IDE 两条路

如果资源里没有构建工具配置,最稳的是命令行编译。把所有.java按包结构放好,从源码根目录执行:

# 编译到 out 目录,-encoding 防止中文注释乱码 javac -encoding UTF-8 -d out $(find src -name "*.java") # 运行,注意 scores.dat 会生成在当前工作目录 java -cp out com.example.minesweeper.MineSweeperApp

逻辑说明:-d out指定输出目录,find递归收集源文件,适合没有 Maven/Gradle 的小项目。-encoding UTF-8很关键,源码里有中文玩家名或注释时,不加这个参数在部分终端下编译报“不可映射字符”。运行时的-cp out是类路径,包名要和目录结构对上。如果你用 IDE,直接导入源码目录、标记src为源码根、指定主类即可,但要注意 IDE 的工作目录默认是项目根,scores.dat会生成在那里,和命令行运行的位置可能不一致——这就是后面要说的“成绩莫名消失”的根源之一。

3.2 难度参数怎么改:三组数字背后的平衡

难度本质是三个参数:行数、列数、雷数。改的时候不能只动雷数,雷密度(雷数/总格数)超过约 0.25 后,首点保护之外的区域会变得几乎无解,体验断崖式下跌。经典三档的密度大致是初级 0.12、中级 0.16、高级 0.21,这个区间是多年玩家验证过的甜点。

难度行 x 列雷数雷密度适合场景
初级9 x 9100.12新手熟悉规则
中级16 x 16400.16日常练习
高级16 x 30990.21冲榜挑战

改参数时,把这三组值抽成枚举或常量类,别散落在界面代码里。常见做法是定义一个Difficulty枚举,每个枚举项带rows/cols/mines三个字段,界面下拉框直接绑定枚举。这样加一档“自定义”也只是多一个构造入口,不用改棋盘逻辑。注意高级的 16x30 在低分辨率屏幕上会超出窗口,需要给棋盘面板套JScrollPane,否则底部几行点不到。

3.3 界面联调:重绘、计时器与鼠标事件的三方配合

Swing 扫雷的界面刷新靠repaint()触发paintComponent,格子绘制、数字颜色、旗帜图标都在这里。计时器用javax.swing.Timer,每 1000 毫秒加一秒,别用Thread.sleep在 EDT 里死等,那会把整个界面冻住。鼠标事件要区分左键翻开、右键插旗,用SwingUtilities.isLeftMouseButton判断,别用getButton()硬编码数字。

// 计时器:只在游戏进行中走,结束时 stop timer = new Timer(1000, e -> { elapsedSeconds++; timeLabel.setText("用时: " + elapsedSeconds + "s"); }); timer.start(); // 鼠标:左键翻开,右键插旗,中键(或双键)快速展开 board.addMouseListener(new MouseAdapter() { @Override public void mousePressed(MouseEvent e) { int r = e.getY() / CELL_SIZE; int c = e.getX() / CELL_SIZE; if (SwingUtilities.isLeftMouseButton(e)) game.reveal(r, c); else if (SwingUtilities.isRightMouseButton(e)) game.toggleFlag(r, c); board.repaint(); // 状态变了必须重绘 } });

逻辑说明:CELL_SIZE是单格像素,坐标换算成行列是界面与模型之间的唯一桥梁,换算错了就点哪哪不对。计时器在胜利或踩雷时stop(),否则会一直加。repaint()放在事件处理末尾,保证一次操作只重绘一次。参数上CELL_SIZE一般取 24 到 32 像素,太小点不准,太大高级难度铺不下。

4. 避坑与排查:五个让项目“看着能跑、实际难用”的细节

这类课程设计级项目,代码量不大,但坑都藏在边角。下面五条是我拆包和复现时真实撞过的,按“现象 → 原因 → 解决”记下来,你照着排能省不少时间。

4.1 成绩文件找不到,英雄榜永远是空的

现象:玩完一局,成绩没进榜,重启后榜还是空。原因:scores.dat用的是相对路径,命令行运行和 IDE 运行的工作目录不同,写入和读取落在了两个地方。解决:把成绩文件路径改成基于用户目录的绝对路径,比如System.getProperty("user.home") + "/.minesweeper/scores.dat",并在启动时确保父目录存在(mkdirs())。这样无论从哪启动,读写都是同一个文件。

4.2 首点就踩雷,玩家直接骂街

现象:第一下点下去就炸。原因:雷在棋盘初始化时就布好了,没等首点坐标。解决:把placeMines推迟到第一次左键点击时调用,并把点击坐标作为安全区中心传进去。注意如果玩家第一下点的是右键插旗,不能触发布雷,要判断是左键且尚未布雷才执行。

4.3 递归展开栈溢出,高级难度必崩

现象:高级难度点开大片空白时程序卡死或抛StackOverflowError。原因:用了裸递归reveal(r,c)自我调用。解决:换成第 2.3 节的显式栈写法。另外检查展开循环里有没有漏掉revealed判重,漏了会导致同一格反复入栈,栈瞬间膨胀。

4.4 右键插旗后左键还能翻开,规则形同虚设

现象:插了旗的格子照样能被左键点开。原因:reveal方法里只判了revealed,没判flagged。解决:在reveal入口加if (flagged[r][c]) return;。同时插旗数量要限制不超过总雷数,否则玩家可以把整盘插满旗来“作弊”探测,虽然不影响胜负判定,但体验很怪。

4.5 胜利判定漏了旗子,赢了却不结算

现象:所有非雷格都翻开了,程序没弹胜利。原因:胜利条件写成了“翻开格数 == 总格数 - 雷数”,但插旗不影响翻开数,逻辑本身没错;真正的问题是有些实现把“插旗数 == 雷数”也当成胜利条件之一,导致玩家不插旗就永远赢不了。解决:胜利判定只认“所有非雷格均已翻开”,插旗纯属辅助标记,不参与胜负。结算时再统一把剩余雷格自动插旗,视觉上收尾。

5. 进阶一招:把英雄榜做成可校验的本地排行榜

基础版英雄榜有个天然缺陷:文本文件谁都能手改,改个1秒就霸榜,这在多人共用一台机器的场景下很尴尬。想让榜单有点可信度,不必上数据库,加一个轻量校验就够用。思路是每条成绩写入时附带一个基于内容计算的校验码,读取时校验,不匹配的条目标记为“存疑”而不是直接丢弃——毕竟误伤正常成绩比放过一条假成绩更让人难受。

// 用内容拼接后取哈希,作为轻量校验码,防手改而非防攻击 static String checksum(String name, String diff, int sec, long ts) { String raw = name + "|" + diff + "|" + sec + "|" + ts + "|" + SALT; return Integer.toHexString(raw.hashCode()); } // 写入格式:玩家名,难度,秒数,时间戳,校验码 static boolean verify(String[] p) { if (p.length != 5) return false; int sec = Integer.parseInt(p[2]); long ts = Long.parseLong(p[3]); return checksum(p[0], p[1], sec, ts).equals(p[4]); }

逻辑说明:SALT是写死在代码里的一个常量字符串,作用是让手改者不能只改数字就蒙混过关,得同时知道拼接规则。hashCode不是密码学哈希,挡不住有心人,但足以过滤“随手改个数字”的情况,对课程设计场景完全够。读取时对verify返回 false 的条目,在界面上用灰色显示并标注“未校验”,而不是删掉,保留数据完整性。参数上时间戳用System.currentTimeMillis(),秒数用计时器累计值,两者一起参与校验,改任何一个都会失配。

这里还有个排序细节值得说:英雄榜按难度分组后,组内排序建议用“用时升序,用时相同则时间戳升序”,后者保证先达成的排前面,避免同秒成绩顺序随机跳动。用Comparator.comparingInt(Score::getSeconds).thenComparingLong(Score::getTimestamp)一行搞定,别手写冒泡。

从那以后我每次拆这类带本地榜单的小项目,都会先手动改一次成绩文件,看程序是崩、是静默接受还是标记存疑——这一步能立刻暴露持久化层的健壮性。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询