1、演示视频
基于Java Swing的连连看小游戏
2、项目截图
设计说明
3.1 整体架构设计
本项目采用单一主类LinkGame继承JFrame作为程序入口,内部包含多个私有方法和内部类,遵循“单一职责原则”将功能模块拆分:
- 界面初始化模块:
initUI()方法负责创建窗口、菜单栏、按钮、信息面板、游戏面板等界面元素,并绑定事件监听器。 - 游戏数据管理模块:包含游戏地图(二维数组)、步数、时间、选中状态等数据,以及
initGame()、updateInfoLabels()等方法管理数据。 - 游戏逻辑处理模块:包含
handleSelect()(选中处理)、isConnectable()(连接判断)、isDeadlock()(死局检测)等核心逻辑方法。 - 界面绘制模块:内部类
GamePanel继承JPanel,重写paintComponent()方法实现游戏元素的绘制。 - 工具方法模块:包含
getCenterX()、getCenterY()等工具方法,提供通用的坐标计算功能。
3.2 数据结构设计
- 游戏地图:使用二维数组
int[][] gameMap存储每个格子的图片编号,0表示空格子,非0值表示对应编号的图片。 - 选中状态:使用
Point类型的变量firstSelect、secondSelect存储玩家选中的两个格子的坐标。 - 图片资源:使用
List存储加载的图片资源,索引与图片编号对应。 - 计时与步数:使用
int类型变量currentSteps(当前步数)、remainingTime(剩余时间)存储游戏进度数据。
3.3 界面布局设计
界面采用BorderLayout布局管理器,分为三个部分:
- 顶部面板(NORTH):包含步数、时间信息标签,以及“重新开始”“洗牌”按钮,使用
FlowLayout布局保证元素排列整齐。 - 中间面板(CENTER):游戏面板,负责绘制格子、图片、选中效果和连线。
- 菜单栏:包含“游戏”菜单,提供“重新开始”“洗牌”“退出”选项。
四、算法说明
4.1 图片连接判断算法
核心算法是判断两个图片是否可连接,支持三种连接方式:直连、单拐点、双拐点,算法流程如下:
4.1.1 直连判断(isDirectConnect方法)
判断两个点是否在同一行或同一列,且中间的格子均为空(图片编号为0)。
- 同行:遍历两个点之间的所有x坐标,检查对应的格子是否为空。
- 同列:遍历两个点之间的所有y坐标,检查对应的格子是否为空。
4.1.2 单拐点判断(findSingleCorner方法)
寻找两个点的拐点(候选拐点为(p1.x, p2.y)和(p2.x, p1.y)),判断拐点是否为空,且拐点与两个点分别直连。
4.1.3 双拐点判断(findDoubleCorner方法)
遍历所有空格子作为第一个拐点,判断该拐点与第一个点直连,且存在第二个拐点与第一个拐点和第二个点分别直连(复用单拐点判断逻辑)。
4.2 死局检测算法(isDeadlock方法)
遍历所有非空格子的坐标,两两配对检测是否存在编号相同且可连接的图片对:
- 收集所有非空格子的坐标到列表
posList中。 - 双重循环遍历列表,对每一对坐标判断图片编号是否相同且可连接。
- 若存在这样的图片对,返回
false(非死局);否则返回true(死局)。
4.3 洗牌算法(shuffleGameMap方法)
打乱现有非空图片的布局,保留空格,保证游戏连续性:
- 收集所有非空格子的图片编号和对应坐标到两个列表中。
- 使用
Collections.shuffle()方法打乱图片编号列表。 - 将打乱后的图片编号放回原非空格子的坐标位置。
- 洗牌后再次检测死局,若仍为死局则提示玩家重新开始。
五、测试说明
5.1 测试环境
| 测试项 | 配置信息 |
|---|---|
| 操作系统 | Windows 10/11、macOS、Linux(Ubuntu) |
| Java版本 | JDK 8、JDK 11、JDK 17(兼容) |
| 开发工具 | IntelliJ IDEA、Eclipse、VS Code |
| 屏幕分辨率 | 1920×1080及以上(推荐) |
5.2 功能测试用例
| 测试用例编号 | 测试功能 | 测试步骤 | 预期结果 |
|---|---|---|---|
| TC001 | 图片消除(直连) | 1. 启动游戏;2. 点击同一行的两个相同图片,中间无遮挡 | 两个图片被消除,步数加1 |
| TC002 | 图片消除(单拐点) | 1. 启动游戏;2. 点击两个相同图片,存在单拐点连接 | 两个图片被消除,步数加1,显示拐点折线 |
| TC003 | 胜利判定 | 1. 启动游戏;2. 消除所有图片 | 弹出胜利提示框,显示步数和剩余时间 |
| TC004 | 失败判定(步数用尽) | 1. 启动游戏;2. 选择超过最大步数的图片对 | 弹出失败提示框,提示步数用尽 |
| TC005 | 失败判定(时间耗尽) | 1. 启动游戏;2. 等待时间耗尽 | 弹出失败提示框,提示时间耗尽 |
| TC006 | 死局检测与洗牌 | 1. 启动游戏;2. 消除图片至死局状态 | 弹出死局提示框,选择洗牌后图片布局打乱,选择放弃则游戏失败 |
| TC007 | 手动洗牌 | 1. 启动游戏;2. 点击“洗牌”按钮 | 非空图片布局打乱,空格保留 |
| TC008 | 重新开始 | 1. 启动游戏;2. 点击“重新开始”按钮 | 游戏地图重置,步数和时间恢复初始值 |
| TC009 | 图片资源适配 | 1. 不放置本地图片;2. 启动游戏 | 游戏正常运行,使用随机颜色块替代图片 |
5.3 边界测试
- 行列数边界:测试6×6、8×8、10×10(需保证乘积为偶数)的地图大小,验证游戏功能是否正常。
- 步数边界:测试最大步数设置为10、50、100时,步数用尽的失败判定是否正常。
- 时间边界:测试最大时间设置为10、60、120秒时,时间耗尽的失败判定是否正常。
- 死局边界:测试仅剩两个不可连接的相同图片时,死局检测是否准确。
5.4 兼容性测试
- 在不同操作系统(Windows、macOS、Linux)下测试游戏界面显示和功能是否正常。
- 在不同JDK版本(8、11、17)下测试代码编译和运行是否正常。
- 在不同屏幕分辨率下测试游戏界面布局是否错乱。
六、关键代码
6.1 核心:图片连接判断代码
/** * 判断两个点是否可连接(直连、单拐点、双拐点) * @param p1 第一个点 * @param p2 第二个点 * @return 是否可连接 */ private boolean isConnectable(Point p1, Point p2) { // 直连判断(同行或同列,中间无遮挡) if (isDirectConnect(p1, p2)) { return true; } // 单拐点判断(存在一个中间点,分别与两个点直连) Point corner = findSingleCorner(p1, p2); if (corner != null) { return true; } // 双拐点判断(存在两个中间点,形成路径) return findDoubleCorner(p1, p2); } /** * 直连判断(同行或同列,中间无遮挡) */ private boolean isDirectConnect(Point p1, Point p2) { int x1 = p1.x, y1 = p1.y; int x2 = p2.x, y2 = p2.y; // 同行 if (y1 == y2) { int minX = Math.min(x1, x2); int maxX = Math.max(x1, x2); for (int x = minX + 1; x < maxX; x++) { if (gameMap[y1][x] != 0) { return false; } } return true; } // 同列 if (x1 == x2) { int minY = Math.min(y1, y2); int maxY = Math.max(y1, y2); for (int y = minY + 1; y < maxY; y++) { if (gameMap[y][x1] != 0) { return false; } } return true; } return false; } /** * 查找单拐点(存在一个点,分别与p1、p2直连),并返回拐点(用于绘制折线) */ private Point findSingleCorner(Point p1, Point p2) { int x1 = p1.x, y1 = p1.y; int x2 = p2.x, y2 = p2.y; // 拐点在(p1.x, p2.y) Point corner1 = new Point(x1, y2); if ((gameMap[corner1.y][corner1.x] == 0 || corner1.equals(p1) || corner1.equals(p2)) && isDirectConnect(p1, corner1) && isDirectConnect(corner1, p2)) { return corner1; } // 拐点在(p2.x, p1.y) Point corner2 = new Point(x2, y1); if ((gameMap[corner2.y][corner2.x] == 0 || corner2.equals(p1) || corner2.equals(p2)) && isDirectConnect(p1, corner2) && isDirectConnect(corner2, p2)) { return corner2; } return null; } /** * 双拐点判断(遍历所有空点,判断是否存在两个拐点形成路径) */ private boolean findDoubleCorner(Point p1, Point p2) { // 遍历所有空位置作为第一个拐点 for (int y = 0; y < ROW; y++) { for (int x = 0; x < COL; x++) { if (gameMap[y][x] != 0 && !(x == p1.x && y == p1.y) && !(x == p2.x && y == p2.y)) { continue; } Point corner1 = new Point(x, y); // 第一个拐点与p1直连,且存在第二个拐点与corner1、p2分别直连 if (isDirectConnect(p1, corner1)) { Point corner2 = findSingleCorner(corner1, p2); if (corner2 != null) { return true; } } } } return false; }6.2 死局检测与洗牌代码
/** * 死局检测:判断是否存在至少一对可连接的图片 * @return true=死局(无可用对),false=有可用对 */ private boolean isDeadlock() { // 遍历所有图片位置,两两配对检测 List posList = new ArrayList<>(); for (int y = 0; y < ROW; y++) { for (int x = 0; x < COL; x++) { if (gameMap[y][x] != 0) { posList.add(new Point(x, y)); } } } // 遍历所有两两组合 for (int i = 0; i < posList.size(); i++) { Point p1 = posList.get(i); for (int j = i + 1; j < posList.size(); j++) { Point p2 = posList.get(j); // 图片编号相同且可连接 if (gameMap[p1.y][p1.x] == gameMap[p2.y][p2.x] && isConnectable(p1, p2)) { return false; // 存在可用对,不是死局 } } } return true; // 无可用对,死局 } /** * 洗牌:打乱现有非空图片的布局,保留空格 */ private void shuffleGameMap() { // 1. 收集所有非空位置的图片编号 List imgList = new ArrayList<>(); List posList = new ArrayList<>(); for (int y = 0; y < ROW; y++) { for (int x = 0; x < COL; x++) { if (gameMap[y][x] != 0) { imgList.add(gameMap[y][x]); posList.add(new Point(x, y)); } } } // 2. 打乱图片编号顺序 Collections.shuffle(imgList); // 3. 将打乱后的图片编号放回原非空位置 for (int i = 0; i < posList.size(); i++) { Point p = posList.get(i); gameMap[p.y][p.x] = imgList.get(i); } // 重置选中状态 firstSelect = null; secondSelect = null; isSecondSelectInvalid = false; // 重绘面板 repaint(); // 洗牌后再次检测,如果还是死局,提示重新开始 if (isDeadlock()) { int result = JOptionPane.showConfirmDialog(this, "洗牌后仍无可用消除的图片,是否重新开始游戏?", "提示", JOptionPane.YES_NO_OPTION); if (result == JOptionPane.YES_OPTION) { restartGame(); } } }6.3 选中处理与胜负判定代码
/** * 处理选中逻辑(新增步数统计、失败判断、死局检测) * @param point 选中的格子坐标(x:列,y:行) */ private void handleSelect(Point point) { isSecondSelectInvalid = false; if (firstSelect == null) { // 第一次选中 firstSelect = point; } else if (firstSelect.equals(point)) { // 点击同一个位置,取消选中 firstSelect = null; secondSelect = null; } else { // 第二次选中,步数+1 currentSteps++; updateInfoLabels(); secondSelect = point; // 判断步数是否超过最大值,游戏失败 if (currentSteps > MAX_STEPS) { timer.stop(); showGameResult(false); return; } // 判断是否是相同的图片 if (gameMap[firstSelect.y][firstSelect.x] == gameMap[secondSelect.y][secondSelect.x]) { // 判断是否可连接 if (isConnectable(firstSelect, secondSelect)) { // 消除图片(置为0) gameMap[firstSelect.y][firstSelect.x] = 0; gameMap[secondSelect.y][secondSelect.x] = 0; // 消除后检测是否胜利 if (isGameWin()) { timer.stop(); showGameResult(true); } else { // 消除后检测是否死局 if (isDeadlock()) { int result = JOptionPane.showConfirmDialog(this, "当前无可用消除的图片,是否进行洗牌?", "死局提示", JOptionPane.YES_NO_OPTION); if (result == JOptionPane.YES_OPTION) { shuffleGameMap(); // 洗牌 } else { // 不洗牌则游戏失败 timer.stop(); JOptionPane.showMessageDialog(this, "游戏失败:无可用消除的图片", "失败", JOptionPane.INFORMATION_MESSAGE); restartGame(); } } } // 重置选中状态 firstSelect = null; secondSelect = null; } else { // 不可连接,标记为无效选中,用于闪烁提示 isSecondSelectInvalid = true; } } else { // 图片不同,标记为无效选中 isSecondSelectInvalid = true; } } } /** * 游戏胜利判断 */ private boolean isGameWin() { for (int y = 0; y < ROW; y++) { for (int x = 0; x < COL; x++) { if (gameMap[y][x] != 0) { return false; } } } return true; } /** * 显示游戏结果(胜利/失败) * @param isWin 是否胜利 */ private void showGameResult(boolean isWin) { String title = isWin ? "胜利" : "失败"; String message = isWin ? "恭喜你,游戏胜利!\n步数:" + currentSteps + "\n剩余时间:" + remainingTime + "秒" : "很遗憾,游戏失败!\n步数已用尽/时间已到"; int result = JOptionPane.showConfirmDialog(this, message + "\n是否重新开始?", title, JOptionPane.YES_NO_OPTION); if (result == JOptionPane.YES_OPTION) { restartGame(); } else { System.exit(0); } }6.4 界面绘制与折线连线代码
/** * 游戏面板,负责绘制游戏元素(优化选中样式、连线、悬浮效果) */ private class GamePanel extends JPanel { @Override protected void paintComponent(Graphics g) { super.paintComponent(g); Graphics2D g2d = (Graphics2D) g; g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); // 抗锯齿 // 绘制游戏格子和图片 for (int y = 0; y < ROW; y++) { for (int x = 0; x < COL; x++) { // 计算格子的坐标 int posX = GAP + x * (CELL_SIZE + GAP); int posY = GAP + y * (CELL_SIZE + GAP); // 绘制格子背景 g2d.setColor(Color.LIGHT_GRAY); g2d.drawRect(posX, posY, CELL_SIZE, CELL_SIZE); // 绘制图片(编号不为0时) int imgNum = gameMap[y][x]; if (imgNum > 0) { BufferedImage img = images.get(imgNum - 1); // 消除时的渐变效果(简单模拟:降低透明度) float alpha = 1.0f; g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, alpha)); g2d.drawImage(img, posX, posY, this); g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f)); // 鼠标悬浮效果:浅灰色边框 if (hoverPoint != null && hoverPoint.x == x && hoverPoint.y == y) { g2d.setColor(Color.GRAY); g2d.setStroke(new BasicStroke(2)); g2d.drawRect(posX, posY, CELL_SIZE, CELL_SIZE); g2d.setStroke(new BasicStroke(1)); } // 绘制第一个选中的边框(红色粗边框) if (firstSelect != null && firstSelect.x == x && firstSelect.y == y) { g2d.setColor(Color.RED); g2d.setStroke(new BasicStroke(3)); g2d.drawRect(posX, posY, CELL_SIZE, CELL_SIZE); g2d.setStroke(new BasicStroke(1)); } // 绘制第二个选中的边框(无效则闪烁红色边框,有效则红色粗边框) if (secondSelect != null && secondSelect.x == x && secondSelect.y == y) { if (isSecondSelectInvalid) { // 闪烁效果:交替显示红色边框和无边框 if (System.currentTimeMillis() % 600 < 300) { g2d.setColor(Color.RED); g2d.setStroke(new BasicStroke(3)); g2d.drawRect(posX, posY, CELL_SIZE, CELL_SIZE); g2d.setStroke(new BasicStroke(1)); } } else { g2d.setColor(Color.RED); g2d.setStroke(new BasicStroke(3)); g2d.drawRect(posX, posY, CELL_SIZE, CELL_SIZE); g2d.setStroke(new BasicStroke(1)); } } } } } // 绘制连线(优化为折线,显示拐点) if (firstSelect != null && secondSelect != null && gameMap[firstSelect.y][firstSelect.x] == gameMap[secondSelect.y][secondSelect.x]) { if (isConnectable(firstSelect, secondSelect)) { drawLineWithCorner(g2d, firstSelect, secondSelect); } } } /** * 绘制带拐点的折线(直连/单拐点/双拐点) */ private void drawLineWithCorner(Graphics2D g2d, Point p1, Point p2) { // 调用类的工具方法获取中心坐标 int x1 = getCenterX(p1), y1 = getCenterY(p1); int x2 = getCenterX(p2), y2 = getCenterY(p2); g2d.setColor(Color.RED); g2d.setStroke(new BasicStroke(2)); // 直连:直接画直线 if (isDirectConnect(p1, p2)) { g2d.drawLine(x1, y1, x2, y2); } else { // 单拐点:画折线(p1 -> 拐点 -> p2) Point corner = findSingleCorner(p1, p2); if (corner != null) { int cx = getCenterX(corner); int cy = getCenterY(corner); g2d.drawLine(x1, y1, cx, cy); g2d.drawLine(cx, cy, x2, y2); } else { // 双拐点:简化处理,直接画直线(可扩展为查找双拐点后画折线) g2d.drawLine(x1, y1, x2, y2); } } g2d.setStroke(new BasicStroke(1)); } }