简介:这是一份基于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) { List<Integer> 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 之间,太高推理会失效,太低又缺乏挑战:
| 难度 | 棋盘 | 雷数 | 非雷格数 |
|---|---|---|---|
| 初级 | 9x9 | 10 | 71 |
| 中级 | 16x16 | 40 | 216 |
| 高级 | 30x16 | 99 | 381 |
英雄榜必须按难度分组,否则中级玩家永远被高级玩家压在底下,榜单失去参考意义。我的做法是 Map<Integer, 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 做主 key,finishTime 做次 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 Map<Integer, List<HeroRecord>> boardByLevel = new HashMap<>(); public synchronized void addRecord(HeroRecord record) { boardByLevel.computeIfAbsent(record.difficulty, k -> new ArrayList<>()) .add(record); List<HeroRecord> 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 TypeToken<Map<Integer, List<HeroRecord>>>(){}.getType(); Map<Integer, List<HeroRecord>> 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 数据。
List<MoveRecord> 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 逐步跑一遍,比空口争论快得多。
做这个项目最大的教训有两个:任何持久化数据,先想清楚「重启之后从哪读」,再动手写读写代码;任何计时逻辑,只存时间戳,绝不存累计秒数。这两条习惯后来帮我少踩了很多类似项目的坑。希望帮到你。
本文还有配套的精品资源,点击获取