简介:这是一份面向高校计算机相关专业学生的Java程序设计实践项目资料,以经典泡泡糖游戏为选题,适合用作课程设计、期末大作业或编程实训参考。项目已通过导师指导并获得97分高分评价,代码结构完整、可直接运行,无需二次修改即可提交使用。压缩包共包含201个文件,整体约174.03MB,其中35个java源文件构成游戏核心逻辑,64个png与8个jpg提供界面与素材资源,16个ttf字体、16个css样式及8个fxml文件支撑界面布局,另有14个md文档与8个xml配置说明项目结构与运行方式,并附带score.conf等配置文件用于分数记录。已有134人学习关注。读者可获得一套完整的游戏开发范例,涵盖图形界面搭建、事件处理、分数统计与资源管理等模块,文档说明与源码相互对照,便于理解设计思路、梳理代码逻辑并快速完成课程任务。
1. 泡泡糖游戏到底在练什么:从 Java 程序设计实践说起
很多人第一次看到「泡泡糖游戏」这个题目,会以为它只是个画几个彩色圆球、点一下消掉的小玩具。真动手写才发现,它把 Java 程序设计实践里最核心的几块能力全串起来了:面向对象建模、集合与泛型、事件驱动、图形绘制、状态机、碰撞与匹配判定,最后还要落到一份能讲清楚设计思路的文档说明。这也是为什么它常被选作课程设计或实训项目——代码量不大,但每一层都能拉开差距。
这篇笔记面向两类人:一类是要交 Java 程序设计实践作业、想拿到 95 分以上评价的学生;另一类是想用一个小项目把 Java 基础、Swing 绘图、面向对象设计重新捋一遍的转行者。我会按「先立住设计、再动手复现、最后讲坑」的顺序,把泡泡糖游戏从零到能跑、能演示、能写进文档的完整路径讲清楚。你照着做,至少能得到一个结构清晰、可扩展、答辩时经得起追问的版本。
2. 先定架构再写代码:泡泡糖游戏的领域模型怎么拆
泡泡糖游戏看着简单,但如果一上来就new JFrame()然后往paint()里堆逻辑,写到一半必然乱。血泪经验是:先把「谁是对象、谁负责什么」想清楚,代码量能少三分之一,后期加功能也不至于推倒重来。
2.1 用面向对象把棋盘、泡泡、规则拆成三类职责
泡泡糖游戏的核心对象其实只有三个层次。第一层是棋盘(Board),它管的是二维网格、行列数、每个格子当前放的是哪个泡泡。第二层是泡泡(Bubble),它管颜色、位置、是否被选中、是否处于消除状态。第三层是规则引擎(MatchEngine),它只做一件事:给定棋盘,找出所有满足消除条件的连通块。
为什么要把规则单独抽出来?因为消除判定是这个项目里最容易写错、也最值得写进文档的部分。把它独立成类,你就能单独给它写单元测试,答辩时也能说清楚「我的匹配算法复杂度是多少、边界怎么处理」。常见做法是让Board只存数据,MatchEngine只读Board返回结果,GamePanel负责把结果画出来。三者不互相持有对方的内部状态,改起来才不牵一发动全身。
具体到类设计,我一般会这样定:
| 类名 | 职责 | 关键字段 |
|---|---|---|
Bubble | 单个泡泡的颜色与状态 | color、row、col、selected |
Board | 二维网格容器与增删改查 | Bubble[][] grid、rows、cols |
MatchEngine | 连通块查找与消除判定 | minMatch(最小消除数) |
GamePanel | 绘制与鼠标事件 | board、engine、score |
GameFrame | 窗口与主循环入口 | JFrame、定时器 |
这张表建议直接放进你的文档说明里,评审一眼就能看出你有没有做设计,而不是堆代码。
2.2 匹配判定用 BFS 还是 DFS:连通块算法的选型理由
泡泡糖游戏的消除规则通常是「同色相邻(上下左右)且数量达到 N 个即可消除」。这就是典型的**连通块(connected component)**问题。选 BFS 还是 DFS?我的建议是 BFS,原因是它天然按层扩展,不会因为递归深度过大在超大棋盘上爆栈,而且用队列实现时逻辑更直观,写进文档也好解释。
核心思路:遍历每个格子,如果它还没被访问过,就以它为起点做一次广度优先搜索,收集所有同色相邻格子。搜索结束后如果这个集合大小 ≥minMatch,就标记为待消除。
// MatchEngine.java public List<List<Bubble>> findMatches(Board board) { int rows = board.getRows(); int cols = board.getCols(); boolean[][] visited = new boolean[rows][cols]; List<List<Bubble>> result = new ArrayList<>(); int[][] dirs = {{-1,0},{1,0},{0,-1},{0,1}}; // 上下左右 for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { if (visited[r][c]) continue; Bubble start = board.get(r, c); if (start == null) continue; List<Bubble> group = new ArrayList<>(); Queue<int[]> queue = new LinkedList<>(); queue.offer(new int[]{r, c}); visited[r][c] = true; while (!queue.isEmpty()) { int[] cur = queue.poll(); Bubble curBubble = board.get(cur[0], cur[1]); group.add(curBubble); for (int[] d : dirs) { int nr = cur[0] + d[0]; int nc = cur[1] + d[1]; if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue; if (visited[nr][nc]) continue; Bubble next = board.get(nr, nc); if (next != null && next.getColor() == curBubble.getColor()) { visited[nr][nc] = true; queue.offer(new int[]{nr, nc}); } } } if (group.size() >= minMatch) { result.add(group); } } } return result; }逻辑说明:外层双循环保证每个格子只作为起点被处理一次;visited数组防止同一个连通块被重复统计;队列里存的是坐标而不是对象,避免对象引用带来的状态污染。参数说明:minMatch是最小消除数量,常见设为 3,调大游戏更难,调小更容易连消。方向数组dirs只含上下左右,如果你要做「斜向也能连」的变体,往这里加{-1,-1}这类偏移即可,其余逻辑不用动。
2.3 棋盘数据结构:二维数组还是 List,什么时候该换
新手最容易纠结的就是「用Bubble[][]还是List<List<Bubble>>」。我的结论很直接:固定行列的泡泡糖游戏,用二维数组。原因是访问是 O(1),绘制时双重循环直观,内存布局连续,缓存友好。List的优势在动态增删,但泡泡糖棋盘大小基本固定,用List反而多一层装箱和边界判断。
不过有一个例外:如果你要做「泡泡下落、整列塌缩」这种动态效果,二维数组在删除中间元素后需要手动搬移,写起来啰嗦。这时可以保留二维数组作为「逻辑棋盘」,另外用一个List<Bubble>作为「渲染列表」,逻辑和渲染分离。这也是我在文档里会强调的一点:数据结构服务于操作频率,不是越灵活越好。
// Board.java 关键片段 public class Board { private final Bubble[][] grid; private final int rows; private final int cols; public Board(int rows, int cols) { this.rows = rows; this.cols = cols; this.grid = new Bubble[rows][cols]; fillRandom(); } public Bubble get(int r, int c) { if (r < 0 || r >= rows || c < 0 || c >= cols) return null; return grid[r][c]; } public void set(int r, int c, Bubble b) { grid[r][c] = b; } private void fillRandom() { Random rand = new Random(); Color[] palette = {Color.RED, Color.BLUE, Color.GREEN, Color.YELLOW}; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { grid[r][c] = new Bubble(palette[rand.nextInt(palette.length)], r, c); } } } }逻辑说明:get里做了边界保护,返回null而不是抛异常,这样匹配算法里可以直接判空跳过,少写 try-catch。参数说明:rows、cols建议设成 8×8 或 10×10,太小一局秒结束,太大屏幕放不下。palette颜色数量决定难度,4 色是经典配置,5 色以上连通概率下降,游戏会明显变难。
3. 从零跑起来:Swing 绘制、事件与主循环的最小实现
架构定完,接下来是让它真的能跑、能点、能消。这一章是纯落地,每一步都能直接抄。
3.1 用 JPanel 重写 paintComponent 画出棋盘和泡泡
Swing 绘图的核心是重写paintComponent(Graphics g),并且第一行一定要调用super.paintComponent(g),否则会出现残影。这是新手翻车率最高的一行。
// GamePanel.java @Override protected void paintComponent(Graphics g) { super.paintComponent(g); // 必须,否则残影 Graphics2D g2 = (Graphics2D) g; g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); int cell = CELL_SIZE; for (int r = 0; r < board.getRows(); r++) { for (int c = 0; c < board.getCols(); c++) { Bubble b = board.get(r, c); if (b == null) continue; g2.setColor(b.getColor()); int x = c * cell + PADDING; int y = r * cell + PADDING; g2.fillOval(x, y, cell - GAP, cell - GAP); if (b.isSelected()) { g2.setColor(Color.WHITE); g2.setStroke(new BasicStroke(3f)); g2.drawOval(x, y, cell - GAP, cell - GAP); } } } }逻辑说明:RenderingHints开抗锯齿,泡泡边缘才不会是锯齿状。fillOval画实心圆,drawOval画选中描边。参数说明:CELL_SIZE是每格像素,建议 50~60;GAP是泡泡之间的间隙,留 4~6 像素视觉更舒服;PADDING是棋盘外边距,防止贴边。改这三个参数就能整体缩放,不用动逻辑。
3.2 鼠标点击怎么映射到行列:坐标换算与选中状态
点击事件里最容易错的是坐标换算。鼠标给的是像素坐标,你要把它转成行列索引,还要判断点是否落在泡泡圆内。
// GamePanel 构造函数中注册监听 addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { int col = (e.getX() - PADDING) / CELL_SIZE; int row = (e.getY() - PADDING) / CELL_SIZE; if (row < 0 || row >= board.getRows() || col < 0 || col >= board.getCols()) return; Bubble b = board.get(row, col); if (b == null) return; b.setSelected(!b.isSelected()); repaint(); } });逻辑说明:先减PADDING再整除CELL_SIZE,顺序反了就会整体偏移一格。边界判断防止数组越界。repaint()触发重绘,选中状态才会显示出来。参数说明:如果你把CELL_SIZE改成动态计算(比如根据窗口大小),这里的换算公式要同步改,建议抽成一个toRow(int y)、toCol(int x)方法,避免两处不一致。
3.3 消除、下落、补位:一次完整回合的状态流转
一个完整回合是:选中若干泡泡 → 判定是否成组 → 消除 → 上方泡泡下落 → 顶部补新泡泡 → 重新判定连锁。这个顺序不能乱,否则会出现「消完不补、补完不判」的尴尬。
public void resolveTurn() { List<List<Bubble>> matches = engine.findMatches(board); if (matches.isEmpty()) return; for (List<Bubble> group : matches) { for (Bubble b : group) { board.set(b.getRow(), b.getCol(), null); score += 10; } } applyGravity(); refillTop(); repaint(); // 连锁:递归或循环再判一次 resolveTurn(); }逻辑说明:先收集所有匹配组再统一清除,避免边清边判导致索引错乱。applyGravity让每列从下往上把非空泡泡压到底部,refillTop在空位生成新泡泡。递归调用实现连锁消除,但要注意加终止条件——findMatches返回空时自然结束。参数说明:score += 10是每消一个泡泡的得分,可以按组大小给加成,比如group.size() * 10,文档里写清楚计分规则会加分。
4. 避坑与排查:泡泡糖游戏最容易翻车的 5 个地方
这一章是我踩过的坑合集,每条都按「现象 → 原因 → 解决」写,你对照排查能省大量时间。
现象一:窗口最小化再恢复,泡泡全花了。原因是paintComponent里没调super.paintComponent(g),旧画面没被清掉。解决:永远把super.paintComponent(g)放在第一行,这是 Swing 绘图的铁律。
现象二:点击位置总是偏一格。原因是坐标换算时先整除后减PADDING,或者PADDING在绘制和点击两处取值不一致。解决:把PADDING、CELL_SIZE定义成static final常量,绘制和点击共用,换算顺序固定为「先减后除」。
现象三:消除后上方泡泡不下落,出现空洞。原因是只做了清除没做重力。解决:applyGravity按列处理,从最底行往上扫描,遇到空位就把上方最近的泡泡移下来,写成一个独立方法,别混在消除循环里。
现象四:连锁消除停不下来,程序卡死。原因是递归没有终止条件,或者refillTop补的新泡泡又恰好成组,无限循环。解决:给连锁加最大层数限制(比如 10 层),或者补位时避免生成立即成组的颜色,文档里说明这个设计取舍。
现象五:答辩时被问「你的匹配算法复杂度是多少」答不上来。原因是只写了代码没分析。解决:BFS 遍历每个格子一次,时间复杂度 O(rows × cols),空间复杂度 O(rows × cols) 用于visited。把这个写进文档,评审印象分直接拉高。
提示:这五条里前三条是必现问题,建议写完第一版就逐条自测,别等到演示当天才发现。
5. 把 95 分拿到手:文档说明的写法与可扩展方向
代码能跑只是及格线,想拿到 95 分以上的评价,文档说明和扩展性才是拉开差距的地方。我一般会按这个结构写文档:需求分析、类设计(附上面那张职责表)、核心算法说明(BFS 连通块 + 复杂度)、关键代码片段、测试用例、可扩展点。测试用例部分别偷懒,至少写三条:正常三连消、边界单格点击、连锁消除,每条写清输入和预期输出。
可扩展方向是答辩加分项,挑一两个真正实现的写,别列一堆没做的。比如加计时模式:用javax.swing.Timer每 100 毫秒刷新倒计时,时间到弹结算框;或者加关卡系统:用配置文件定义每关的minMatch和颜色数量,读进来初始化Board。这两个改动都不大,但能让评审看到你的架构是真的可扩展。
// 计时模式最小实现 Timer timer = new Timer(1000, e -> { remainingSeconds--; if (remainingSeconds <= 0) { ((Timer) e.getSource()).stop(); JOptionPane.showMessageDialog(this, "时间到,得分:" + score); } repaint(); }); timer.start();逻辑说明:Timer每秒触发一次,递减倒计时,归零时停表并弹窗。参数说明:1000是毫秒间隔,改成100可以做更平滑的进度条。注意Timer的回调在 EDT 线程上执行,弹窗是安全的,但别在里面做耗时计算。
最后一个具体技巧:把游戏参数全部外置成常量或配置文件。CELL_SIZE、minMatch、颜色数量、初始分数、连锁上限,这些一旦散落在代码各处,改一个就要全局搜。我习惯在项目根目录放一个config.properties,启动时读进来,文档里附上每个参数的含义和推荐值。这样评审问「难度怎么调」,你直接指配置文件,比翻代码有说服力得多。
我自己做这类课程项目的习惯是:先花半小时画类图,再写匹配算法并单独测通,最后才接界面。顺序反了,返工是必然的。希望帮到你。
本文还有配套的精品资源,点击获取