☰
JSP+Java Web实战:从棋盘算法到部署,完整复现连连看创新项目
2026/10/1 3:13:03 网站建设 项目流程

说实话,“JAVA-JSP创新项目-连连看”这种题目,几乎每个学Java的人都绕不过去。你说它是个网页游戏吧,它确实就是浏览器里打开一个JSP页面,点格子消除图标;你说它是Java Web项目吧,它又确实把Session、Servlet、JDBC、JavaBean这些东西全串起来了。我这些年带过不少学生做课设,也在公司内部用JSP搭过几次小工具,这个题目算是Java Web方向里性价比很高的一款——代码量不大,但该考的考点都能覆盖,做完了还能真玩,不至于像“学生管理系统”那样做完自己都不想打开。

这篇文章就以我实际做过的方案为准,把整个项目从思路拆解到核心算法、从前端交互到部署打包,完整展开讲。目标是让正在做课设、毕设或者想拿Java练手的朋友,看完之后能照着重现一遍,并且在答辩或面试时能讲清楚“我为什么这么设计”。

1. 项目整体设计与技术选型

1.1 这个项目到底在练什么

很多人一看“连连看”就觉得是纯前端游戏,用HTML+JS就能写,何必扯上JSP和Java。这个想法没错,但那是“网页版连连看”,不是“JSP创新项目”。作为Java Web方向的课程设计,评分的重点从来不是游戏多好玩,而是你有没有把Java Web的核心知识点用起来。

JSP在这个项目里的定位是“动态页面渲染”。棋盘长什么样、格子里面放什么图,这些不是写死在HTML里的,而是后端Java算出数据后,通过JSP语法动态生成表格。用户点击格子后,前端把坐标传给后台,Java核心算法判断两个格子能不能消除,再把结果返回前端,由JavaScript更新界面。这样一个流程走下来,JSP负责展示逻辑,Java负责业务逻辑,职责分得清清楚楚。

这个项目真正训练的能力有几块:面向对象建模(棋盘、格子、游戏状态)、集合框架的使用(List、Map存数据和配对)、Servlet与JSP数据交互、Session跟踪用户状态、JDBC做登录注册和排行榜、以及经典的算法逻辑(路径搜索和碰撞检测)。把这些都想明白,这个项目就算吃透了。

1.2 功能拆解与技术栈选择

我建议项目至少包含这几个功能模块:

  • 用户登录注册,历史最高分保存到数据库;
  • 游戏主界面:棋盘展示、计时、得分、剩余图标对数统计;
  • 核心消除逻辑:点击两个图案相同且路径连通的格子完成消除;
  • 辅助功能:提示(hint)、重排(reshuffle)、暂停;
  • 输赢判定:全部消除则胜利,死局则提示重排。

技术栈上,我的固定搭配是JDK 1.8或更高版本 + Tomcat 9 + MySQL 5.7/8.0 + JSP/Servlet原生技术,前端只用基础JavaScript和CSS,顶多引入jQuery简化DOM操作。不用Spring Boot的原因很简单:课设阶段没必要上框架,而且很多学校的Java Web课程只教到JSP/Servlet,框架用得太超前反而容易被答辩老师追问到尴尬。

还有个问题是“JSP到底要不要大量写Java代码”。我的建议是:JSP页面里尽量少写业务代码,Java逻辑全部放到后台类和Servlet里处理。JSP只负责用EL表达式、JSTL和简单脚本输出数据。这样不仅代码干净,答辩时还能主动说一句“我做了前后端逻辑分离”,加分效果实实在在。

2. 连连看核心算法实现

这个项目的灵魂就是核心算法,直接决定了程序好不好玩、代码值不值得加分。我把它拆成三块来讲:棋盘生成、路径连通判定、死局检测与重排。

2.1 棋盘生成:成对布局与洗牌

连连看的棋盘是一个N行M列的二维网格,每个格子里放一种图标,每种图标必须出现偶数次,保证最后能全部消完。我最开始做的时候直接随机摆放,结果经常出现“剩下最后两个但死活对不上”的情况,因为数量奇偶没控制好。所以正确做法是:先构造一个成对出现的数组,再打乱顺序填入棋盘。

这里有个容易忽略的点:外层留一圈空白。比如游戏区域是6行8列,我在Java里实际用的是一个8行10列的二维数组,下标0和最后一行一列都空着。这样做的好处后面会体现——边缘上的方块也可以借助外部空白区域连通。留了这圈虚拟空白通道,边缘消除的规则就好处理很多,不用写一堆边界特判。

public class BoardGenerator { private int rows; private int cols; private int[][] board; public BoardGenerator(int rows, int cols) { this.rows = rows; this.cols = cols; // 外层加一圈0,所以实际数组行列各加2 this.board = new int[rows + 2][cols + 2]; } public int[][] generate(int iconTypes) { // 确保每种图标成对出现 int cellCount = rows * cols; if (cellCount % 2 != 0) { throw new IllegalArgumentException("棋盘格子数必须为偶数"); } int[] icons = new int[cellCount]; for (int i = 0; i < cellCount / 2; i++) { icons[2 * i] = i % iconTypes + 1; icons[2 * i + 1] = i % iconTypes + 1; } // Fisher-Yates 洗牌 Random random = new Random(); for (int i = icons.length - 1; i > 0; i--) { int j = random.nextInt(i + 1); int tmp = icons[i]; icons[i] = icons[j]; icons[j] = tmp; } // 填入棋盘内层 int index = 0; for (int i = 1; i <= rows; i++) { for (int j = 1; j <= cols; j++) { board[i][j] = icons[index++]; } } return board; } }

洗牌算法用的Fisher-Yates,这是目前最靠谱的等概率随机排列算法。别用Collections.shuffle代替手动实现吗?其实也行,但自己写一遍能加深理解,而且面试时经常被问到,课设里展示一下是加分项。

2.2 消除路径判定:从直线到双拐角

连连看的核心规则是两个格子图案相同、且最多经过两个转折点就能连通时,才允许消除。换句话说,两个格子之间要存在一条“最多转两次弯”的路径,且路径上所有格子(除起点终点外)都是空的。

这个判定过程可以从简单到复杂拆成三层:

第一层:直线连通。两个格子在同一个方向(同行或同列),中间没有障碍物。比如(2,3)和(2,7)在同一行,检查(2,4)到(2,6)是否全为空即可。这是最基础的情况。

第二层:一个拐角连通。两个格子不在同一行也不在同一列,但能通过一个拐角点连通。拐角点就是两个候选坐标:(x1, y2)和(x2, y1)。只要这两个坐标的格子是空的,并且从起点到拐角点、从拐角点到终点分别都是直线连通,就说明成立。

第三层:两个拐角连通。这是最容易漏的场景。比如起点在左上、终点在右下,路径可能需要先向右走、再向下、再向右,形成两个拐角。判断方法是扫描起点所在的行和列,找出所有与起点直线连通的空格子,以这些格子为中转点,再用“一个拐角”的逻辑去尝试连接终点。换个理解方式:在起点可能到达的所有位置中,有没有一个位置能通过单拐角连到终点。

public boolean canConnect(int[][] board, int x1, int y1, int x2, int y2) { if (board[x1][y1] != board[x2][y2]) return false; if (x1 == x2 && y1 == y2) return false; int value = board[x1][y1]; board[x1][y1] = board[x2][y2] = 0; // 临时置空,便于路径判断 boolean result = false; // 1. 直线连通 if (x1 == x2 || y1 == y2) { result = isLineClear(board, x1, y1, x2, y2); } // 2. 一个拐角 if (!result && isCornerClear(board, x1, y1, x2, y2)) { result = true; } // 3. 两个拐角 if (!result) { result = hasTwoTurnPath(board, x1, y1, x2, y2); } board[x1][y1] = board[x2][y2] = value; return result; }

isLineClear就是沿同一行或同一列遍历,中间全是0才返回true。hasTwoTurnPath的实现思路是枚举起点能直线到达的每个空格,再判断该空格能否通过一个拐角连到终点。注意遍历时要包括外圈那层为0的区域,这正是棋盘设计时多留一圈的原因。

private boolean hasTwoTurnPath(int[][] board, int x1, int y1, int x2, int y2) { int rows = board.length; int cols = board[0].length; // 扫描起点所在的列(向下向上) for (int i = 0; i < rows; i++) { if (i == x1) continue; if (board[i][y1] == 0 && isLineClear(board, x1, y1, i, y1) && cornerConnect(board, i, y1, x2, y2)) { return true; } } // 扫描起点所在的行(向左向右) for (int j = 0; j < cols; j++) { if (j == y1) continue; if (board[x1][j] == 0 && isLineClear(board, x1, y1, x1, j) && cornerConnect(board, x1, j, x2, y2)) { return true; } } return false; }

这个算法的复杂度是O(n*m),棋盘规模不大时性能完全够用。很多网上的实现还额外判断了“两条直线同时可走”的路径,但如果拐角点本身被占,就不用继续探测了,我们的写法已经把这类情况过滤掉了,逻辑更简洁。

2.3 死局检测与自动重排

游戏进行到中后期,经常出现“明明剩余图标很多,但就是找不到一对能连通的”,这就是死局。死局不处理,玩家只能退出重开,体验很差。所以要在每次消除后检查一次:当前棋盘上是否还存在可消除的对。

检测方法简单粗暴但可靠:遍历所有未消除的格子,嵌套两层循环,对每对图案相同的格子调用canConnect,能找到任意一对就说明局面还没死。如果全部检查完都找不到,就执行重排。

public boolean hasDeadLock(int[][] board) { List<int[]> positions = new ArrayList<>(); for (int i = 1; i < board.length - 1; i++) { for (int j = 1; j < board[i].length - 1; j++) { if (board[i][j] != 0) positions.add(new int[]{i, j}); } } // 用Map按图标值分组,减少无效配对次数 Map<Integer, List<int[]>> map = new HashMap<>(); for (int[] pos : positions) { map.computeIfAbsent(board[pos[0]][pos[1]], k -> new ArrayList<>()).add(pos); } for (List<int[]> list : map.values()) { for (int i = 0; i < list.size(); i++) { for (int j = i + 1; j < list.size(); j++) { int[] a = list.get(i); int[] b = list.get(j); if (canConnect(board, a[0], a[1], b[0], b[1])) { return false; } } } } return true; }

重排的做法是:把所有剩余图标的值收集起来,仍是成对数组,再次洗牌后填回棋盘。这里有个细节,重排后需要再检查一次是否还死局,如果连续多次仍然死局(极端情况),就把整个棋盘重新洗牌。实测中连续重排两次基本都能解开,不用做太复杂的回溯处理。

3. 从Java核心到JSP页面的完整实现

算法搞定之后,剩下的是怎么把算法挂到Web页面上去。这个环节牵扯到的地方很多,我按流程逐个讲。

3.1 登录注册与数据库访问

虽然连连看可以做成免登录的,但加上用户系统之后项目档次立刻不一样。我在这个项目里用了一张users表,字段包括用户ID、用户名、密码、最高分、游戏局数。

CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(255) NOT NULL, highest_score INT DEFAULT 0, play_count INT DEFAULT 0 );

数据库连接我直接用JDBC原生的DriverManager,没有套连接池。课设阶段连接池不是必需项,但代码要写成独立的工具类,方便以后换成C3P0或者Druid。另外,登录校验一定要用PreparedStatement,防止SQL注入。很多同学在这上面犯懒直接拼接字符串,答辩时被老师问一下“SQL注入怎么防”就哑火了。

public User login(String username, String password) { String sql = "SELECT * FROM users WHERE username = ? AND password = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return new User(rs.getString("username"), rs.getInt("highest_score")); } } } catch (SQLException e) { e.printStackTrace(); } return null; }

密码存明文在课设里比较常见,但如果你想让项目亮眼,用MD5或者SHA-256做一次哈希再入库,并在用户注册时加一个随机盐(salt),讲解起来会显得很专业。加盐的做法不复杂:hash(password + salt),盐值每次注册随机生成并单独存一列,校验时取出盐再算一次比对即可。

3.2 JSP项目目录结构与界面渲染

用IDEA新建JSP项目,我推荐用Maven的maven-archetype-webapp骨架,目录结构清晰,后面打包war也方便。核心目录如下:

src/main/java/com/example/llk/ ├── entity/User.java ├── dao/UserDao.java ├── util/DBUtil.java ├── game/BoardGenerator.java ├── game/GameCore.java └── servlet/LoginServlet.java, InitGameServlet.java, ActionServlet.java src/main/webapp/ ├── login.jsp ├── game.jsp ├── css/style.css ├── js/game.js └── images/icon1.png ...

game.jsp的核心逻辑是显示棋盘。我是这么做的:进入游戏时,InitGameServlet生成一个棋盘对象存进Session,然后把棋盘数据通过请求转发传到JSP。JSP用<c:forEach>双重循环输出表格,每个单元格的ID用行列号拼接,比如cell_3_5,这样JavaScript拿到格子后能直接通过ID定位。

<table id="board"> <c:forEach var="i" begin="1" end="${rows}"> <tr> <c:forEach var="j" begin="1" end="${cols}"> <td id="cell_${i}_${j}">async function handleClick(cell) { if (cell.querySelector('img') === null) return; // 已消除 if (firstCell !== null && secondCell !== null) return; // 等待处理中 if (firstCell === null) { firstCell = cell; cell.classList.add('selected'); } else { secondCell = cell; let x1 = firstCell.dataset.x, y1 = firstCell.dataset.y; let x2 = cell.dataset.x, y2 = cell.dataset.y; let resp = await fetch('action?x1=' + x1 + '&y1=' + y1 + '&x2=' + x2 + '&y2=' + y2); let data = await resp.json(); if (data.success) { firstCell.innerHTML = ''; secondCell.innerHTML = ''; updateScore(data.score); } else { firstCell.classList.remove('selected'); } firstCell = null; secondCell = null; } }

后端处理逻辑比较直接:ActionServlet从Session取出棋盘数组,调用GameCore.canConnect(),成功则把两个格子置为0,同时更新剩余对数;失败则返回失败标记。注意,每次校验后还要调用hasDeadLock()检查死局,如果死局则直接返回needShuffle,前端收到后刷新页面或者重新请求棋盘数据。

有一个热词提到“jsp页面让加载完后刷新一次”,这个场景在连连看里确实存在。比如用户按F5刷新页面,如果不清空Session中的游戏状态,会出现一个完全随机的老棋盘,点击会报错。我建议在game.jsp页面顶部的window.onload里做一次判断,如果需要重新开局就直接跳转到initGame请求,保证进入永远是新游戏。

3.4 war包打包与Tomcat部署

部署环节手把手再说一遍,这里翻车的概率不低。项目做完后,用IDEA右侧Maven面板执行clean package,如果项目结构正确且没有依赖问题,会在target目录下生成.war文件。把这个war文件复制到Tomcat的webapps目录,启动Tomcat,它会自动解压并部署。

一个常见的坑是web.xml的配置。Servlet 3.0以上可以用注解@WebServlet("/action")代替web.xml配置,但如果你的项目还配置了JSP欢迎页,一定要确认welcome-file指向的是login.jsp,否则访问根路径会404。

<welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list>

数据库配置我习惯单独抽一个db.properties放在src/main/resources下,用DBUtil加载。注意Tomcat运行时的classpath已经包含target/classes,所以配置文件不需要额外复制到webapp里。

4. 常见问题与独家排错笔记

最后这部分是纯实战经验汇总,全是自己踩过的坑,整理出来给后来人避雷。

4.1 问题速查表

现象常见原因解决方案
JSP页面中文乱码页面编码与Tomcat默认编码不一致所有JSP头部统一用pageEncoding="UTF-8",并在Servlet里设置request.setCharacterEncoding("UTF-8")
图片加载不出来相对路径引用错误用<%=request.getContextPath()%>/images/icon_1.png拼绝对路径
点击格子没有反应前端坐标与后端二维数组行列搞反统一按“先行后列”定义,前端>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询