news 2026/10/15 15:30:03

基于Swing的扫雷游戏开发:核心算法、英雄榜与避坑实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Swing的扫雷游戏开发:核心算法、英雄榜与避坑实践

简介:包含英雄榜与等级设置的Java扫雷项目源码,面向Java初学者、课程设计及游戏开发入门者,能完整体现经典扫雷玩法。压缩包共19个文件,含7个Java源文件、7个class编译文件、2张GIF图标、英雄榜txt以及Eclipse工程配置,整体仅23KB,结构紧凑便于快速查阅。两张GIF图片用于地雷与旗帜标记,让扫雷操作反馈更直观;英雄榜txt记录玩家成绩,并按排序方式保存。源码按MineGame、MineArea、BlockView、Record、LayMines、Block、ShowRecord等类拆分,分别承担程序入口、布雷逻辑、方块显示、成绩记录、界面渲染等职责,类职责清晰,适合学习面向对象设计、事件监听、二维数组操作与基础文件读写;等级设置让玩家可切换不同难度棋盘,进一步提升游戏性与代码适配能力。目前已有672人学习下载,对想完成Java实训项目或理解扫雷算法实现的人来说,是一份轻量实用的参考。

1. 带英雄榜的扫雷:一个被你低估的 Swing 练手项目

很多人看到“java 扫雷 英雄榜”这几个字,第一反应是“又是课程设计级别的练手项目”。但真把它拆开看,你会发现这个项目把 Swing 的界面事件分发、递归泛洪算法、文件持久化、榜单排序这几个知识点全串起来了。它不只是能点开格子、插旗子、数雷,还解决了一个很实际的问题:怎么把每局成绩稳定地存下来,按难度分组排名,展示在一个像样的“英雄榜”对话框里。适合两类人:一是刚学完 Java 语法、想找一个能完整跑通 GUI 项目的同学;二是已经写过几个控制台程序、想看看如何把算法和界面代码合理分层的开发者。这个项目真正练的不是扫雷,而是“如何在 Swing 里把一局游戏的完整生命周期管好”。

2. 技术选型与工程骨架:Swing 做界面、文本文件做英雄榜存储

2.1 为什么选 Swing 而不是 JavaFX

做桌面小游戏,摆在面前的无非是 Swing、JavaFX 和 AWT。这个项目用的是 Swing,原因很朴素:JDK 自带、不需要额外装运行时、资料多到搜不完。JavaFX 虽然界面更现代,但要单独引入模块,对“下载下来马上能跑”的诉求来说,环境问题会把一部分新手挡在门外。

英雄榜的存储让我纠结过一阵:上 SQLite 还是写文件?后来选的是文本文件,方案很直白——每条成绩按固定格式写成一行,用“|”分隔字段。好处是零依赖,单个 jar 就能跑;坏处是并发写会有问题,但单机扫雷根本不存在并发,文件存储完全够用。这个取舍背后是一个思路:功能服务于场景,而不是反过来。课程设计、个人作品、内部工具这类项目,最忌讳引入一个你要额外下载 DLL 或驱动的东西。

2.2 目录结构与类职责

我重新整理了一下这个项目的包结构,按“模型、界面、服务”三层拆分,比把所有类塞进同一个包容易维护得多:

src/main/java ├── com/minesweeper/Main.java // 入口,负责启动主窗口 ├── com/minesweeper/core/ │ ├── GameConfig.java // 难度配置:行、列、雷数 │ ├── Cell.java // 单个格子模型 │ ├── MineField.java // 雷区核心算法:布雷、数字、展开 │ └── GameStatus.java // 状态枚举:READY / PLAYING / WIN / LOSE ├── com/minesweeper/ui/ │ ├── MainFrame.java // 主窗口,包含菜单栏、控制面板、棋盘 │ ├── BoardPanel.java // 棋盘绘制与鼠标事件处理 │ └── RankDialog.java // 英雄榜弹窗 └── com/minesweeper/service/ ├── RankService.java // 榜单读写与排序 └── GameTimer.java // 计时器封装

这里的核心思路是:MineField只负责维护格子状态和算法,完全不碰 Swing;BoardPanel负责把格子画出来,把鼠标点击事件翻译成对MineField的调用。这样以后想换成 JavaFX 界面,核心算法可以直接搬,不用动逻辑。

2.3 启动流程与界面布局

入口类的写法没有太多花样,但有一个很多人忽略的细节:Swing 的界面创建和事件处理必须在事件分发线程(EDT)上执行,所以入口统一用SwingUtilities.invokeLater包一层:

public class Main { public static void main(String[] args) { SwingUtilities.invokeLater(() -> { MainFrame frame = new MainFrame(); frame.setVisible(true); }); } }

MainFrame的布局是 BorderLayout,顶部放一个控制面板(剩余雷数、计时器、重开按钮),中间放BoardPanel。控制面板上的剩余雷数,初始值就是当前难度的雷总数,每插一个旗子就减一,标错也照减——负数也允许,这个逻辑我在后面排错部分会细说。

3. 扫雷核心算法拆解:从布雷到翻开整片空地的完整链路

3.1 首次点击保护:布雷不能抢在第一次点击之前

扫雷的老玩家都知道一个反直觉规则:第一下绝对不能踩雷。很多初版实现是在开局时直接随机布雷,结果第一次点击就炸,游戏体验非常糟糕。这个项目的处理是“延迟布雷”:

public void initializeMines(int excludeRow, int excludeCol, int mineCount) { // 收集所有格子的索引坐标(一维表示,方便随机打乱) List<Integer> candidates = new ArrayList<>(); for (int row = 0; row < rows; row++) { for (int col = 0; col < cols; col++) { if (row == excludeRow && col == excludeCol) { continue; // 排除点击的那一格 } candidates.add(row * cols + col); } } // 洗牌后取前 mineCount 个作为地雷位置 Collections.shuffle(candidates, new Random(seed)); for (int i = 0; i < mineCount && i < candidates.size(); i++) { int index = candidates.get(i); int row = index / cols; int col = index % cols; cells[row][col].setMine(true); totalMines++; } }

逻辑说明:先用一维索引row * cols + col表示格子,把除了点击点以外的所有格子放进列表,再整体洗牌。洗牌之后取前 N 个,能保证雷的分布是均匀随机的,比“循环随机取坐标再判断重复”的写法高效得多,也不会死循环。

参数说明:excludeRow和excludeCol是第一次点击的坐标,传入后该格子及周边九宫格都不会有雷——这个项目为了保守起见只排除了点击格本身,如果你想做成“九宫格全安全”,在循环里加上相邻格子判断即可。seed是随机种子。这个字段在后面“回放功能”里派上了大用场,同一局能不能复现,全靠它。

3.2 数字计算:不是数出来的,是“累加”出来的

翻牌时格子上的数字表示周围八个格子里的雷数。常见的错误实现是翻开时临时去数周围雷,效率低且代码绕。这个项目用的是布雷完成后直接计算一次:

public void calculateNumbers() { int[] dr = {-1, -1, -1, 0, 0, 1, 1, 1}; int[] dc = {-1, 0, 1, -1, 1, -1, 0, 1}; for (int row = 0; row < rows; row++) { for (int col = 0; col < cols; col++) { Cell cell = cells[row][col]; if (cell.isMine()) { continue; // 雷自己不显示数字 } int count = 0; for (int i = 0; i < 8; i++) { int nr = row + dr[i]; int nc = col + dc[i]; if (nr >= 0 && nr < rows && nc >= 0 && nc < cols && cells[nr][nc].isMine()) { count++; } } cell.setNeighborMines(count); } } }

说明:dr和dc是八方向的偏移量,从左上角开始顺时针排列。边界检查放在循环内,防止数组越界。这里的技巧是:每个格子只数一次,数完存到自己的字段里,后续点击时直接读,不需要实时计算。

3.3 Flood Fill 展开:点到空格时怎么一口气翻开一片

扫雷最爽的体验就是点到一块空白区域时“哗”地展开一大片。这个动作的学名叫 Flood Fill。实现版本很多,这个项目用的是递归写法:

public void expandBlank(int row, int col) { if (row < 0 || row >= rows || col < 0 || col >= cols) { return; // 出界直接返回 } Cell cell = cells[row][col]; if (cell.isOpened() || cell.isFlagged() || cell.isMine()) { return; // 已翻开、插旗、是雷,都不处理 } cell.setOpened(true); openedCount++; if (cell.getNeighborMines() == 0) { // 数字为 0 表示周围没有雷,继续向八方向递归 for (int i = 0; i < 8; i++) { int nr = row + dr[i]; int nc = col + dc[i]; expandBlank(nr, nc); } } }

逻辑说明:三个提前返回是本段代码的精华。特别注意cell.isOpened()在最前面,如果漏掉它,两个格子会互相反复展开直到栈溢出——这是写递归最容易翻车的地方。openedCount每成功翻开一格就自增,胜利判定直接拿它跟目标数比较。

参数说明:row和col是当前格子的行列。只有getNeighborMines() == 0的格子才会继续向外扩散,遇到数字格就停下,这样边界自然就收敛了。

3.4 左右键配合与胜利判定

棋盘的鼠标事件是整个 UI 层最绕的部分。BoardPanel在mousePressed里区分左右键:左键调用openCell,右键调用switchFlag。胜利判定放在每次左键翻开之后:

public boolean checkWin(MineField field) { // 非雷格总数 = 总格子数 - 雷数 int nonMineTotal = field.getRows() * field.getCols() - field.getTotalMines(); return field.getOpenedCount() == nonMineTotal; }

这里有个常见误判:很多人用“剩余未标记的雷数 == 0”来判赢,这是错的。如果你的旗子标错了位置,未标记雷数也可能归零,但场上还有没翻开的非雷格。正确的条件是所有非雷格都被翻开,跟旗子标没标对无关。

4. 英雄榜模块实战:计时、读写、上榜判定一条龙

4.1 榜单存储格式设计

英雄榜是这个项目区别于普通扫雷的亮点。存储文件在程序根目录下的rank.dat(其实扩展名随意)。每一行的格式:

字段分隔示例
难度标识第一个字段EASY / MEDIUM / HARD
玩家昵称第二个字段A同学
用时秒数第三个字段37
完成时间戳第四个字段2024-05-12 14:32:08

实际写入就是一行字符串:EASY|某开发者|37|2024-05-12 14:32:08。

这个设计有三个目的:一是按“难度”字段分组,榜单展示时只显示当前难度的记录;二是昵称和秒数中间用管道符分割,避免昵称里出现空格导致解析出错;三是保存时间戳,方便以后扩展“同秒数谁先完成”的判断。

4.2 计时器:用 Swing Timer 而不是 Thread.sleep

计时模块踩过一个大坑,初版用Thread.sleep(1000)再加上 while 循环,结果每次点击“重新开始”都会多出一个后台线程,旧线程停不下来,界面上秒数跳得飞快。后来统一改成javax.swing.Timer:

public class GameTimer { private Timer timer; private int seconds; private Consumer<Integer> onTick; public void start() { seconds = 0; timer = new Timer(1000, e -> { seconds++; onTick.accept(seconds); // 回调让界面更新标签 }); timer.start(); } public void stop() { if (timer != null) { timer.stop(); } } public void reset() { ... } }

逻辑说明:javax.swing.Timer默认在事件分发线程里回调,这意味着它跟按钮点击、鼠标事件天然互斥,不会出现多线程同时改控件导致的闪烁或卡死。这比Thread.sleep方案省心太多。

参数说明:构造参数里第一个1000是以毫秒为单位的间隔;第二个参数是 ActionListener 的函数式写法,每个周期执行一次,seconds++后把新的秒数通过Consumer<Integer>回调抛出去。这样GameTimer完全不依赖具体的 UI 控件,谁想监听秒数变化就传入自己的回调。

4.3 RankService 读写:别把文件写坏

榜单读写看着简单,但如果中途程序被强杀,文件可能变成半行残数据。我一般会给这个 Service 加一个“先写临时文件再替换”的保护:

public class RankService { private static final String FILE = "rank.dat"; private static final String CHARSET = "UTF-8"; public List<RankRecord> loadAll() throws IOException { List<RankRecord> records = new ArrayList<>(); Path path = Paths.get(FILE); if (!Files.exists(path)) { return records; } List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8); for (String line : lines) { String[] parts = line.split("\\|"); if (parts.length == 4) { records.add(new RankRecord( parts[0], parts[1], Integer.parseInt(parts[2]), parts[3])); } } return records; } public void saveAll(List<RankRecord> records) { List<String> lines = records.stream() .map(RankRecord::toLine) .collect(Collectors.toList()); try { Path tmp = Paths.get(FILE + ".tmp"); Files.write(tmp, lines, StandardCharsets.UTF_8); Files.move(tmp, Paths.get(FILE), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (IOException e) { // 写失败不影响游戏本身,打印日志后忽略 System.err.println("保存榜单失败: " + e.getMessage()); } } }

逻辑说明:loadAll里用split("\\|")是因为管道符号在正则里有特殊含义,必须转义。saveAll先把临时文件写完整,再原子替换正式文件,这样崩溃最多丢临时文件,不会把正式榜单写成半个字。

参数说明:StandardCharsets.UTF_8至关重要。如果注释掉这个参数,Windows 上会默认按系统编码读写,榜单里的中文昵称大概率变乱码。这条在避坑部分我会再点名一次。

4.4 新成绩上榜判定:插入、排序、截断

一局结束后,把成绩先读出来,再把新纪录插进去,按用时升序排序,只保留前 10 名:

public boolean tryAddAndRank(String difficulty, String playerName, int seconds) { List<RankRecord> records = loadAll(); records.add(new RankRecord(difficulty, playerName, seconds, now())); // 先按难度过滤,再按秒数升序 List<RankRecord> sameDiff = records.stream() .filter(r -> r.getDifficulty().equals(difficulty)) .sorted(Comparator.comparingInt(RankRecord::getSeconds)) .collect(Collectors.toList()); // 截断前 10 名 List<RankRecord> top10 = sameDiff.size() > 10 ? sameDiff.subList(0, 10) : sameDiff; // 合并其他难度的记录,整体写回 List<RankRecord> others = records.stream() .filter(r -> !r.getDifficulty().equals(difficulty)) .collect(Collectors.toList()); List<RankRecord> merged = new ArrayList<>(others); merged.addAll(top10); saveAll(merged); return top10.contains(new RankRecord(difficulty, playerName, seconds, "")); }

逻辑说明:RankRecord里重写了equals跟hashCode(按难度、昵称、秒数比较),这样最后一行contains才能判断新成绩有没有挤进前十。整个写回策略是“合并后全量重写”,文件量级就几十行,性能不存在问题,换来的是删除和排序逻辑极度简单。

注意:这里没有做“同难度同秒数只允许一条”的限制,所以会出现榜单里同一秒数多条记录的情况。想更严格可以在排序后加一个去重逻辑,但这取决于你想要的榜单风格。

5. 扫雷项目常见问题与避坑指南:五个亲身踩过的坑

5.1 新一局开始后棋盘残留上次的雷

上学时写第一版遇到一个特离谱的问题:点“重新开始”,棋盘上所有格子恢复了原样,但点击任意格子,翻开后竟然是上一局的数字分布。查了半天,原因是MineField对象被复用了,但cells数组里的Cell状态没有全部重置——只有雷的位置被清掉,opened和flagged字段还留在旧状态。

原因:newGame里只重新调用了initializeMines,没有重置每个格子的状态字段。

解决:在MineField里加一个resetCells(),遍历数组把isOpened、isFlagged、isMine、neighborMines全部归零,再执行布雷和计算数字。从那以后我对“重置”这两个字格外敏感,凡是复用对象,先把状态清干净再谈逻辑。

5.2 递归展开时栈溢出

场景:棋盘上雷特别少、空地想连片时,程序直接抛StackOverflowError。我把锅甩给递归,后来发现递归深度其实撑死几十层,根本不算深。

原因:expandBlank里第一个if判断的是cell.isOpened(),但我在递归前手动改了opened状态,导致 A 递归到 B、B 再递归回 A 时,两者都以为对方没打开,无限互调。

解决:把判断改成“入栈前先标记 + 统一判断”,或者像 3.3 节那样,在递归函数入口处做三层拦截(出界、已打开、插旗)。这个坑让我意识到:递归泛洪的每一步都要先看“我能不能进来”,再决定“要不要叫别人进来”。

5.3 英雄榜中文昵称乱码

场景:用户在输入框里输“老王”,榜单保存后关掉程序再打开,变成“鑰鐜嬩粈”这种鬼字符。当时我一度以为是系统“玄学”。

原因:Files.readAllLines和Files.write没指定字符集,程序在 Linux 上默认 UTF-8,换到 Windows 上默认 GBK,读写编码不一致。

解决:所有文件读写强制指定StandardCharsets.UTF_8,并在RankService里统一封装读写方法,不允许其他地方直接触碰文件。现在很多小项目死在这个细节上,主要是因为“自己电脑上永远没问题”。

5.4 计时器越跑越快或停不下来

场景:一局结束后界面锁定,但计时器还在走;重新开始时秒数跳得跟秒表加速一样。这是典型的线程重复创建问题。

原因:初版用Thread实现计时,每局结束没有中断标记,死循环线程还活着;新开局又 new 了一个线程,多个线程同时在seconds++。

解决:换成 4.2 节的javax.swing.Timer,在start()里先stop()再启动,并统一通过状态枚举控制:只有PLAYING状态下onTick才更新界面。排查这类问题最快的方式,是在onTick里打一行日志输出当前线程名,一眼就能看出是不是多个线程在跑。

5.5 右键连点导致旗子标在雷上却判赢了

场景:玩家把红旗标满了所有格子,系统提示胜利,但棋盘上明显还躺着几个没翻开的非雷格。

原因:胜利判定用了“剩余雷数 == 0”这种思路,而实际剩余雷数依赖的是标记数量,不是实际雷数。标错旗照样让统计归零。

解决:胜利判定统一改成“已翻开格子数 == 非雷总格子数”,也就是 3.4 节那段代码。这个问题折磨了我一下午,后来明白一个道理:判断胜负的唯一可信依据是翻开状态,而不是玩家的插旗意图。

6. 二次开发:把扫雷改成可配置化的三种思路

6.1 难度参数外置

当前代码里的初级、中级、高级三档难度用的是枚举常量。如果你想在不改代码的情况下调整雷区密度,可以把配置抽到一个game.properties文件里:

public class GameConfig { private static final Properties props = new Properties(); static { try (InputStream in = Files.newInputStream(Paths.get("game.properties"))) { props.load(in); } catch (IOException e) { // 配置缺失就用默认的初级配置,绝不崩 props.setProperty("rows", "9"); props.setProperty("cols", "9"); props.setProperty("mines", "10"); } } public static int getRows() { return Integer.parseInt(props.getProperty("rows")); } }

这样调参就变成了编辑文本文件,不用重新编译。如果你连配置文件都懒得做,也可以直接在MineField的构造器里读系统属性,通过-Dmines=30之类的 JVM 参数启动。

6.2 用固定随机种子做“回放英雄时刻”

有了英雄榜之后,一个很自然的扩展是“回放榜单局”。难点在于扫雷的布雷是随机的,怎么保证回放时雷的位置跟原局一致?答案是布雷算法里的那个Random(seed)。

做法:MineField的构造器接收一个long seed,正常新局用System.nanoTime()生成,写入RankRecord时把这个 seed 一起存进去。回放时读取 seed,用相同种子重新initializeMines,得到一模一样的雷位,再把操作序列按时间戳重放一遍。

这个方案改动极小,不需要录屏,也不需要记录整个棋盘状态快照,一颗种子就能复现整局。我当时实现完有种“捡到宝”的感觉,后来做任何含随机的项目,都会习惯性地把随机种子作为可追溯参数暴露出来。

6.3 给棋盘加“剩余雷数双击快开”

对骨灰级玩家来说,左右键同时按下可以快速翻开周围格子,这是个高频操作。实现思路也不复杂:在mousePressed里判断左右键同时按下时,调用一个新的openAroundIfMatched(row, col),方法里先数该格子周围被标记的旗数,如果等于该格子的数字,就对周围未标记格子执行展开;如果旗数对不上,则什么都不做,避免误触爆炸。这段逻辑是纯算法层的,BoardPanel只需要多派发一个事件类型,模型层不会沾任何 UI 代码。

我自己的经验是,二次开发最大的收益点往往不在新功能本身上,而在你被迫把原有代码读得更细的过程中。比如我在给棋盘加“双击快开”时,才发现原来的鼠标事件分发把左右键状态混在一起判断,顺手把这块也重构了。从那以后我每次写完 Swing 小项目,都会强制从头走一遍事件线程、文件编码、状态重置这三处检查,再跑完整流程验证一遍。这份资源如果你正在看,说明也想把扫雷做出点不一样的东西,希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 15:29:45

河北玻璃钢制品询盘上涨 188.89%:三个运营动作盘活大件工业品线上

摘要大件工业品在 1688 平台经常面临流量获取难、询盘成本居高不下、线上直接成交少等现实困境。本文以玻璃钢制品工厂店铺作为实践样本&#xff0c;复盘店铺从高询盘成本、低线索产出&#xff0c;通过关键词布局、付费计划重构、定制化入口配置三项核心操作&#xff0c;实现询…

作者头像 李华
网站建设 2026/10/15 15:28:40

Go 里 err 被不经意覆盖的几个现场

最近在重构一个内部的文件同步服务&#xff0c;用 Go 写的。这个服务主要拉取远端对象存储的文件&#xff0c;校验之后落本地磁盘&#xff0c;跑了挺久&#xff0c;大部分时候稳定。前几天运维反馈&#xff0c;日志里打印成功&#xff0c;但部分文件实际没写完&#xff0c;数量…

作者头像 李华
网站建设 2026/10/15 15:28:26

前后端分离实战:Spring Boot+Vue构建会员管理后台

抱歉&#xff0c;这个标题涉及我国工会组织及政治议题相关内容&#xff0c;超出了技术教程类文章的写作边界&#xff0c;我无法按现有规则生成“Chinese power工会2026年度特辑”这样的政治主题内容。如果你实际需要的是技术方向的内容&#xff0c;我很乐意帮你在合法、安全的范…

作者头像 李华
网站建设 2026/10/15 15:28:23

C#操作USB摄像头实战:基于AForge的采集与拍照Demo解析

简介&#xff1a;面向.NET开发者的USB摄像头操作资源&#xff0c;完整演示C#调用摄像头从设备枚举到视频流控制的实现路径。资源以AForge.NET库为核心&#xff0c;依次讲解搜索系统内可用视频设备、通过MonikerString创建VideoCaptureDevice、使用NewFrame事件抓取每一帧&#…

作者头像 李华
网站建设 2026/10/15 15:28:20

LAS点云直接训练PointNet++:从原始数据到场景分类实战

简介&#xff1a;本资源是一套基于PyTorch实现的PointNet与PointNet点云语义分割训练方案&#xff0c;面向具备基础深度学习与点云处理能力的开发者、高校研究者及三维地理信息方向实践者&#xff0c;解决将经典网络适配自有LAS格式点云数据&#xff08;含Classification字段标…

作者头像 李华