Java扫雷英雄榜项目实战:从核心逻辑到成绩持久化与二次开发
发布时间:2026/11/2 5:36:25 锦皓数字建站

简介这是一份面向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这点后面避坑章节会细说。先验证 JDKjava -version javac -version两条命令输出的版本要一致不一致说明机器上装了多个 JDKJAVA_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.MainWindows 下-cp的分隔符是分号find也要换成对应的写法或者干脆用 IDE 打开项目直接运行主类省去命令行拼参数的麻烦。首次运行如果窗口能正常弹出、网格能点、能插旗说明环境和构建都没问题。3.3 英雄榜数据的初始化与验证英雄榜依赖一个持久化文件常见做法是项目自带一个初始的heroboard.dat或者首次运行时由程序自动创建空榜单。跑起来之后先完整玩一局简单难度通关后看是否弹出输入昵称的对话框确认后打开英雄榜检查刚才的成绩是否出现在列表里。再关掉程序重新启动看成绩是否还在这一步验证的是持久化有没有真正落盘。如果重启后成绩丢了多半是写文件时路径用了相对路径而工作目录变了或者写流没关闭导致内容没刷出去排查时先看HeroBoard里保存方法的文件路径和流关闭逻辑。4. 英雄榜模块成绩记录、排序与持久化4.1 成绩模型与排序规则英雄榜的核心是一条成绩记录该存什么。至少要有玩家昵称、难度等级、用时秒数、完成时间戳。排序规则一般是先按难度分组同难度内按用时升序用时相同再按完成时间先后。ScoreRecord实现Comparable接口把比较逻辑收在模型里public class ScoreRecord implements ComparableScoreRecord { 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(ListScoreRecord records, Path file) throws IOException { ListString 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 ListScoreRecord load(Path file) throws IOException { ListScoreRecord 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 条 MapString, Integer countMap new HashMap(); ListScoreRecord 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.headlessfalse。如果确实要在无桌面环境跑那这个项目本身就不适合得改成纯逻辑测试或者换 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包的逻辑单独抽出来写几个单元测试布雷数量对不对、首点是否安全、连锁展开后已翻开数量是否符合预期、榜单排序是否稳定。桌面项目很少有人写测试但恰恰是这些逻辑最容易在二次开发时被改坏有了测试再动刀心里有底。从那以后我每次改这类带持久化的桌面项目都强制先把读写和排序的测试跑一遍再动界面省下来的排查时间远比写测试的时间多。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。