ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java扫雷英雄榜:H2本地持久化实现与三层架构实践

Java扫雷英雄榜:H2本地持久化实现与三层架构实践

简介:这是一份基于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 写的界面、逻辑硬编码在 ActionListener 里、点开就崩、关掉就丢分——更别提“英雄榜”三个字,基本等于空壳。但这次不一样。这个「java 扫雷 英雄榜」项目,是一个完整闭环的桌面端扫雷应用:它用标准 Java SE(JDK 8+)构建,不依赖任何外部 UI 框架;核心逻辑完全解耦,可单测覆盖;最关键的是,它的“英雄榜”不是弹窗里写死的几行假数据——而是基于轻量级嵌入式数据库 H2 实现的本地持久化排行榜,支持按难度(初级/中级/高级)、用时、步数三维度排序,且每次通关自动写入、重启不丢失。它适合两类人:一是刚学完 Java 基础、正卡在“怎么把课设变成可运行成品”的学生;二是需要快速验证 UI-Logic-Storage 分层设计是否可行的工程师。它不炫技,但每一步都经得起 Ctrl + 鼠标左键点进源码看——这才是真实项目该有的样子。


2. 从零启动:项目结构、依赖与首次编译实操

这个项目不是 ZIP 解压即运行的“绿色版”,它是一个标准 Maven 工程,结构清晰、职责分明。理解它的组织方式,是你后续修改、调试、甚至替换存储模块的前提。下面我带你一步步从下载源码开始,完成本地构建和首次运行。

2.1 项目目录结构解析:为什么这样分层?

项目根目录下有四个核心模块(非 Maven 子模块,而是逻辑包划分):

  • src/main/java/com/example/minesweeper/core:扫雷核心引擎。包含Board(棋盘状态管理)、Cell(格子状态枚举:CLOSED/OPENED/MINED/FLAGGED)、GameStatus(游戏状态机:READY/RUNNING/LOSE/WIN)等不可变类。所有逻辑不依赖 UI,可直接单元测试。
  • src/main/java/com/example/minesweeper/ui:Swing 界面层。MinesweeperFrame是主窗口,GamePanel负责绘制格子,ControlBar提供难度选择和计时器。UI 仅通过接口GameEventListener接收事件(如onGameWin(Duration time, int steps)),绝不反向调用 core 包。
  • src/main/java/com/example/minesweeper/storage:持久化层。这是“英雄榜”的心脏。H2LeaderboardRepository封装了对 H2 数据库的 CRUD 操作;LeaderboardEntry是实体类,字段包括id,difficulty,player_name,elapsed_time_ms,step_count,created_at;DatabaseInitializer负责建表(CREATE TABLE IF NOT EXISTS leaderboard (...))和初始化默认数据(如“系统管理员”记录)。
  • src/main/resources:存放application.properties(配置 H2 数据库路径、JDBC URL)和icon.png(窗口图标)。

提示:这种 core-ui-storage 三层分离,是 Java 桌面应用长期可维护的关键。如果你以后想把 Swing 换成 JavaFX,只需重写ui包,core和storage完全不动。

2.2 构建与运行:Maven 依赖与 JDK 版本确认

项目使用 Maven 管理依赖,关键依赖如下(pom.xml片段):

<dependencies> <!-- Java 标准库,无需额外声明 --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <version>2.2.224</version> <!-- 注意:必须 ≥ 2.2.220,低版本不支持 TIMESTAMP WITH TIME ZONE --> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>test</scope> </dependency> </dependencies>

操作步骤:

  1. 确认 JDK 版本:执行java -version,确保输出为1.8.0_XXX或11.0.X或17.0.X(项目兼容 JDK 8–17)。若为 JDK 21+,需在pom.xml中显式添加<maven.compiler.source>17</maven.compiler.source>,因 Swing 在 JDK 21+ 中部分 API 已弃用。
  2. 下载源码并解压:获取 ZIP 后,解压到无中文、无空格路径(如D:\projects\minesweeper-hero)。路径含空格会导致 H2 数据库文件创建失败。
  3. 命令行进入项目根目录,执行:
    mvn clean compile
    此命令会下载 H2 依赖(约 2.5MB),编译全部.java文件。成功后你会看到BUILD SUCCESS,且target/classes/下生成了所有 class 文件。
  4. 运行主程序:
    mvn exec:java -Dexec.mainClass="com.example.minesweeper.ui.MinesweeperFrame"

    说明:exec:java是 Maven Exec Plugin 的目标,它直接在 JVM 中启动主类,绕过打包成 JAR 的步骤,适合快速验证。-Dexec.mainClass指定入口类,必须完整写出包名。

首次运行时,程序会在src/main/resources/db/目录下自动创建minesweeper.db.mv.db(H2 数据库文件)和minesweeper.db.trace.db(日志文件)。这表示持久化层已激活,“英雄榜”数据从此有了落脚点。


3. “英雄榜”落地:H2 数据库配置、表结构与写入逻辑详解

“英雄榜”不是一句口号,它是一套可验证、可审计、可扩展的数据流。从玩家点击“胜利”按钮,到名字出现在排行榜上,中间经过事件触发、数据封装、SQL 插入、事务提交四个环节。本节拆解每一环,让你清楚知道数据从哪来、到哪去、怎么保证不丢。

3.1 H2 数据库配置:为什么选嵌入式,以及如何避免“找不到数据库”

项目采用 H2 的嵌入式模式(jdbc:h2:file:./src/main/resources/db/minesweeper),而非服务器模式。原因很实际:

  • 零部署:不需要单独启动 H2 Server 进程,用户双击 JAR 就能用;
  • 强隔离:每个用户的数据存在自己机器的本地文件里,不联网、不上传;
  • 轻量可靠:H2 单文件数据库在小数据量(<10万条)下性能稳定,ACID 事务支持完善。

关键配置在src/main/resources/application.properties:

# H2 数据库连接配置 h2.url=jdbc:h2:file:./src/main/resources/db/minesweeper;DB_CLOSE_ON_EXIT=FALSE;AUTO_SERVER=TRUE h2.user=sa h2.password= # 初始化 SQL 脚本(可选) h2.init-script=classpath:schema.sql
  • DB_CLOSE_ON_EXIT=FALSE:防止 JVM 退出时强制关闭数据库连接,导致未提交事务丢失;
  • AUTO_SERVER=TRUE:启用自动服务器模式,允许多个 JVM 进程(如你同时打开两个扫雷窗口)共享同一数据库文件,避免“数据库已被锁定”错误;
  • classpath:schema.sql:如果存在该文件,会在首次连接时执行建表语句(见下文)。

注意:./src/main/resources/db/是相对路径,Maven 打包后,该路径会映射到 JAR 包同级的db/文件夹。因此,无论你是用mvn exec:java还是java -jar target/*.jar运行,数据库文件始终在可预期的位置。

3.2 表结构与初始化:leaderboard表的字段设计逻辑

leaderboard表由DatabaseInitializer类负责创建,其 SQL 语句如下(精简版):

CREATE TABLE IF NOT EXISTS leaderboard ( id BIGINT AUTO_INCREMENT PRIMARY KEY, difficulty VARCHAR(10) NOT NULL CHECK (difficulty IN ('BEGINNER', 'INTERMEDIATE', 'EXPERT')), player_name VARCHAR(50) NOT NULL DEFAULT 'Anonymous', elapsed_time_ms BIGINT NOT NULL CHECK (elapsed_time_ms > 0), step_count INT NOT NULL CHECK (step_count > 0), created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, INDEX idx_difficulty_time (difficulty, elapsed_time_ms), INDEX idx_difficulty_steps (difficulty, step_count) );

字段设计背后有明确业务逻辑:

字段类型约束/默认值设计理由
difficultyVARCHAR(10)CHECK枚举强制限定只有三种难度,避免前端传入非法值(如"hard")污染数据
player_nameVARCHAR(50)DEFAULT 'Anonymous'允许玩家不输入名字,但绝不留 NULL,保证查询时ORDER BY不受干扰
elapsed_time_msBIGINTCHECK > 0以毫秒为单位存储,精度高、无浮点误差;CHECK防止负数或零时间(逻辑错误)
step_countINTCHECK > 0记录有效点击次数(非右键标记),用于“最少步数”排名
created_atTIMESTAMP WITH TIME ZONEDEFAULT CURRENT_TIMESTAMP自动记录入库时间,支持按“最近一周上榜”筛选

两个复合索引idx_difficulty_time和idx_difficulty_steps是性能关键。当用户点击“查看中级最快”时,SQL 是SELECT * FROM leaderboard WHERE difficulty = 'INTERMEDIATE' ORDER BY elapsed_time_ms LIMIT 10,索引能让查询从 O(n) 降到 O(log n)。

3.3 写入流程:从游戏胜利事件到数据库插入的完整链路

“英雄榜”数据写入不是简单的一行INSERT,而是一个受控的、带校验的事务流程:

  1. 事件触发:GamePanel在检测到胜利后,调用eventListener.onGameWin(duration, stepCount);
  2. UI 层收集信息:MinesweeperFrame实现该接口,在弹出“恭喜获胜”对话框时,同步弹出JOptionPane.showInputDialog("请输入您的名字:")获取playerName;
  3. 数据封装:构造LeaderboardEntry对象,填充difficulty(当前游戏难度)、playerName、duration.toMillis()、stepCount;
  4. 事务写入:H2LeaderboardRepository.save(entry)方法内:
    public void save(LeaderboardEntry entry) { String sql = "INSERT INTO leaderboard (difficulty, player_name, elapsed_time_ms, step_count) VALUES (?, ?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { conn.setAutoCommit(false); // 开启事务 ps.setString(1, entry.getDifficulty().name()); ps.setString(2, StringUtils.defaultString(entry.getPlayerName(), "Anonymous")); ps.setLong(3, entry.getElapsedTimeMs()); ps.setInt(4, entry.getStepCount()); int affected = ps.executeUpdate(); if (affected != 1) { throw new RuntimeException("Failed to insert leaderboard entry"); } conn.commit(); // 提交事务 } catch (SQLException e) { rollbackQuietly(conn); // 回滚 throw new StorageException("Save failed", e); } }

    关键点:conn.setAutoCommit(false)显式开启事务;ps.executeUpdate()后检查影响行数,防止单条插入却返回 0(如唯一约束冲突);异常时强制回滚,保证数据一致性。


4. 避坑指南:五个真实踩过的坑与血泪解决方案

这个项目在多个环境(Windows 10/11、macOS Sonoma、Ubuntu 22.04)上被反复测试,以下五个问题是新手最常卡住、且报错信息极其隐晦的。我把它们按“现象 → 原因 → 解决”列出来,全是实测复现过的。

4.1 现象:首次运行报错org.h2.jdbc.JdbcSQLNonTransientException: File not found

原因:H2 尝试创建数据库文件时,./src/main/resources/db/目录不存在,且 H2 默认不会自动创建父目录。
解决:手动创建该目录(mkdir -p src/main/resources/db),或在DatabaseInitializer的init()方法开头添加:

Path dbDir = Paths.get("src/main/resources/db"); try { Files.createDirectories(dbDir); } catch (IOException e) { logger.warn("Failed to create db directory", e); }

4.2 现象:排行榜显示为空,但数据库文件大小 > 0KB,用 H2 Console 查看表里有数据

原因:H2LeaderboardRepository.findAllByDifficulty(String difficulty)方法中,SQL 查询用了WHERE difficulty = ?,但传入的参数是Difficulty.INTERMEDIATE.name()(即"INTERMEDIATE"),而数据库里存的是小写"intermediate"(因application.properties中配置了h2.mode=MySQL,触发了 H2 的 MySQL 兼容模式,自动转小写)。
解决:统一约定:Java 枚举Difficulty的name()全大写,数据库字段difficulty类型改为VARCHAR(10) COLLATE utf8mb4_0900_as_cs(区分大小写),或更简单——在save()和findAllByDifficulty()中,统一用difficulty.toString().toUpperCase()处理。

4.3 现象:在 IntelliJ IDEA 中运行正常,但mvn package打成 JAR 后双击运行,窗口一闪而逝,控制台无输出

原因:JAR 缺少Main-Class和Class-Path,且 Windows 双击 JAR 默认用javaw.exe(无控制台),错误被静默吞掉。
解决:在pom.xml中添加 Maven Shade Plugin:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.minesweeper.ui.MinesweeperFrame</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin>

然后用java -jar target/*.jar运行,错误会打印在终端。

4.4 现象:多人在同一台电脑上玩,A 玩家的记录出现在 B 玩家的排行榜里

原因:AUTO_SERVER=TRUE开启后,H2 使用文件锁协调多进程,但若 A 玩家的 JVM 异常崩溃(如强制 kill),H2 的锁文件minesweeper.db.lock.db可能残留,导致 B 玩家连接时被拒绝,进而 fallback 到新建一个内存数据库(jdbc:h2:mem:test),此时数据不持久。
解决:在DatabaseInitializer初始化前,添加锁文件清理逻辑:

Path lockFile = Paths.get("src/main/resources/db/minesweeper.db.lock.db"); if (Files.exists(lockFile)) { try { Files.delete(lockFile); logger.info("Deleted stale H2 lock file"); } catch (IOException e) { logger.warn("Failed to delete lock file, proceeding anyway", e); } }

4.5 现象:输入名字含单引号(如O'Connor),保存时报org.h2.jdbc.JdbcSQLException: Lexical error

原因:player_name字符串直接拼接进 SQL(如"INSERT ... VALUES ('O'Connor', ...)"),导致语法错误。这是典型的 SQL 注入漏洞,也是新手最容易犯的错。
解决:必须使用PreparedStatement的setString()方法(如 3.3 节所示),绝不用字符串拼接。已在H2LeaderboardRepository.save()中强制使用,无需额外改。


5. 进阶技巧:自定义排行榜查询、导出 CSV 与离线数据分析

“英雄榜”不只是展示,更是可挖掘的数据源。本节教你三个实用技巧:如何查“上周中级最快前三”,如何一键导出所有记录为 CSV 供 Excel 分析,以及如何用纯 Java 读取数据库做离线统计——这些功能都不需要改一行 Swing 代码,全在storage层完成。

5.1 按时间范围查询:查“过去 7 天中级最快玩家”

H2LeaderboardRepository原生只提供findAllByDifficulty(String difficulty),但 H2 支持标准 SQL 时间函数。我们新增一个方法:

public List<LeaderboardEntry> findTopByDifficultyAndLastDays(String difficulty, int days) { String sql = """ SELECT * FROM leaderboard WHERE difficulty = ? AND created_at >= DATEADD('DAY', ?, CURRENT_TIMESTAMP) ORDER BY elapsed_time_ms ASC LIMIT 10 """; return jdbcTemplate.query(sql, new LeaderboardRowMapper(), difficulty, -days); }
  • DATEADD('DAY', ?, CURRENT_TIMESTAMP)是 H2 特有语法,等价于CURRENT_TIMESTAMP - INTERVAL '7' DAY(MySQL/PostgreSQL 风格);
  • -days参数传入-7,表示“7 天前”;
  • jdbcTemplate是 Spring JDBC Template 的简化版(项目中已封装),LeaderboardRowMapper将 ResultSet 映射为对象。

调用方式(在MinesweeperFrame中):

List<LeaderboardEntry> recentTop = repository.findTopByDifficultyAndLastDays("INTERMEDIATE", 7); // 将结果更新到 JTable 模型

5.2 导出 CSV:一键生成leaderboard_export_20240520.csv

导出功能放在storage层,不污染 UI。核心是CsvExporter类:

public class CsvExporter { private final DataSource dataSource; public void exportToCsv(String outputPath) throws SQLException, IOException { String sql = "SELECT player_name, difficulty, elapsed_time_ms, step_count, created_at FROM leaderboard ORDER BY created_at DESC"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery(); FileWriter writer = new FileWriter(outputPath)) { // 写入 CSV 头 writer.append("Player Name,Difficulty,Time (ms),Steps,Created At\n"); // 写入数据行,处理特殊字符(逗号、换行、引号) while (rs.next()) { String name = escapeCsvField(rs.getString("player_name")); String diff = rs.getString("difficulty"); long time = rs.getLong("elapsed_time_ms"); int steps = rs.getInt("step_count"); String createdAt = rs.getTimestamp("created_at").toString(); writer.append(String.format("%s,%s,%d,%d,%s\n", name, diff, time, steps, createdAt)); } } } private String escapeCsvField(String field) { if (field == null) return ""; // 若含逗号、双引号、换行符,则用双引号包裹,并将内部双引号转义为两个双引号 if (field.contains(",") || field.contains("\"") || field.contains("\n")) { return "\"" + field.replace("\"", "\"\"") + "\""; } return field; } }

在 UI 中绑定按钮:

JButton exportBtn = new JButton("导出排行榜"); exportBtn.addActionListener(e -> { try { String path = "./leaderboard_export_" + LocalDate.now() + ".csv"; new CsvExporter(dataSource).exportToCsv(path); JOptionPane.showMessageDialog(this, "已导出至:" + path); } catch (Exception ex) { JOptionPane.showMessageDialog(this, "导出失败:" + ex.getMessage()); } });

5.3 离线统计:用 Java 读取 H2 数据库做周报分析

假设你想每周一早上自动发邮件,内容是:“上周共 127 人通关,中级平均用时 142.3 秒”。你可以写一个独立的WeeklyReportGenerator类,不依赖 Swing:

public class WeeklyReportGenerator { public static void main(String[] args) throws SQLException { // 直连 H2 数据库文件(与主程序同一路径) String url = "jdbc:h2:file:./src/main/resources/db/minesweeper"; try (Connection conn = DriverManager.getConnection(url, "sa", "")) { // 统计上周数据 String sql = """ SELECT COUNT(*) as total_wins, AVG(elapsed_time_ms)/1000.0 as avg_time_sec, MAX(elapsed_time_ms)/1000.0 as max_time_sec, MIN(step_count) as min_steps FROM leaderboard WHERE created_at >= DATEADD('DAY', -7, CURRENT_TIMESTAMP) """; try (PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { if (rs.next()) { System.out.printf("上周统计:%d 人通关,平均用时 %.1f 秒,最长 %.1f 秒,最少 %d 步%n", rs.getInt("total_wins"), rs.getDouble("avg_time_sec"), rs.getDouble("max_time_sec"), rs.getInt("min_steps") ); } } } } }

编译运行:javac -cp "h2-2.2.224.jar;." WeeklyReportGenerator.java && java -cp "h2-2.2.224.jar;." WeeklyReportGenerator

从那以后我每次给新同学讲 Java 桌面项目,都会先让他们把这个「java 扫雷 英雄榜」跑起来,然后删掉ui包,只留core和storage,再写一套 JUnit 测试跑通。因为真正的工程能力,不在画多炫的界面,而在让逻辑可测、数据可溯、问题可查。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进