☰
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 实现扫雷核心逻辑,配合图形界面完成交互,再叠加一个英雄榜模块,把玩家昵称、通关用时、难度等级这些信息记录下来并排序展示。适合两类人:一类是想找一个结构完整、能直接跑起来的 Java 桌面小项目练手的开发者;另一类是想在现有扫雷玩法上做二次开发,比如加计时赛、加排行榜规则、换皮肤或者改难度参数的人。整个项目的价值不在于算法多高深,而在于它把「游戏逻辑 + 界面交互 + 数据持久化」这条链路走通了,拿过来就能改。

2. 项目结构拆解:从入口类到英雄榜数据流

2.1 目录组织与模块职责

这个项目按常见的 Java 桌面应用习惯来组织,源码放在src下,按包名分层。核心包一般会拆成这么几块:游戏逻辑包负责雷区生成、翻格、插旗、胜负判定;界面包负责主窗口、按钮网格、状态栏和弹窗;数据包负责英雄榜的读写和排序;入口类负责组装窗口和启动游戏。资源目录里放图标、字体和英雄榜的初始数据文件。下面是一个典型的目录结构,实际拿到源码后可以对照着看:

MineSweeperHero/ ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/example/mine/ │ │ │ ├── Main.java // 程序入口 │ │ │ ├── core/ │ │ │ │ ├── Board.java // 雷区与格子状态 │ │ │ │ ├── Cell.java // 单个格子模型 │ │ │ │ └── GameEngine.java // 翻格、插旗、胜负判定 │ │ │ ├── ui/ │ │ │ │ ├── MainFrame.java // 主窗口 │ │ │ │ ├── BoardPanel.java // 网格绘制与鼠标事件 │ │ │ │ └── HeroDialog.java // 英雄榜展示弹窗 │ │ │ └── data/ │ │ │ ├── ScoreRecord.java // 单条成绩 │ │ │ └── HeroBoard.java // 榜单读写与排序 │ │ └── resources/ │ │ ├── icons/ // 雷、旗、数字图标 │ │ └── heroboard.dat // 英雄榜持久化文件 └── pom.xml 或 build.gradle // 构建配置

这个分层的好处是职责清晰:core包不依赖任何界面类,可以单独写单元测试;ui包只负责把Board的状态画出来并把鼠标事件转成GameEngine的调用;data包只管成绩的序列化和排序。二次开发时想换界面,动ui就行;想改计分规则,动data和GameEngine的结算部分就行,互不牵连。

2.2 雷区模型与格子状态设计

扫雷的核心是每个格子的状态机。一个格子至少有这几种状态:未翻开、已翻开、已插旗,另外还要记录它是不是雷、周围雷数是多少。常见做法是用一个Cell类把这些属性收在一起,再用二维数组在Board里组织:

public class Cell { private boolean mine; // 是否是雷 private boolean revealed; // 是否已翻开 private boolean flagged; // 是否已插旗 private int adjacentMines; // 周围八格中的雷数 public boolean isMine() { return mine; } public void setMine(boolean mine) { this.mine = mine; } public boolean isRevealed() { return revealed; } public void setRevealed(boolean revealed) { this.revealed = revealed; } public boolean isFlagged() { return flagged; } public void setFlagged(boolean flagged) { this.flagged = flagged; } public int getAdjacentMines() { return adjacentMines; } public void setAdjacentMines(int n) { this.adjacentMines = n; } }

mine决定这格是不是雷,revealed和flagged是玩家操作后的可见状态,adjacentMines在布雷完成后统一计算一次,之后翻格时直接读,不用每次重新数。这里有个容易忽略的点:插旗和翻开是互斥的,已翻开的格子不能再插旗,已插旗的格子点开时应该先取消旗子而不是直接翻。这些规则放在GameEngine里判断,Cell只做数据载体,这样逻辑不会散落到界面代码里。

2.3 布雷与首点保护逻辑

布雷一般放在游戏初始化阶段,按难度决定行列数和雷数。合格的做法是先把所有格子清空,再随机选位置放雷,最后遍历每个非雷格计算adjacentMines。首点保护是扫雷体验的关键:玩家第一次点击不应该直接踩雷,常见做法是首点之后再布雷,或者布雷时把首点及其周围排除在雷区之外。下面这段是首点后布雷的写法:

public void placeMines(int firstRow, int firstCol, int mineCount) { Random random = new Random(); int placed = 0; while (placed < mineCount) { int r = random.nextInt(rows); int c = random.nextInt(cols); // 首点及其周围八格不布雷,保证第一下安全 if (Math.abs(r - firstRow) <= 1 && Math.abs(c - firstCol) <= 1) { continue; } Cell cell = cells[r][c]; if (!cell.isMine()) { cell.setMine(true); placed++; } } // 布雷完成后统一计算周围雷数 for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (!cells[r][c].isMine()) { cells[r][c].setAdjacentMines(countAdjacentMines(r, c)); } } } }

firstRow和firstCol是玩家第一次点击的坐标,mineCount由难度决定。Math.abs那两行是首点保护的范围,实际项目里也可以只保护首点本身,看你想给玩家多大容错。countAdjacentMines遍历八邻域,注意边界判断,别越界。布雷用while加随机重试在雷数远小于格子总数时没问题,但如果做超大棋盘,最好换成洗牌算法,避免反复碰撞拖慢初始化。

2.4 翻格与连锁展开的实现

翻开一个格子后,如果它周围雷数为零,应该自动把周围八格也翻开,形成连锁展开,这是扫雷手感的核心。递归写法最直观,但要注意防止重复翻开和栈过深。常见做法是用一个队列做广度优先展开:

public void reveal(int row, int col) { Cell cell = cells[row][col]; if (cell.isRevealed() || cell.isFlagged()) { return; } cell.setRevealed(true); if (cell.isMine()) { gameOver(false); // 踩雷,游戏结束 return; } if (cell.getAdjacentMines() == 0) { // 周围无雷,连锁展开八邻域 for (int dr = -1; dr <= 1; dr++) { for (int dc = -1; dc <= 1; dc++) { int nr = row + dr; int nc = col + dc; if (inBounds(nr, nc) && !(dr == 0 && dc == 0)) { reveal(nr, nc); } } } } checkWin(); // 每次翻开后检查是否已清完非雷格 }

reveal先判断已翻开或已插旗就直接返回,避免重复处理。踩雷时调用gameOver(false)结束游戏。adjacentMines == 0时递归展开八邻域,inBounds做边界检查。递归深度在标准难度下完全够用,但如果有人把棋盘改得很大,建议改成显式栈或队列,防止栈溢出。checkWin放在每次翻开后调用,判断已翻开的非雷格数量是否等于总格数减雷数。

3. 跑起来:环境准备、构建与启动验证

3.1 环境与依赖确认

这个项目是纯 Java 桌面应用,不依赖数据库和外部服务,跑起来的门槛很低。需要确认三件事:JDK 版本、构建工具、图形环境。JDK 建议用 8 或 11 以上,源码里如果用了var或者新 API 就得往上调。构建工具看根目录是pom.xml还是build.gradle,有哪个用哪个。图形环境方面,它用的是 Swing 或 JavaFX 这类桌面库,在带桌面的系统上直接跑没问题,在纯命令行环境里会报HeadlessException,这点后面避坑章节会细说。先验证 JDK:

java -version javac -version

两条命令输出的版本要一致,不一致说明机器上装了多个 JDK,JAVA_HOME指到了其中一个而PATH里是另一个,这种环境问题在 Windows 上特别常见,先理顺再往下走。

3.2 构建与启动命令

如果是 Maven 项目,在根目录执行打包,然后运行主类。主类名以Main.java里的包声明为准,比如com.example.mine.Main:

# Maven 项目打包,跳过测试加快首次构建 mvn clean package -DskipTests # 运行打出来的 jar,注意替换成实际文件名 java -jar target/mine-sweeper-hero-1.0.jar

如果是 Gradle 项目,对应命令是./gradlew build然后java -jar build/libs/xxx.jar。没有构建工具、只有源码目录的情况,直接编译再运行:

# 编译 src/main/java 下所有源码到 out 目录 javac -encoding UTF-8 -d out $(find src/main/java -name "*.java") # 运行主类,资源目录一起带上 java -cp out:src/main/resources com.example.mine.Main

Windows 下-cp的分隔符是分号,find也要换成对应的写法,或者干脆用 IDE 打开项目直接运行主类,省去命令行拼参数的麻烦。首次运行如果窗口能正常弹出、网格能点、能插旗,说明环境和构建都没问题。

3.3 英雄榜数据的初始化与验证

英雄榜依赖一个持久化文件,常见做法是项目自带一个初始的heroboard.dat,或者首次运行时由程序自动创建空榜单。跑起来之后先完整玩一局简单难度,通关后看是否弹出输入昵称的对话框,确认后打开英雄榜,检查刚才的成绩是否出现在列表里。再关掉程序重新启动,看成绩是否还在,这一步验证的是持久化有没有真正落盘。如果重启后成绩丢了,多半是写文件时路径用了相对路径而工作目录变了,或者写流没关闭导致内容没刷出去,排查时先看HeroBoard里保存方法的文件路径和流关闭逻辑。

4. 英雄榜模块:成绩记录、排序与持久化

4.1 成绩模型与排序规则

英雄榜的核心是一条成绩记录该存什么。至少要有玩家昵称、难度等级、用时秒数、完成时间戳。排序规则一般是先按难度分组,同难度内按用时升序,用时相同再按完成时间先后。ScoreRecord实现Comparable接口,把比较逻辑收在模型里:

public class ScoreRecord implements Comparable<ScoreRecord> { private String playerName; private String difficulty; // EASY / MEDIUM / HARD private int seconds; // 通关用时 private long finishedAt; // 完成时间戳 @Override public int compareTo(ScoreRecord other) { // 先按难度分组,再按用时升序,最后按完成时间 int byDifficulty = this.difficulty.compareTo(other.difficulty); if (byDifficulty != 0) return byDifficulty; int byTime = Integer.compare(this.seconds, other.seconds); if (byTime != 0) return byTime; return Long.compare(this.finishedAt, other.finishedAt); } // getter / setter 省略 }

把比较逻辑放在compareTo里,榜单展示时直接Collections.sort(list)就行,不用在每个界面里重复写排序条件。难度用字符串枚举值而不是中文显示名,是为了排序稳定和持久化时不依赖界面语言。如果以后要加「最少步数」这类维度,在这里扩展比较字段即可,改动集中在一处。

4.2 榜单读写与文件格式

持久化用对象序列化或者简单的文本格式都行。文本格式的好处是出问题时能直接打开看,坏处是要自己处理转义。下面用每行一条记录的文本格式做示例,字段之间用制表符分隔:

public void save(List<ScoreRecord> records, Path file) throws IOException { List<String> lines = new ArrayList<>(); for (ScoreRecord r : records) { // 字段用 \t 分隔,昵称里的制表符替换掉防止串列 String name = r.getPlayerName().replace("\t", " "); lines.add(name + "\t" + r.getDifficulty() + "\t" + r.getSeconds() + "\t" + r.getFinishedAt()); } Files.write(file, lines, StandardCharsets.UTF_8); } public List<ScoreRecord> load(Path file) throws IOException { List<ScoreRecord> records = new ArrayList<>(); if (!Files.exists(file)) return records; // 首次运行没有文件,返回空榜 for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { String[] parts = line.split("\t"); if (parts.length < 4) continue; // 跳过损坏行,别让一行脏数据拖垮整个榜单 records.add(new ScoreRecord(parts[0], parts[1], Integer.parseInt(parts[2]), Long.parseLong(parts[3]))); } return records; }

save里把昵称中的制表符替换掉,是为了防止用户输入带分隔符导致读回来字段错位,这是文本格式最容易翻车的地方。load里对不存在文件和损坏行都做了兜底,首次运行没有榜单文件是正常情况,不应该抛异常。Files.write和readAllLines都显式指定 UTF-8,避免不同系统默认编码不一致导致中文昵称乱码。

4.3 榜单展示与截断策略

英雄榜不可能无限增长,常见做法是每个难度只保留前 N 名,比如前 10 或前 20。写入新成绩后排序、截断、再保存,展示时按难度分页或分栏。截断逻辑放在HeroBoard的提交方法里:

public void submit(ScoreRecord record, int keepPerDifficulty) { records.add(record); Collections.sort(records); // 按难度分组,每组只保留前 keepPerDifficulty 条 Map<String, Integer> countMap = new HashMap<>(); List<ScoreRecord> kept = new ArrayList<>(); for (ScoreRecord r : records) { int count = countMap.getOrDefault(r.getDifficulty(), 0); if (count < keepPerDifficulty) { kept.add(r); countMap.put(r.getDifficulty(), count + 1); } } records = kept; saveToFile(); }

keepPerDifficulty由调用方传入,方便以后做成可配置项。先整体排序再按难度计数保留,保证每个难度组内都是用时最短的那批。注意这里依赖compareTo里难度字段的排序顺序,如果难度字符串的字典序和你想展示的顺序不一致,展示层再单独排一次显示顺序,别把展示顺序和成绩排序混在一起。

5. 避坑与排查:跑不起来、点不动、榜单丢数据

5.1 启动报 HeadlessException

现象是双击运行或命令行启动后直接抛java.awt.HeadlessException,窗口根本不出现。原因是当前 JVM 被判定为无图形环境,常见于服务器、容器或者某些远程会话里。解决办法是确认在带桌面的系统上运行,或者启动时显式指定-Djava.awt.headless=false。如果确实要在无桌面环境跑,那这个项目本身就不适合,得改成纯逻辑测试或者换 Web 界面,别在这上面硬耗。

5.2 中文昵称变乱码

现象是英雄榜里中文昵称显示成问号或方块。原因通常是读写文件时用了平台默认编码,Windows 默认 GBK,而文件实际是 UTF-8,一来一回就错了。解决是在所有Files.readAllLines、Files.write、new String(bytes)的地方显式传StandardCharsets.UTF_8,编译时也加-encoding UTF-8。已经写坏的文件没法自动恢复,删掉重新生成榜单即可。

5.3 首次点击就踩雷

现象是开局第一下点下去直接爆炸,体验极差。原因是布雷在首点之前就完成了,没有做首点保护。解决是把布雷推迟到第一次点击时执行,或者布雷时排除首点及其周围。改的时候注意GameEngine里要有一个「是否已布雷」的标志,避免每次点击都重新布雷,这个标志漏了会导致雷区每点一次变一次,属于改首点保护时最容易带出来的新问题。

5.4 连锁展开卡死或栈溢出

现象是点开一片空白区域时界面卡住,或者大棋盘下抛StackOverflowError。原因是递归展开没有正确去重,或者棋盘被改得过大导致递归深度超限。解决是先确认reveal开头有「已翻开直接返回」的判断,再去检查八邻域循环有没有把自身排除。如果确实要支持超大棋盘,把递归改成显式队列的广度优先展开,逻辑不变但不受栈深度限制。

5.5 重启后榜单成绩消失

现象是玩完一局成绩在列表里,关掉程序再开就没了。原因通常是保存方法没被调用、写流没关闭,或者文件路径用了相对路径而两次启动的工作目录不同。排查顺序是:先在save方法里打日志确认有没有执行,再看文件是否真的被创建和修改,最后把路径改成基于用户目录的绝对路径,比如System.getProperty("user.home")下拼一个固定目录,避免工作目录变化带来的玄学问题。

6. 二次开发:把英雄榜改成可配置的赛季制

拿到这个项目后,最值得动手改的地方是英雄榜的规则,因为它同时牵扯数据模型、排序、持久化和界面展示,改一遍能把整条链路摸熟。我一般会先做一个最小改动:把「每个难度保留前 N 名」改成「按赛季保留」,每个赛季一个独立的榜单文件,赛季编号写在文件名里,比如heroboard_s1.dat。这样旧赛季成绩天然归档,新赛季从空榜开始,不用在数据里加赛季字段也能跑通。

具体做法是给HeroBoard加一个构造参数接收赛季号,内部把文件路径拼成heroboard_s{season}.dat,读写逻辑完全复用。界面上加一个下拉框切换赛季,切换时重新load对应文件并刷新列表。验证方法是:在赛季 1 玩一局,切到赛季 2 确认榜单为空,再玩一局,切回赛季 1 确认原来的成绩还在。这个验证过程能同时暴露路径拼接错误和缓存没清空两类问题。

再往深一层,可以把难度参数也外置成配置文件,行列数和雷数从文件读,而不是写死在代码里。改的时候注意Board初始化、placeMines的雷数校验、以及界面网格尺寸计算这三处要同步,漏一处就会出现「逻辑上 10x10 但界面只画了 9 列」这种对不上的情况。下面是一个配置读取的骨架:

// 从 config.properties 读取难度参数,缺省时回退到内置值 Properties props = new Properties(); try (InputStream in = Files.newInputStream(Paths.get("config.properties"))) { props.load(in); } catch (IOException e) { // 配置文件不存在就用默认难度,不让程序起不来 } int rows = Integer.parseInt(props.getProperty("easy.rows", "9")); int cols = Integer.parseInt(props.getProperty("easy.cols", "9")); int mines = Integer.parseInt(props.getProperty("easy.mines", "10"));

getProperty的第二个参数是缺省值,保证配置文件缺失或字段不全时还能跑。mines要做上限校验,不能超过rows * cols - 9,否则首点保护区域之外放不下这么多雷,布雷循环会死循环,这个坑我在类似项目里踩过,表现是点开始后界面直接卡住,查了半天才发现是雷数配大了。

改完这些,建议把core包的逻辑单独抽出来写几个单元测试:布雷数量对不对、首点是否安全、连锁展开后已翻开数量是否符合预期、榜单排序是否稳定。桌面项目很少有人写测试,但恰恰是这些逻辑最容易在二次开发时被改坏,有了测试再动刀心里有底。从那以后我每次改这类带持久化的桌面项目,都强制先把读写和排序的测试跑一遍再动界面,省下来的排查时间远比写测试的时间多。希望帮到你。

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

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

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

立即咨询