ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java扫雷实战:从Swing界面到英雄榜存储的完整项目开发

Java扫雷实战:从Swing界面到英雄榜存储的完整项目开发 简介一款基于Java Swing实现的扫雷小游戏及英雄榜管理程序面向Java初学者、课程设计者以及想通过完整项目提升GUI编程能力的开发者。程序在经典扫雷玩法基础上加入英雄榜记录功能和等级难度划分玩家完成游戏后可将成绩写入英雄榜并通过记录展示类查看历史排名体验不同难度下的策略差异。资源压缩包共19个文件包含7个Java源文件与对应class文件源码模块涵盖游戏主界面、雷区初始化、方块视图渲染、布雷逻辑、记录管理以及方块对象结构清晰适合按类分析学习另提供2张gif图标、Eclipse工程配置文件与英雄榜文本数据便于直接导入工程运行。压缩包仅23KB轻量但功能完整。当前已有672人学习下载对有课程设计或项目实训需求的读者可参考其界面事件处理、对象划分与本地文件读写实现。 从“输出”到“可玩”一个练习项目该有的样子很多学 Java 的朋友都问过类似的问题学完基础语法之后拿什么练手比较合适图书管理系统太无聊电商项目又够不着这时候“扫雷”确实是个被低估的好题材。它体量不大但要把逻辑理清楚并不简单尤其是状态管理、随机生成、递归泛洪填充这几个点踩过坑才算真正理解。我最近自己动手实现了一个带“英雄榜”的 Java 扫雷也就是把经典扫雷玩法做出来后再把每次通关的耗时、步数、难度记到本地排行榜里支持查看历史最佳成绩。这个项目虽然是一个 demo 级的东西但做完之后发现它对于理解 Java 面向对象设计、Swing 事件驱动模型、文件 IO 与数据持久化非常有帮助而且直接可以作为面试时能拿得出手的小项目来聊。这篇文章就围绕这个项目把核心设计思路、关键实现步骤、以及我在开发过程中踩过的坑完整拆开来讲清楚。1. 项目整体设计与思路拆解1.1 核心需求解析开始写代码之前先把需求落地成清晰的功能列表。我这里定义的“扫雷 英雄榜”项目核心功能分两块一是完整的扫雷游戏逻辑二是战绩记录模块。前者是基础后者是亮点。扫雷的基础规则不用多说一个 N x M 的棋盘上随机分布若干雷玩家点击格子翻开如果翻到雷则游戏结束如果翻到数字则显示该格子周围八个方向雷的数量如果翻到空格则自动递归展开相邻的空格和数字直到触达数字边界。玩家通过右键标记疑似雷的位置全部非雷格被翻开即胜利。英雄榜要做的就是记录每一局游戏的结果包括难度等级、所用时间、剩余标记数、步数、胜负状态。按照通关时间升序排列保留前十名这样产生了一个简单但真实存在需求的功能点本地存储。这个项目选型上我用的是纯 Java Swing没有引入任何第三方库。说明一下为什么这么选首先是扫雷的 UI 本身不复杂Swing 的 JButton、GridLayout、MouseListener 足够实现其次是避免引入 Maven 依赖和外部框架增加环境复杂度毕竟目标是练习核心语言能力。数据持久化方面用文件 IO 序列化保存对象列表简单可靠不依赖数据库。1.2 为什么选 Swing 而不是 JavaFX有人可能会问都什么年代了还学 Swing确实JavaFX 在 UI 现代化程度上比 Swing 好很多但从练手角度Swing 仍然有自己的优势第一Swing 是 JDK 自带的不用额外配置 JavaFX SDK尤其是在一些老旧环境或者线上笔试环境里Swing 的兼容性更好。第二Swing 的事件监听模型非常直观addActionListener、addMouseListener 这种写法新手很容易理解什么叫“回调函数”。第三Swing 的组件 API 足够丰富JButton 可以设置图标、文本、背景色BorderFactory 可以做出经典扫雷的边框效果。如果你以后想转 JavaFX从 Swing 过去的学习成本也不高核心还是事件驱动和布局管理那套思想。作为教学项目Swing 是性价比最高的选择。1.3 数据流与模块划分我在做架构设计的时候把项目拆成了四个包model存放雷区数据、格子状态、玩家成绩等实体类core游戏逻辑核心包括布雷算法、翻格子算法、胜负判定ui主窗口、棋盘面板、英雄榜对话框等界面util文件存取、时间格式化等工具类这种分包方式不复杂但足够锻炼模块化思维。最核心的一点是UI 层不直接操作数据它只负责把用户的点击行为转成指令调用 core 层的方法再刷新界面。core 层不依赖任何 Swing 组件这样即使以后要做一个控制台版本的扫雷核心逻辑也可以直接复用。我实际开发中就是先写 core 层的逻辑用 JUnit 跑通了之后再写 UI省去了大量调试界面时重复点鼠标的麻烦。2. 核心细节解析与实操要点2.1 格子状态设计扫雷的格子看似简单但状态却不只是“翻开/未翻开”二值状态。我用一个枚举来管理格子的状态public enum CellState { HIDDEN, // 未翻开 REVEALED, // 已翻开 FLAGGED, // 已被玩家标记为雷 QUESTION // 已标记问号可选经典扫雷有三次循环标记 }这里有个设计取舍是否要“问号”状态。经典 Windows 扫雷其实只有标记和问号两种附加状态。我在实现时保留问号状态但把它做成可选的菜单里可以关闭。因为对于练手项目来说多做一种状态切换逻辑能加深对状态机设计的理解。每个格子还需要存两类数据一类是身份属性也就是这个格子是不是雷另一类是显示属性就是根据周围雷数计算出来的数字。这里容易犯的一个错误是把“是否是雷”和“是否被翻开”混在一起思考。实际上它们是独立的维度一个格子可以同时是雷且未翻开也可以不是雷且已翻开。2.2 布雷算法的两种实现布雷算法的核心要求是从 M x N 个格子中随机选 K 个格子放雷每个格子最多只能有一颗雷且每次游戏雷的位置都应该不同。最简单的实现是生成一个包含所有格子索引的列表然后用 Collections.shuffle 打乱顺序取前 K 个。这种方法我实测下来效果不错代码很短ListInteger positions new ArrayList(); for (int i 0; i rows * cols; i) { positions.add(i); } Collections.shuffle(positions); for (int i 0; i mineCount; i) { int pos positions.get(i); int r pos / cols; int c pos % cols; grid[r][c].setMine(true); }另一种更常见的思路是 for 循环 Random.nextInt 生成随机位置遇到重复位置就重新生成。这种写法在雷数量很少时没问题但雷数量接近格子总数时会出现大量无效循环而且可能陷入死循环。这两种方法我推荐第一种原因不仅是性能稳定更关键的是 Collections.shuffle 的语义清晰一次性把所有位置打乱再取前 K 个代码的意图一目了然不会产生边界条件上的疑问。2.3 数字计算与状态图每个格子周围八个方向的雷数计算是扫雷逻辑里最容易写错的地方。最容易出错的是边界判断如果格子在第一行就不能去查它的上一行如果在最后一列就不能去查右边一列。我封装了一个方法private int calculateAdjacentMines(int row, int col) { int count 0; for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; int nr row dr; int nc col dc; if (isValid(nr, nc) grid[nr][nc].isMine()) { count; } } } return count; }这个方法也被用于胜利判定中的“剩余未翻开格子数是否等于雷数”的检查所以一定要保证正确性。我在调试时曾经犯过忘了跳过 dr0, dc0 的错导致每个格子的数字都比实际多 1排查了很久才定位到是这里的问题。扫雷的“状态图”其实也很值得梳理一遍它表示的的是游戏中每个格子可能处于的状态以及触发状态转换的事件。这里我用文字简单描述状态转换逻辑HIDDEN - REVEALED左键点击非雷格子时触发HIDDEN - FLAGGED右键标记为雷时触发FLAGGED - QUESTION再次右键时触发QUESTION - HIDDEN第三次右键时触发FLAGGED 或 QUESTION 状态下的格子左键点击不应触发翻开操作这一套状态转换规则是交互层的核心逻辑非常适合通过画笔在纸上画状态图帮助理解。2.4 递归泛洪填充的实现当玩家点开一个周围雷数为 0 的空白格时游戏需要自动展开周围的空白格和数字格直到触达数字边界。这个功能的经典实现方式是 DFS深度优先搜索递归public void reveal(int row, int col) { if (!isValid(row, col)) return; Cell cell grid[row][col]; if (cell.isRevealed() || cell.isFlagged()) return; cell.setRevealed(true); if (cell.getAdjacentMines() 0 !cell.isMine()) { for (int dr -1; dr 1; dr) { for (int dc -1; dc 1; dc) { if (dr 0 dc 0) continue; reveal(row dr, col dc); } } } }这里要注意的是递归深度。如果棋盘很大比如 30x30 的格子全部是空白递归深度会达到 900对 Java 默认栈大小来说完全没问题。但如果在极端情况下你想做到 1000x1000 的棋盘递归就可能栈溢出这时要改成 BFS 队列实现。我实际测试下来对于经典的 9x9、16x16、16x30 三种难度DFS 递归完全没有性能问题。但如果想要练习非递归思路也可以改成 ArrayDeque 做 BFS也是一种很好的锻炼。3. 实操过程与核心环节实现3.1 项目环境准备这个项目只依赖 JDK我用的是 JDK 8因为 JDK 8 在国内环境最普及而且 Swing API 自 JDK 1.2 之后就没有大变化用更高的 JDK 也完全可以。不需要 Maven不需要 Gradle只需要一个能编译运行的 Java 环境。如果你连 JDK 环境都没有配好注意配置 JAVA_HOME 和 PATH 两个环境变量。JAVA_HOME 指向 JDK 安装目录PATH 里追加 %JAVA_HOME%\bin。配好之后在命令行输入 java -version 和 javac -version 都能正常输出版本号就可以继续了。我建议用于这个项目的 IDE 是 IntelliJ IDEA 社区版免费且对 Java 项目支持很好。当然Eclipse 和 VS Code 也能完成工作这个看个人习惯。3.2 核心模块代码实现游戏主逻辑先上核心的 GameEngine 类它负责整个游戏的逻辑状态控制public class GameEngine { private Cell[][] grid; private int rows; private int cols; private int mineCount; private boolean gameOver; private boolean firstClick; public GameEngine(int rows, int cols, int mineCount) { this.rows rows; this.cols cols; this.mineCount mineCount; initGame(); } private void initGame() { grid new Cell[rows][cols]; for (int i 0; i rows; i) { for (int j 0; j cols; j) { grid[i][j] new Cell(); } } gameOver false; firstClick true; } public boolean openCell(int row, int col) { if (gameOver) return false; Cell cell grid[row][col]; if (cell.isRevealed() || cell.isFlagged()) return false; if (firstClick) { placeMines(row, col); firstClick false; } if (cell.isMine()) { gameOver true; revealAllMines(); return false; } reveal(row, col); return checkWin(); } }这里有一个很关键的设计首次点击保护。经典扫雷中玩家第一次点击的格子绝对不能是雷否则第一把就结束游戏体验会很差。实现方案一般有两种第一种是第一次点击前不布雷等第一次点击之后再布雷并且把点击位置的格子从候选雷位置中排除第二种是如果点击的位置是雷就把这颗雷移走再换一个位置重新布雷。我采用的是第一种改进做法在首次点击之后才布雷并且把第一击的位置连同周围八个格子如果范围内从布雷候选区排除确保第一击周围一定有一个安全区域。这样做体验更好同时也稍微提升了玩家的开局存活率。计算周围雷数的时机也在布雷之后、第一次翻开之前批量完成具体在 placeMines 方法里private void placeMines(int firstRow, int firstCol) { ListInteger 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); } Collections.shuffle(candidates); int placed 0; for (int pos : candidates) { if (placed mineCount) break; int r pos / cols; int c pos % cols; grid[r][c].setMine(true); placed; } calculateAllNumbers(); }3.3 英雄榜存储与读取英雄榜模块的核心需求其实就一句话把每一局的关键信息记录到本地文件中方便下一次打开的时候读取。我的实现仍然坚持零依赖直接用 Java 的对象序列化机制。定义成绩实体类public class ScoreRecord implements Serializable { private static final long serialVersionUID 1L; private String playerName; private Difficulty difficulty; // 枚举类型 private int timeSeconds; private int steps; private Date createTime; // getter / setter ... }注意这里必须实现 Serializable 接口并且声明 serialVersionUID。serialVersionUID 的重要性经常被忽略如果类结构发生变化比如增加字段Java 反序列化时会因版本不一致抛 InvalidClassException。对于练习项目这不是大问题但如果未来想保留旧数据最好从一开始就定义好。排行榜存储的核心类public class ScoreStore { private static final String SAVE_FILE scores.dat; private ListScoreRecord records new ArrayList(); public void add(ScoreRecord record) { records.add(record); records.sort(Comparator.comparingInt(ScoreRecord::getTimeSeconds)); if (records.size() 10) { records new ArrayList(records.subList(0, 10)); } save(); } public void save() { try (ObjectOutputStream oos new ObjectOutputStream( new FileOutputStream(SAVE_FILE))) { oos.writeObject(records); } catch (IOException e) { e.printStackTrace(); } } SuppressWarnings(unchecked) public ListScoreRecord load() { File file new File(SAVE_FILE); if (!file.exists()) return new ArrayList(); try (ObjectInputStream ois new ObjectInputStream( new FileInputStream(file))) { records (ListScoreRecord) ois.readObject(); } catch (IOException | ClassNotFoundException e) { e.printStackTrace(); } return records; } }排序的关键是“时间升序”也就是用时最短的排在最前。如果时间相同再按步数升序排。这样一个简单的 Comparator 链式比较就能搞定records.sort(Comparator .comparingInt(ScoreRecord::getTimeSeconds) .thenComparingInt(ScoreRecord::getSteps));3.4 界面层与计时器设计界面层是用 Swing 实现的整体布局采用经典的 BorderLayoutNORTH 区域放计时器和雷数计数器CENTER 区域放棋盘面板SOUTH 区域放操作按钮新手、中级、高级、查看英雄榜棋盘面板使用 GridLayout 管理 JButton 数组每个格子对应一个按钮。这里有一个容易犯的错误直接用 JButton 的文本显示数字会导致字体时大时小布局抖动。我的做法是给每个格子预先创建好图标数字变化时直接 setIcon刷新时 setText 改图标引用这样就避免了布局重算。计时器我用的是 java.util.Timer 和 TimerTask 组合每秒更新一次标签。如果使用 Swing 的 Timer 也可以Swing Timer 的优势是回调自动在 EDT事件分发线程中执行不会出现线程安全问题。我在代码中用的是 Swing Timer代码更简洁Timer timer new Timer(1000, e - { elapsedSeconds; timeLabel.setText(formatTime(elapsedSeconds)); if (elapsedSeconds 999) { stopTimer(); } }); timer.start();这里要注意应该先创建计时器并绑定监听再在首次点击时执行 timer.start()而不是在构造界面时就启动否则玩家还没开始点计时器就在跑。这个问题我在初版实现中踩到过。3.5 英雄榜的 GUI 展示英雄榜对话框使用 JDialog 实现模态对话框模式内部用 JTable 展示成绩列表。列设计为排名、玩家名、难度、时间、步数、完成时间。JTable 默认不可编辑需要重写 isCellEditable 方法返回 false否则双击单元格就能改内容这在逻辑上是不合适的。另外JTable 默认的隔行变色在默认主题下是白灰交替看起来还算清晰。如果觉得不够美观可以用 TableRowSorter 增加点击列排序功能。我实现时做了这一步用户点击“时间”列头就能快速切换到按时间排序的视图虽然实际数据已经按时间排序了但多一个交互维度总是好事。4. 常见问题与排查技巧实录4.1 经典报错Noclassdeffounderror 相关在很多 Java 入门环境里最容易撞到的一类错误就是Exception in thread main java.lang.NoClassDefFoundError这类错误字面意思是“找不到类定义”。我在开发扫雷的过程中遇到过两种典型场景第一种编译时能通过但运行时提示某个类找不到。这通常是因为 class 文件不在 classpath 中或者依赖的第三方 jar 包没有打入 classpath。解决方法是检查 IDEA 的 Project Structure 里 Output path 是否正确或者在命令行跑 java -cp .;lib/* cn.project.Main 来显式指定 classpath。第二种把项目打包成可执行 jar 之后运行时提示找不到某个类。这是因为打包时没有包含依赖库。解决方法是使用带 Class-Path 属性的 MANIFEST.MF 文件或者更简单地用 IDEA 的 Artifacts 打包功能。这类问题尤其在 Swing 项目里常见因为 Swing 涉及到的资源文件图片、配置文件需要打包进 jar 时指定路径。我建议所有的图片资源统一放在 resources 目录并在代码里使用 getResource 方式加载new ImageIcon(MainFrame.class.getResource(/images/mine.png))这样打 jar 包的时候资源文件会自动包含不会因为路径问题导致运行时找不到图片。4.2 事件监听中的并发问题Swing 是单线程模型所有 UI 操作必须在 EDT 中执行。我在第一版用 java.util.Timer 做计时器时在回调里直接更新 timeLabel偶尔会出现界面卡死或数据不同步的问题。后来改成 Swing Timer 才解决。如果你在界面上做了一些耗时操作比如加载大文件排行榜千万不要直接在事件回调里同步执行否则界面会像死掉一样。正确做法是用 SwingWorker 后台执行或者在完成之后用 SwingUtilities.invokeLater 切回 EDT 更新界面。对于这个项目来说排行榜读写本地文件的数据量很小直接在事件回调中执行问题不大但这个规范值得养成。4.3 泛洪填充导致的性能抖动还有一个我调试过程中特别注意的地方如果棋盘很大递归深度比较深第一次点击空白区域时可能会有肉眼可见的展开卡顿。这在标准难度下不明显但如果你扩展成自定义模式比如 50x50、500 颗雷就会感受到明显延迟。解决方案有两种第一种是改用 BFS 非递归实现用队列代替递归栈第二种是首次铺雷时将首点周围区域强制设置为非雷并用小型泛洪算法保证首点附近至少是一个较小连通区域减少首次递归的面。我项目中使用的是第二种方案因为处理逻辑在初始化阶段就完成了不会影响点击时的响应时间。4.4 Lombok 报错问题虽然我的项目没有用 Lombok但看热词里有人提到 Lombok 在编译时会报You arent using a compiler supported by lombok。这个问题本质是 Lombok 注解处理器与当前编译环境不兼容。解决方案通常是升级 Lombok 版本到最新稳定版注意与 JDK 版本匹配或者改用手动编写 getter/setter。对于练手项目我其实不建议使用 Lombok。一方面是为了减少依赖另一方面是手写 getter/setter、toString 这些方法本身也是熟悉 Java 语法和设计模式的过程。等你用纯手写的方式练过一个完整的项目之后再引入 Lombok 或者其他代码生成工具才会真正理解这些工具解决了什么问题。4.5 数据文件冲突与脏数据排行榜记录的 data 文件如果被手动编辑或者程序异常中断导致写入不完整反序列化时就会抛异常列表无法加载。我在代码里做了一层容错处理如果 load 方法捕获到异常不再直接崩溃而是备份损坏文件为 scores.bak并返回一个空列表下一局正常存储时会重新生成新的 scores.dat。这个策略虽然简单但能有效避免因为单次写入失败导致整个应用无法启动的问题。5. 实操心得与扩展建议做完整个项目我最大的感受是扫雷看起来小但涉及到的知识点密度非常高。从二维数组的边界处理、递归、状态机、事件监听到文件持久化、对象序列化、UI 布局几乎把 Java 基础的核心内容都串起来了。如果你是一个正在准备面试的人我强烈建议把这个项目放到你的项目列表里面试官问到 Java 基础、集合、IO、多线程都能从这里找到切入点。关于英雄榜目前实现的版本很朴素只支持本地文件存储和简单的表格展示。如果想让项目更有亮点可以从下面几个方向扩展支持按难度分别记录和筛选排行榜而不是混在一起排序加入通关后的“重播”功能把每一步操作记录成操作序列下次回放时通过观察者模式重建游戏流程换成 SQLite 或 MySQL 存储成绩体验 JDBC 操作数据库的常规流程改为 Spring Boot 后端 Web 前端这样就能把同一个扫雷逻辑做成一个前后端分离的在线游戏每一条路线都能带出一个新的知识点。我自己的计划是下一步把排行榜存储改成 JSON 文件格式这样数据可以直接被前端读取为以后做 Web 版做准备。如果你也想在练手项目中保持持续学习的动力找一个方向把这个项目迭代下去比频繁换新项目要有效得多。本文还有配套的精品资源点击获取
返回列表