1 项目登场:这是个啥玩意儿?
1.1 一句话介绍
这是一个在线五子棋对战网站。你打开浏览器,登录账号,点一下 "匹配",服务器帮你找个对手,然后你们俩就在线下棋,谁先连成五个子谁赢,赢了加分输了扣分。就这么简单。
整个项目后端用 Java 写,前端是 HTML 页面(这次给的代码里只有后端,前端页面没包含进来,但后端所有接口都是为前端服务的)。
1.2 技术栈:都用了啥工具?
| 技术 | 定位 | 解释 |
|---|---|---|
| Spring Boot | 项目框架 | 帮你把服务器搭起来,不用自己写一堆配置 |
| WebSocket | 实时通信 | 浏览器和服务器之间建立一条 "永久热线",随时互相发消息 |
| MyBatis | 数据库操作 | 帮你把 Java 对象和数据库表来回转换 |
| MySQL | 数据库 | 存用户账号、密码、分数这些数据 |
| Lombok | 代码简化工具 | 自动生成 getter/setter,少写一堆废话代码 |
| ConcurrentHashMap | 线程安全的 Map | 多线程同时读写也不会乱套的哈希表 |
1.3 一个玩家的完整旅程
在深入代码之前,先搞清楚一个玩家从打开网页到下完一局,经历了哪些步骤:
java
打开登录页 → 输入账号密码 → 登录成功
↓
进入游戏大厅 → 建立WebSocket连接 → 点"开始匹配"
↓
服务器在匹配队列里等 → 凑够两个人 → 创建房间 → 通知双方
↓
跳转到游戏房间 → 建立新的WebSocket连接 → 双方准备就绪
↓
轮流落子 → 服务器判定胜负 → 分出输赢
↓
更新数据库分数 → 销毁房间 → 回到大厅
记住这条主线,后面所有代码都是在为这条线上的某个环节服务。
1.4 项目结构全景图
java
com.example.java_gobang
├── JavaGobangApplication.java ← 程序入口,main方法在这里
├── api ← 接口层(和前端打交道的)
│ ├── UserAPI.java ← 登录、注册、查用户信息(HTTP接口)
│ ├── MatchAPI.java ← 匹配大厅(WebSocket)
│ ├── GameAPI.java ← 游戏房间(WebSocket)
│ └── TestAPI.java ← 测试用的回声接口(WebSocket)
├── config ← 配置类
│ └── WebSocketConfig.java ← 注册WebSocket端点
├── game ← 游戏核心逻辑
│ ├── Matcher.java ← 匹配器(帮玩家找对手)
│ ├── Room.java ← 房间(棋盘、落子、判胜负)
│ ├── RoomManager.java ← 房间管理器(管理所有房间)
│ ├── OnlineUserManager.java ← 在线用户管理器(谁在线一目了然)
│ ├── MatchRequest.java ← 匹配请求的数据结构
│ ├── MatchResponse.java ← 匹配响应的数据结构
│ ├── GameRequest.java ← 落子请求的数据结构
│ ├── GameResponse.java ← 落子响应的数据结构
│ └── GameReadyResponse.java ← 游戏准备就绪的数据结构
└── model ← 数据模型(和数据库对应的)
├── User.java ← 用户实体类
├── UserMapper.java ← 数据库操作接口
└── UserMapper.xml ← 具体SQL语句
一共 19 个文件,不多,但麻雀虽小五脏俱全。我们一个一个来。
2 数据库设计:数据住在哪儿?
2.1 建库建表语句
sql
create database if not exists java_gobang charset utf8;
use java_gobang;
drop table if exists user;
create table user (
userId int primary key auto_increment,
username varchar(50) unique,
password varchar(50),
score int,
totalCount int,
winCount int
);
insert into user values(null, '张三', '123', 1000, 0, 0);
insert into user values(null, '李四', '123', 1000, 0, 0);
insert into user values(null, '王五', '123', 1000, 0, 0);
2.1.1 逐行解读
create database if not exists java_gobang charset utf8;创建一个叫java_gobang的数据库,如果不存在就创建,字符集用 utf8(支持中文)。use java_gobang;切换到这个数据库,后面的操作都在这个库里进行。drop table if exists user;如果已经有叫 user 的表,先删掉。这是为了保证每次执行都是干净的状态(开发环境常用,生产环境千万别这么干)。create table user (...)创建用户表,六个字段:
| 字段 | 类型 | 约束 | 含义 |
|---|---|---|---|
| userId | int | primary key auto_increment | 用户 ID,主键,自动增长(你不用管,数据库自己加 1) |
| username | varchar(50) | unique | 用户名,最长 50 字符,唯一约束(不能重名) |
| password | varchar(50) | --- | 密码,最长 50 字符(明文存储,简单项目够用) |
| score | int | --- | 天梯积分,初始 1000 |
| totalCount | int | --- | 总比赛场数 |
| winCount | int | --- | 获胜场数 |
- 最后三条 insert 插入了三个测试账号:张三、李四、王五,密码都是
123,初始分数都是 1000,战绩都是 0。你可以直接用这些账号登录测试。
2.1.2 为什么只有一张表?
因为这个项目的核心是实时对战,不是社交平台。不需要好友、不需要聊天记录、不需要游戏回放,只需要记住 "谁是谁" 和 "他多少分"。一张 user 表足够了。
3 数据模型层:Java 怎么表示数据?
3.1 User.java --- 用户实体类
java
package com.example.java_gobang.model;
import lombok.Data;
@Data
public class User {
private int userId;
private String username;
private String password;
private int score;
private int totalCount;
private int winCount;
}
3.1.1 这是个啥?
这就是一个普通的 Java 类,用来表示一个用户。字段和数据库表的列一一对应。你可以把它理解成一个 "数据容器"------ 从数据库查出来的用户数据就装在这个对象里,要存到数据库的数据也先装到这个对象里。
3.1.2 @Data 注解是干啥的?
如果没有 Lombok,你得手写这些方法:
getUserId()/setUserId()getUsername()/setUsername()- ...... 六个字段就是 12 个 getter/setter
- 还有
toString()、equals()、hashCode()
加了 @Data,Lombok 自动帮你生成所有这些方法,代码瞬间清爽。这就是个 "偷懒神器"。
3.1.3 这个对象会传到哪里去?
这个 User 对象非常重要,它会被:
- 从数据库查出来(登录时)
- 存进 HttpSession(作为登录凭证)
- 通过拦截器拷贝到 WebSocketSession(匹配和游戏时识别身份)
- 放进匹配队列(Matcher 里)
- 放进房间(Room 里的 user1 和 user2)
可以说,整个项目的身份识别都是围绕这个 User 对象转的。
3.2 UserMapper.java --- 数据库操作接口
java
package com.example.java_gobang.model;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper {
void insert(User user);
User selectByName(String username);
void userWin(int userId);
void userLose(int userId);
}
3.2.1 什么是 Mapper?
MyBatis 的 Mapper 就是一个接口,你只需要定义方法名和参数,具体的 SQL 语句写在 XML 文件里。MyBatis 在运行时自动帮你生成这个接口的实现类,你直接调用方法就行,不用写 JDBC 那套繁琐的代码。
3.2.2 四个方法逐个说
| 方法 | 参数 | 返回值 | 用途 |
|---|---|---|---|
insert |
User 对象 | void | 注册时往数据库插一个新用户 |
selectByName |
用户名 | User 对象 | 登录时根据用户名查用户信息 |
userWin |
用户 ID | void | 玩家赢了,更新分数和战绩 |
userLose |
用户 ID | void | 玩家输了,更新分数和战绩 |
注意 userWin 和 userLose 只传 userId,不传具体加多少分 ------ 加减分的逻辑写在 SQL 里,是原子操作,不会出现并发问题。
3.3 UserMapper.xml --- SQL 语句都在这里
XML
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.java_gobang.model.UserMapper">
<insert id="insert">
insert into user values(null, #{username}, #{password}, 1000, 0, 0);
</insert>
<select id="selectByName" resultType="com.example.java_gobang.model.User">
select * from user where username = #{username};
</select>
<update id="userWin">
update user set totalCount = totalCount + 1, winCount = winCount + 1, score = score + 30
where userId = #{userId}
</update>
<update id="userLose">
update user set totalCount = totalCount + 1, score = score - 30
where userId = #{userId}
</update>
</mapper>
3.3.1 文件头是啥?
<?xml ...?>:XML 声明,固定格式。<!DOCTYPE ...>:指定 DTD 文件,用来验证 XML 格式是否正确,也是固定的。<mapper namespace="...">:命名空间,必须和对应的 Mapper 接口的全限定类名一致,这样 MyBatis 才能把 XML 和接口关联起来。
3.3.2 insert --- 注册
sql
insert into user values(null, #{username}, #{password}, 1000, 0, 0);
null:userId 是自增的,传 null 数据库会自动生成。#{username}、#{password}:从传入的 User 对象里取对应的属性值。#{}是 MyBatis 的占位符,会自动做预编译,防止 SQL 注入。1000, 0, 0:新用户初始分数 1000,总场数 0,胜场 0。写死在 SQL 里,不用在 Java 代码里设置。
3.3.3 selectByName --- 登录查询
sql
select * from user where username = #{username};
根据用户名查整条记录。resultType 指定了查询结果要封装成 User 对象,MyBatis 会自动把列名和字段名对应起来(因为我们的列名和字段名完全一样)。
3.3.4 userWin --- 赢了
sql
update user set totalCount = totalCount + 1, winCount = winCount + 1, score = score + 30
where userId = #{userId}
赢一把做三件事:
- 总场数 +1
- 胜场 +1
- 天梯分 +30
全部在一条 SQL 里完成,数据库保证原子性 ------ 要么全成功,要么全失败,不会出现 "场数加了但分数没加" 的中间状态。
3.3.5 userLose --- 输了
sql
update user set totalCount = totalCount + 1, score = score - 30
where userId = #{userId}
输一把做两件事:
- 总场数 +1
- 天梯分 -30
注意胜场不变(输了当然不加胜场)。扣分下限呢?代码里没做限制,理论上能扣到负数,但实际项目中一般会加个保底分(比如不低于 0),这里简化处理了。
3.4 application.yml --- 全局配置
java
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/java_gobang?characterEncoding=utf8&useSSL=false
username: root
password: YUHAO@yyds.666
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/**Mapper.xml
logging:
pattern:
console: "[%-5level] - %msg%n"
3.4.1 数据源配置
url:数据库连接地址。1.117.254.192是远程服务器 IP,3306是 MySQL 默认端口,java_gobang是库名。参数characterEncoding=utf8指定字符编码,useSSL=false关闭 SSL 连接(开发环境用,生产环境建议开启)。username/password:数据库账号密码。driver-class-name:MySQL 驱动类名,com.mysql.cj.jdbc.Driver是 MySQL 8.x 的驱动(5.x 是com.mysql.jdbc.Driver,少了个cj)。
3.4.2 MyBatis 配置
mapper-locations: classpath:mapper/**Mapper.xml:告诉 MyBatis 去 resources/mapper/ 目录下找所有以 Mapper.xml 结尾的文件。** 表示可以匹配任意层级的子目录。
3.4.3 日志配置
console: "[%-5level] - %msg%n":控制台日志格式。%-5level 表示日志级别(DEBUG/INFO/ERROR 等)左对齐占 5 个字符宽度,%msg 是日志内容,%n 是换行。这样打出来的日志整齐好看。
4 启动类:程序从哪儿开始跑?
4.1 JavaGobangApplication.java
java
package com.example.java_gobang;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class JavaGobangApplication {
public static ConfigurableApplicationContext context;
public static void main(String[] args) {
context = SpringApplication.run(JavaGobangApplication.class, args);
}
}
4.1.1 @SpringBootApplication
这是一个组合注解,包含了三个核心功能:
@Configuration:表示这是一个配置类@EnableAutoConfiguration:开启自动配置(Spring Boot 的核心,根据你引入的依赖自动配置各种东西)@ComponentScan:自动扫描当前包及其子包下的所有组件(@Component、@Controller、@Service、@Repository等),把它们注册到 Spring 容器里
4.1.2 main 方法
SpringApplication.run(...) 是 Spring Boot 的启动入口,它会:
- 创建 Spring 容器
- 扫描并注册所有 Bean
- 启动内嵌的 Tomcat 服务器
- 返回一个
ConfigurableApplicationContext对象(就是 Spring 容器本身)
4.1.3 为什么把 context 存成静态变量?
这是这个项目的一个特殊技巧 。正常情况下,Spring 管理的 Bean 之间可以用 @Autowired 互相注入。但 Room 类不是 Spring 管理的 Bean(它是 new Room() 手动创建的),无法使用 @Autowired。
解决方案:把 Spring 容器存成一个公共静态变量 ,这样在任何地方都能通过 JavaGobangApplication.context.getBean(XXX.class) 手动拿到 Spring 管理的 Bean。后面讲 Room 类的时候你会看到具体用法。
5 用户接口层:登录注册那些事儿
5.1 UserAPI.java --- 唯一的 HTTP 接口
java
package com.example.java_gobang.api;
import com.example.java_gobang.model.User;
import com.example.java_gobang.model.UserMapper;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class UserAPI {
@Resource
private UserMapper userMapper;
@PostMapping("/login")
@ResponseBody
public Object login(String username, String password, HttpServletRequest req) {
User user = userMapper.selectByName(username);
System.out.println("[login] username=" + username);
if (user == null || !user.getPassword().equals(password)) {
System.out.println("登录失败!");
return new User();
}
HttpSession httpSession = req.getSession(true);
httpSession.setAttribute("user", user);
return user;
}
@PostMapping("/register")
@ResponseBody
public Object register(String username, String password) {
try {
User user = new User();
user.setUsername(username);
user.setPassword(password);
userMapper.insert(user);
return user;
} catch (org.springframework.dao.DuplicateKeyException e) {
User user = new User();
return user;
}
}
@GetMapping("/userInfo")
@ResponseBody
public Object getUserInfo(HttpServletRequest req) {
try {
HttpSession httpSession = req.getSession(false);
User user = (User) httpSession.getAttribute("user");
User newUser = userMapper.selectByName(user.getUsername());
return newUser;
} catch (NullPointerException e) {
return new User();
}
}
}
5.1.1 @RestController 是啥?
@RestController = @Controller + @ResponseBody。表示这个类是一个控制器,并且所有方法的返回值都会直接作为 HTTP 响应体返回(而不是跳转页面)。返回的对象会被自动序列化成 JSON 字符串发给前端。
5.1.2 @Resource 注入 UserMapper
@Resource 和 @Autowired 都是用来注入依赖的,功能类似。这里把 UserMapper 注入进来,就能调用数据库操作方法了。
5.2 登录功能详解
java
@PostMapping("/login")
@ResponseBody
public Object login(String username, String password, HttpServletRequest req) {
User user = userMapper.selectByName(username);
System.out.println("[login] username=" + username);
if (user == null || !user.getPassword().equals(password)) {
System.out.println("登录失败!");
return new User();
}
HttpSession httpSession = req.getSession(true);
httpSession.setAttribute("user", user);
return user;
}
5.2.1 方法参数怎么来的?
String username, String password:前端通过表单或 JSON 提交的参数,Spring MVC 自动把请求参数绑定到方法参数上(参数名要和前端传的字段名一致)。HttpServletRequest req:HTTP 请求对象,Spring 自动注入,可以用它获取 Session、请求头、请求参数等。
5.2.2 登录流程逐步拆解
第一步:查用户
java
User user = userMapper.selectByName(username);
拿着用户名去数据库查。如果用户名不存在,user 就是 null。
第二步:校验
java
if (user == null || !user.getPassword().equals(password)) {
return new User();
}
两种情况登录失败:
- 用户不存在(
user == null) - 用户存在但密码不对(
!user.getPassword().equals(password))
注意用的是 equals 而不是 ==,因为比较的是字符串内容。|| 是短路或,前面为 true 后面就不执行了,所以不会空指针。
失败时返回一个 new User()------ 空的 User 对象,所有字段都是默认值(int 是 0,String 是 null)。前端拿到后判断 userId == 0 就知道登录失败了。
第三步:创建 Session,存用户信息
java
HttpSession httpSession = req.getSession(true);
httpSession.setAttribute("user", user);
req.getSession(true):获取当前用户的 Session,如果没有就创建一个新的。true表示 "没有就创建"。setAttribute("user", user):把 User 对象存到 Session 里,key 是"user"。
这一步是整个项目的身份基石。 后面 WebSocket 连接建立时,通过拦截器把 HttpSession 里的属性拷贝过去,这样在 WebSocket 处理器里就能拿到当前登录用户。相当于给用户发了一张 "门禁卡",后面进大厅、进房间都靠这张卡验证身份。
第四步:返回用户信息
java
return user;
登录成功,把用户对象返回给前端。Spring 自动转成 JSON,前端拿到后可以显示用户名、分数等信息。
5.3 注册功能详解
java
@PostMapping("/register")
@ResponseBody
public Object register(String username, String password) {
try {
User user = new User();
user.setUsername(username);
user.setPassword(password);
userMapper.insert(user);
return user;
} catch (org.springframework.dao.DuplicateKeyException e) {
User user = new User();
return user;
}
}
5.3.1 注册流程
- 创建一个空的 User 对象。
- 设置用户名和密码(分数和战绩不用设,SQL 里写死了 1000/0/0)。
- 调用
userMapper.insert(user)插入数据库。 - 插入成功,返回 user 对象。
5.3.2 重复用户名怎么处理?
数据库表里 username 字段有 unique 约束,如果插入重复的用户名,数据库会报错,MyBatis 会抛出 DuplicateKeyException。
代码用 try-catch 捕获这个异常,返回一个空的 User 对象。前端同样通过 userId == 0 判断注册失败。
这种写法的好处:不用先查一次数据库判断用户名是否存在,直接尝试插入,靠数据库约束兜底。少了一次数据库查询,效率更高,也避免了 "查的时候不存在、插的时候被别人抢注了" 的并发问题。
5.4 获取用户信息详解
java
@GetMapping("/userInfo")
@ResponseBody
public Object getUserInfo(HttpServletRequest req) {
try {
HttpSession httpSession = req.getSession(false);
User user = (User) httpSession.getAttribute("user");
User newUser = userMapper.selectByName(user.getUsername());
return newUser;
} catch (NullPointerException e) {
return new User();
}
}
5.4.1 为什么需要这个接口?
前端页面(比如游戏大厅)需要显示当前登录用户的用户名、分数、胜率等信息。但前端自己不知道当前用户是谁,所以调这个接口从服务器获取。
5.4.2 为什么用 getSession (false)?
req.getSession(false) 和登录时的 getSession(true) 不同:
true:没有 Session 就创建一个(登录时需要创建)false:没有 Session 就返回 null(查询时不应该创建新的,没登录就是没登录)
如果用户没登录,httpSession 是 null,调用 getAttribute 会抛 NullPointerException,被 catch 住后返回空 User。
5.4.3 为什么不直接返回 Session 里的 user?
java
User user = (User) httpSession.getAttribute("user");
User newUser = userMapper.selectByName(user.getUsername());
return newUser;
Session 里存的 user 是登录那一刻的快照。如果用户打完一局比赛,分数已经更新了,但 Session 里的 user 还是旧数据。所以这里重新查一次数据库,拿到最新的分数和战绩,保证前端显示的是实时数据。
6 WebSocket 配置:实时通信的管道
6.1 为什么需要 WebSocket?
HTTP 协议是 "一问一答" 模式 ------ 前端发请求,服务器回响应,然后连接就断了。服务器没法主动给前端发消息。
但五子棋对战需要实时性:对手落子了,你得立刻看到;匹配到对手了,你得立刻收到通知。这时候 HTTP 就不够用了。
WebSocket 是一种长连接协议:浏览器和服务器建立连接后不断开,双方可以随时互相发消息。就像打电话(WebSocket)和发短信(HTTP)的区别 ------ 打电话可以实时对话,发短信只能你一句我一句。
6.2 WebSocketConfig.java
java
package com.example.java_gobang.config;
import com.example.java_gobang.api.GameAPI;
import com.example.java_gobang.api.MatchAPI;
import com.example.java_gobang.api.TestAPI;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private TestAPI testAPI;
@Autowired
private MatchAPI matchAPI;
@Autowired
private GameAPI gameAPI;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry.addHandler(testAPI, "/test");
webSocketHandlerRegistry.addHandler(matchAPI, "/findMatch")
.addInterceptors(new HttpSessionHandshakeInterceptor());
webSocketHandlerRegistry.addHandler(gameAPI, "/game")
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
}
6.2.1 注解说明
@Configuration:表示这是一个配置类,Spring 启动时会加载它。@EnableWebSocket:开启 WebSocket 支持。implements WebSocketConfigurer:实现这个接口,重写registerWebSocketHandlers方法来注册 WebSocket 处理器。
6.2.2 三个 WebSocket 端点
| 端点 | 处理器 | 用途 |
|---|---|---|
/test |
TestAPI | 测试用,回声接口 |
/findMatch |
MatchAPI | 匹配大厅,玩家在这里匹配对手 |
/game |
GameAPI | 游戏房间,玩家在这里下棋 |
前端通过 new WebSocket("ws://服务器地址/findMatch") 这样的方式建立连接。
6.2.3 HttpSessionHandshakeInterceptor 是干啥的?
这是整个项目最关键的配置之一。
WebSocket 连接建立时会有一个 "握手" 过程(handshake)。这个拦截器会在握手阶段,把 HttpSession 中的所有 attribute 拷贝到 WebSocketSession 的 attributes 中。
什么意思呢?登录时我们把 User 对象存进了 HttpSession(httpSession.setAttribute("user", user))。但 WebSocket 连接和 HTTP 请求是不同的协议,默认情况下 WebSocket 拿不到 HttpSession 的数据。
加了 HttpSessionHandshakeInterceptor 之后,在 WebSocket 握手时,拦截器会把 HttpSession 里的 "user" 拷贝过去。这样在 MatchAPI 和 GameAPI 里就能通过 session.getAttributes().get("user") 拿到当前登录用户了。
就像你进小区大门时刷了门禁卡(HTTP 登录),进单元门时保安通过系统查到你已经刷过卡了(拦截器拷贝 Session),不用再刷一次。
注意 /test 端点没加这个拦截器,因为测试接口不需要用户身份。
7 测试接口:先跑通再说
7.1 TestAPI.java
java
package com.example.java_gobang.api;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
@Component
public class TestAPI extends TextWebSocketHandler {
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
System.out.println("连接成功");
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
System.out.println("收到消息: " + message.getPayload());
session.sendMessage(message);
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
System.out.println("连接异常");
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
System.out.println("连接关闭");
}
}
7.1.1 TextWebSocketHandler 是什么?
这是 Spring 提供的 WebSocket 文本消息处理器基类。继承它之后重写四个生命周期方法,就能处理 WebSocket 的各种事件了。
7.1.2 四个生命周期方法
| 方法 | 定位 | 解释 |
|---|---|---|
afterConnectionEstablished |
连接建立成功后 | 打印 "连接成功" |
handleTextMessage |
收到客户端文本消息时 | 打印消息内容,然后原封不动发回去(回声) |
handleTransportError |
连接异常时 | 打印 "连接异常" |
afterConnectionClosed |
连接关闭后 | 打印 "连接关闭" |
7.1.3 回声测试有啥用?
开发 WebSocket 功能时,先写一个最简单的回声接口,验证前后端 WebSocket 连通性是否正常。前端发 "hello",后端回 "hello",就说明通了。这是最小验证原则------ 先把基础设施跑通,再写复杂业务逻辑。
8 在线用户管理器:谁在线我门儿清
8.1 OnlineUserManager.java
java
package com.example.java_gobang.game;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class OnlineUserManager {
private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();
private ConcurrentHashMap<Integer, WebSocketSession> gameRoom = new ConcurrentHashMap<>();
public void enterGameHall(int userId, WebSocketSession webSocketSession) {
gameHall.put(userId, webSocketSession);
}
public void exitGameHall(int userId) {
gameHall.remove(userId);
}
public WebSocketSession getFromGameHall(int userId) {
return gameHall.get(userId);
}
public void enterGameRoom(int userId, WebSocketSession webSocketSession) {
gameRoom.put(userId, webSocketSession);
}
public void exitGameRoom(int userId) {
gameRoom.remove(userId);
}
public WebSocketSession getFromGameRoom(int userId) {
return gameRoom.get(userId);
}
}
8.1.1 为什么需要这个类?
服务器要给在线玩家发消息(比如 "你匹配到对手了"、"对手落子了"),就得知道每个玩家对应的 WebSocketSession。这个类就是一个在线用户花名册,记录了每个用户当前的连接状态。
8.1.2 为什么分两个哈希表?
玩家在不同阶段用不同的 WebSocket 连接:
- 在游戏大厅时,连接的是
/findMatch,session 存在gameHall里。 - 在游戏房间时,连接的是
/game,session 存在gameRoom里。
从大厅匹配成功后,前端会页面跳转到游戏房间页面,原来的大厅 WebSocket 连接会断开,然后建立新的游戏房间 WebSocket 连接。所以需要分表管理。
而且,分表还能实现多开检测 ------ 只要 gameHall 或 gameRoom 任意一个表里有这个 userId,就说明这个账号已经在别的地方登录了,不允许再连。
8.1.3 为什么用 ConcurrentHashMap?
WebSocket 的连接和断开是多线程并发 的 ------ 可能同时有很多玩家连接或断开。普通的 HashMap 在多线程环境下可能出问题(死循环、数据丢失等)。ConcurrentHashMap 是线程安全的,并发读写不会乱套。
8.1.4 六个方法一览
| 方法 | 作用 |
|---|---|
enterGameHall |
玩家进入大厅,记录 session |
exitGameHall |
玩家离开大厅,删除记录 |
getFromGameHall |
根据 userId 查大厅的 session |
enterGameRoom |
玩家进入房间,记录 session |
exitGameRoom |
玩家离开房间,删除记录 |
getFromGameRoom |
根据 userId 查房间的 session |
@Component 注解让这个类成为 Spring 管理的单例 Bean,整个项目只有一个实例,所有地方共享同一份在线用户数据。
9 匹配系统:帮你找对手
9.1 匹配请求和响应的数据结构
9.1.1 MatchRequest.java
java
package com.example.java_gobang.game;
public class MatchRequest {
private String message = "";
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
前端发给服务器的匹配请求,只有一个 message 字段:
"startMatch":开始匹配"stopMatch":停止匹配
这个类没有用 Lombok 的 @Data,手写了 getter/setter。(这里我不是忘了加,是故意, 再次区分使用 Lombok 的区别, 功能一样,就是代码多几行)
9.1.2 MatchResponse.java
java
package com.example.java_gobang.game;
public class MatchResponse {
private boolean ok;
private String reason;
private String message;
// getter/setter 省略...
}
服务器发给前端的匹配响应,三个字段:
| 字段 | 类型 | 含义 |
|---|---|---|
ok |
boolean | 请求是否成功处理 |
reason |
String | 失败原因(成功时为空) |
message |
String | 状态标识,前端根据这个值做不同处理 |
9.2 MatchAPI.java --- 匹配大厅的 WebSocket 处理器
java
package com.example.java_gobang.api;
import com.example.java_gobang.game.MatchRequest;
import com.example.java_gobang.game.MatchResponse;
import com.example.java_gobang.game.Matcher;
import com.example.java_gobang.game.OnlineUserManager;
import com.example.java_gobang.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import tools.jackson.databind.ObjectMapper;
@Component
public class MatchAPI extends TextWebSocketHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private Matcher matcher;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
try {
User user = (User) session.getAttributes().get("user");
if (onlineUserManager.getFromGameHall(user.getUserId()) != null
|| onlineUserManager.getFromGameRoom(user.getUserId()) != null) {
MatchResponse response = new MatchResponse();
response.setOk(true);
response.setReason("当前禁止多开!");
response.setMessage("repeatConnection");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
return;
}
onlineUserManager.enterGameHall(user.getUserId(), session);
System.out.println("玩家 " + user.getUsername() + " 进入游戏大厅!");
} catch (NullPointerException e) {
System.out.println("[MatchAPI.afterConnectionEstablished] 当前用户未登录!");
MatchResponse response = new MatchResponse();
response.setOk(false);
response.setReason("您尚未登录! 不能进行后续匹配功能!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
}
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
User user = (User) session.getAttributes().get("user");
String payload = message.getPayload();
MatchRequest request = objectMapper.readValue(payload, MatchRequest.class);
MatchResponse response = new MatchResponse();
if (request.getMessage().equals("startMatch")) {
matcher.add(user);
response.setOk(true);
response.setMessage("startMatch");
} else if (request.getMessage().equals("stopMatch")) {
matcher.remove(user);
response.setOk(true);
response.setMessage("stopMatch");
} else {
response.setOk(false);
response.setReason("非法的匹配请求");
}
String jsonString = objectMapper.writeValueAsString(response);
session.sendMessage(new TextMessage(jsonString));
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
try {
User user = (User) session.getAttributes().get("user");
WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
if (tmpSession == session) {
onlineUserManager.exitGameHall(user.getUserId());
}
matcher.remove(user);
} catch (NullPointerException e) {
System.out.println("[MatchAPI.handleTransportError] 当前用户未登录!");
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
try {
User user = (User) session.getAttributes().get("user");
WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
if (tmpSession == session) {
onlineUserManager.exitGameHall(user.getUserId());
}
matcher.remove(user);
} catch (NullPointerException e) {
System.out.println("[MatchAPI.afterConnectionClosed] 当前用户未登录!");
}
}
}
9.2.1 依赖注入
ObjectMapper:Jackson 库的核心类,用来在 Java 对象和 JSON 字符串之间互相转换。readValue把 JSON 转 Java 对象,writeValueAsString把 Java 对象转 JSON。OnlineUserManager:在线用户管理器,用来登记 / 查询玩家在线状态。Matcher:匹配器,核心的匹配逻辑都在里面。
9.2.2 afterConnectionEstablished --- 玩家进入大厅
这是玩家打开游戏大厅页面、建立 WebSocket 连接后调用的第一个方法。
第一步:获取用户身份
java
User user = (User) session.getAttributes().get("user");
从 WebSocketSession 的 attributes 里拿 User 对象。这全靠 HttpSessionHandshakeInterceptor 在握手时把 HttpSession 的数据拷贝过来了。
第二步:多开检测
java
if (onlineUserManager.getFromGameHall(user.getUserId()) != null
|| onlineUserManager.getFromGameRoom(user.getUserId()) != null) {
MatchResponse response = new MatchResponse();
response.setOk(true);
response.setReason("当前禁止多开!");
response.setMessage("repeatConnection");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
return;
}
检查这个用户是否已经在大厅或房间在线。如果已经在线,说明同一个账号在别的浏览器 / 标签页登录了,返回 repeatConnection 让前端处理(比如提示 "禁止多开" 并跳回登录页)。
注意这里 ok 设为 true------ 因为连接本身是成功建立的,只是业务上不允许多开。前端通过 message == "repeatConnection" 来判断这种情况。
第三步:登记在线状态
java
onlineUserManager.enterGameHall(user.getUserId(), session);
把用户的 userId 和 WebSocketSession 存到 gameHall 里,标记为 "在大厅在线"。
异常处理 如果用户没登录直接访问大厅页面,session.getAttributes().get("user") 返回 null,调用 user.getUserId() 会抛 NullPointerException。catch 住后返回 "您尚未登录" 的响应。
9.2.3 handleTextMessage --- 处理匹配请求
玩家点击 "开始匹配" 或 "停止匹配" 按钮时,前端发消息过来,这个方法被调用。
第一步:获取用户和消息内容
java
User user = (User) session.getAttributes().get("user");
String payload = message.getPayload();
MatchRequest request = objectMapper.readValue(payload, MatchRequest.class);
message.getPayload():拿到前端发来的原始 JSON 字符串。objectMapper.readValue(payload, MatchRequest.class):把 JSON 字符串解析成MatchRequest对象。
第二步:根据 message 类型处理
java
if (request.getMessage().equals("startMatch")) {
matcher.add(user); // 加入匹配队列
response.setMessage("startMatch");
} else if (request.getMessage().equals("stopMatch")) {
matcher.remove(user); // 移出匹配队列
response.setMessage("stopMatch");
} else {
response.setOk(false);
response.setReason("非法的匹配请求");
}
startMatch:把玩家加入匹配队列,具体匹配逻辑由 Matcher 处理。stopMatch:把玩家从匹配队列移除。- 其他值:非法请求,返回错误。
第三步:返回响应
java
String jsonString = objectMapper.writeValueAsString(response);
session.sendMessage(new TextMessage(jsonString));
把响应对象转成 JSON,通过 WebSocket 发给前端。TextMessage 是 Spring WebSocket 封装的文本消息类。
9.2.4 handleTransportError 和 afterConnectionClosed --- 玩家离开
这两个方法逻辑几乎一样,一个是连接异常时调用,一个是连接正常关闭时调用。
java
User user = (User) session.getAttributes().get("user");
WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
if (tmpSession == session) {
onlineUserManager.exitGameHall(user.getUserId());
}
matcher.remove(user);
第一步:从在线列表移除 先查 gameHall 里存的 session 是不是当前这个 session,是的话才移除。
为什么要加这个判断?考虑多开场景:用户在 A 标签页登录了,又在 B 标签页登录(被拒绝但连接建立了)。如果 B 标签页关闭时直接移除,就会把 A 标签页的 session 也删掉。加 tmpSession == session 判断后,只有关闭的是当前登记的那个连接才移除,避免误删。
第二步:从匹配队列移除
java
matcher.remove(user);
玩家都断开连接了,当然不能还在匹配队列里等着匹配对手。所以要移除。
异常处理 同样 catch NullPointerException,因为未登录的用户也可能建立连接然后断开。
9.3 Matcher.java --- 匹配器核心
java
package com.example.java_gobang.game;
import com.example.java_gobang.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.LinkedList;
import java.util.Queue;
@Component
public class Matcher {
private Queue<User> normalQueue = new LinkedList<>();
private Queue<User> highQueue = new LinkedList<>();
private Queue<User> veryHighQueue = new LinkedList<>();
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private RoomManager roomManager;
private ObjectMapper objectMapper = new ObjectMapper();
public void add(User user) {
if (user.getScore() < 2000) {
synchronized (normalQueue) {
normalQueue.offer(user);
normalQueue.notify();
}
System.out.println("把玩家 " + user.getUsername() + " 加入到了 normalQueue 中!");
} else if (user.getScore() >= 2000 && user.getScore() < 3000) {
synchronized (highQueue) {
highQueue.offer(user);
highQueue.notify();
}
System.out.println("把玩家 " + user.getUsername() + " 加入到了 highQueue 中!");
} else {
synchronized (veryHighQueue) {
veryHighQueue.offer(user);
veryHighQueue.notify();
}
System.out.println("把玩家 " + user.getUsername() + " 加入到了 veryHighQueue 中!");
}
}
public void remove(User user) {
if (user.getScore() < 2000) {
synchronized (normalQueue) {
normalQueue.remove(user);
}
System.out.println("把玩家 " + user.getUsername() + " 移除了 normalQueue!");
} else if (user.getScore() >= 2000 && user.getScore() < 3000) {
synchronized (highQueue) {
highQueue.remove(user);
}
System.out.println("把玩家 " + user.getUsername() + " 移除了 highQueue!");
} else {
synchronized (veryHighQueue) {
veryHighQueue.remove(user);
}
System.out.println("把玩家 " + user.getUsername() + " 移除了 veryHighQueue!");
}
}
public Matcher() {
Thread t1 = new Thread() {
@Override
public void run() {
while (true) {
handlerMatch(normalQueue);
}
}
};
t1.start();
Thread t2 = new Thread(){
@Override
public void run() {
while (true) {
handlerMatch(highQueue);
}
}
};
t2.start();
Thread t3 = new Thread() {
@Override
public void run() {
while (true) {
handlerMatch(veryHighQueue);
}
}
};
t3.start();
}
private void handlerMatch(Queue<User> matchQueue) {
synchronized (matchQueue) {
try {
while (matchQueue.size() < 2) {
matchQueue.wait();
}
User player1 = matchQueue.poll();
User player2 = matchQueue.poll();
System.out.println("匹配出两个玩家: " + player1.getUsername() + ", " + player2.getUsername());
WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());
if (session1 == null) {
matchQueue.offer(player2);
return;
}
if (session2 == null) {
matchQueue.offer(player1);
return;
}
if (session1 == session2) {
matchQueue.offer(player1);
return;
}
Room room = new Room();
roomManager.add(room, player1.getUserId(), player2.getUserId());
MatchResponse response1 = new MatchResponse();
response1.setOk(true);
response1.setMessage("matchSuccess");
String json1 = objectMapper.writeValueAsString(response1);
session1.sendMessage(new TextMessage(json1));
MatchResponse response2 = new MatchResponse();
response2.setOk(true);
response2.setMessage("matchSuccess");
String json2 = objectMapper.writeValueAsString(response2);
session2.sendMessage(new TextMessage(json2));
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
}
9.3.1 三档匹配队列
java
private Queue<User> normalQueue = new LinkedList<>(); // 分数 < 2000
private Queue<User> highQueue = new LinkedList<>(); // 2000 <= 分数 < 3000
private Queue<User> veryHighQueue = new LinkedList<>(); // 分数 >= 3000
按天梯分把玩家分成三个段位,同段位的人才能匹配到一起。这样新手不会遇到大佬,游戏体验更好。就像拳击中的重量级和轻量级不能同台竞技一样。
用 LinkedList 实现队列,因为它天然支持先进先出(FIFO)。
9.3.2 add 方法 --- 加入匹配队列
java
public void add(User user) {
if (user.getScore() < 2000) {
synchronized (normalQueue) {
normalQueue.offer(user);
normalQueue.notify();
}
} else if (...) {
// 类似
} else {
// 类似
}
}
根据玩家分数决定进哪个队列。关键细节:
synchronized (normalQueue):对队列加锁,保证多线程环境下入队操作的线程安全。normalQueue.offer(user):把玩家加入队列尾部。offer和add的区别是 offer 失败返回 false 而不是抛异常(这里队列是无界的,不会失败)。normalQueue.notify():唤醒正在这个队列上wait()的匹配线程。因为可能有线程在等 "队列里凑够两个人",现在来了一个人,得叫醒它看看够不够两个了。
9.3.3 remove 方法 --- 移出匹配队列
java
public void remove(User user) {
if (user.getScore() < 2000) {
synchronized (normalQueue) {
normalQueue.remove(user);
}
}
// ... 其他两档类似
}
玩家点 "停止匹配" 或者断开连接时调用。Queue.remove(user) 会遍历队列找到并移除这个用户对象(依赖 User 的 equals 方法,Lombok 的 @Data 已经生成了)。
注意 remove 时没有 notify,因为移除元素不会让 "等待凑够两人" 的条件变成 true,不需要唤醒。
9.3.4 构造方法 --- 启动三个匹配线程
java
public Matcher() {
Thread t1 = new Thread() {
public void run() {
while (true) {
handlerMatch(normalQueue);
}
}
};
t1.start();
// t2、t3 同理
}
Matcher 被 Spring 创建时(单例),构造方法启动三个死循环线程 ,分别扫描三个匹配队列。每个线程不断调用 handlerMatch,一旦队列里凑够两个人就匹配。
这是典型的生产者 - 消费者模式:
- 生产者:玩家点击匹配,
add方法把玩家放入队列(生产)。 - 消费者:匹配线程从队列里取人配对(消费)。
wait/notify:队列没人时消费者等待,有人入队时唤醒消费者。
9.3.5 handlerMatch --- 核心匹配逻辑
这是整个匹配系统最重要的方法,逐行拆解:
第一步:加锁并等待
java
synchronized (matchQueue) {
while (matchQueue.size() < 2) {
matchQueue.wait();
}
synchronized (matchQueue):对队列对象加锁,保证同一时间只有一个线程操作这个队列。while (matchQueue.size() < 2):队列里不足两人时,调用wait()释放锁并阻塞等待。- 用
while而不是if:防止虚假唤醒(线程可能在没有被 notify 的情况下醒来,或者醒来后条件又不满足了)。while 循环会重新检查条件,不满足就继续 wait。
第二步:取出两个玩家
java
User player1 = matchQueue.poll();
User player2 = matchQueue.poll();
poll() 从队列头部取出并移除元素。先入队的先被取出来(先来先匹配)。
第三步:检查玩家是否还在线
java
WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());
if (session1 == null) {
matchQueue.offer(player2);
return;
}
if (session2 == null) {
matchQueue.offer(player1);
return;
}
理论上匹配队列里的玩家都是在线的(断开连接时会 remove),但多线程环境下存在时间差:玩家可能在被取出队列的前一瞬间断开了连接。所以这里再检查一次。
如果玩家 1 不在线了,把玩家 2 塞回队列继续等下一个对手。反之亦然。
第四步:检查是不是同一个人
java
if (session1 == session2) {
matchQueue.offer(player1);
return;
}
理论上不可能 ------ 一个玩家不可能同时入队两次(禁止多开 + 断开即移除)。但作者还是加了这层防御性检查,防止前面的逻辑有 bug 时导致自己和自己匹配。
第五步:创建房间
java
Room room = new Room();
roomManager.add(room, player1.getUserId(), player2.getUserId());
new Room() 创建一个新房间(构造方法里自动生成 UUID 作为房间号),然后通过 roomManager.add 把房间和两个玩家关联起来。
第六步:通知双方匹配成功
java
MatchResponse response1 = new MatchResponse();
response1.setOk(true);
response1.setMessage("matchSuccess");
String json1 = objectMapper.writeValueAsString(response1);
session1.sendMessage(new TextMessage(json1));
// 给玩家2发同样的消息
给两个玩家都发 matchSuccess 消息。前端收到后跳转到游戏房间页面(game_room.html)。
注意:这里只是通知匹配成功,玩家还没有正式进入游戏房间 。需要等前端页面跳转后,建立 /game 的 WebSocket 连接,才算真正进入房间。
10 房间管理:对战的物理空间
10.1 RoomManager.java --- 房间管理器
java
package com.example.java_gobang.game;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class RoomManager {
private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();
public void add(Room room, int userId1, int userId2) {
rooms.put(room.getRoomId(), room);
userIdToRoomId.put(userId1, room.getRoomId());
userIdToRoomId.put(userId2, room.getRoomId());
}
public void remove(String roomId, int userId1, int userId2) {
rooms.remove(roomId);
userIdToRoomId.remove(userId1);
userIdToRoomId.remove(userId2);
}
public Room getRoomByRoomId(String roomId) {
return rooms.get(roomId);
}
public Room getRoomByUserId(int userId) {
String roomId = userIdToRoomId.get(userId);
if (roomId == null) {
return null;
}
return rooms.get(roomId);
}
}
10.1.1 两张哈希表
| 表 | key | value | 用途 |
|---|---|---|---|
rooms |
roomId(房间号) | Room 对象 | 通过房间号查房间 |
userIdToRoomId |
userId(玩家 ID) | roomId(房间号) | 通过玩家 ID 反查在哪个房间 |
为什么需要两张表?因为不同场景需要不同的查询方式:
- 创建房间后,需要通过 roomId 找到房间(虽然代码里没怎么用这个方法,但保留了)。
- 玩家建立游戏连接时,只知道自己的 userId,需要通过
getRoomByUserId找到自己在哪个房间。
userIdToRoomId 就像一个索引,通过 userId 快速定位 roomId,再通过 roomId 找到 Room 对象。
10.1.2 add --- 添加房间
java
public void add(Room room, int userId1, int userId2) {
rooms.put(room.getRoomId(), room);
userIdToRoomId.put(userId1, room.getRoomId());
userIdToRoomId.put(userId2, room.getRoomId());
}
同时写两张表:房间存进 rooms,两个玩家的映射存进 userIdToRoomId。必须同时成功,否则数据不一致。
10.1.3 remove --- 移除房间
java
public void remove(String roomId, int userId1, int userId2) {
rooms.remove(roomId);
userIdToRoomId.remove(userId1);
userIdToRoomId.remove(userId2);
}
游戏结束后销毁房间,同时清理两张表的数据。
10.1.4 getRoomByUserId --- 通过玩家查房间
java
public Room getRoomByUserId(int userId) {
String roomId = userIdToRoomId.get(userId);
if (roomId == null) {
return null;
}
return rooms.get(roomId);
}
两步查询:先通过 userId 查 roomId,再通过 roomId 查 Room。如果玩家不在任何房间里,roomId 为 null,直接返回 null。
这个方法在 GameAPI 里被频繁调用 ------ 每次玩家发落子消息,都要通过 userId 找到他所在的房间。
11 房间类:棋盘、落子、判胜负全在这里
11.1 Room.java 完整代码
java
package com.example.java_gobang.game;
import com.example.java_gobang.JavaGobangApplication;
import com.example.java_gobang.model.User;
import com.example.java_gobang.model.UserMapper;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.UUID;
public class Room {
private String roomId;
private User user1;
private User user2;
private int whiteUser;
private static final int MAX_ROW = 15;
private static final int MAX_COL = 15;
private int[][] board = new int[MAX_ROW][MAX_COL];
private ObjectMapper objectMapper = new ObjectMapper();
private OnlineUserManager onlineUserManager;
private RoomManager roomManager;
private UserMapper userMapper;
public Room() {
roomId = UUID.randomUUID().toString();
onlineUserManager = JavaGobangApplication.context.getBean(OnlineUserManager.class);
roomManager = JavaGobangApplication.context.getBean(RoomManager.class);
userMapper = JavaGobangApplication.context.getBean(UserMapper.class);
}
public void putChess(String reqJson) throws IOException {
GameRequest request = objectMapper.readValue(reqJson, GameRequest.class);
GameResponse response = new GameResponse();
int chess = request.getUserId() == user1.getUserId() ? 1 : 2;
int row = request.getRow();
int col = request.getCol();
if (board[row][col] != 0) {
System.out.println("当前位置 (" + row + ", " + col + ") 已经有子了!");
return;
}
board[row][col] = chess;
printBoard();
int winner = checkWinner(row, col, chess);
response.setMessage("putChess");
response.setUserId(request.getUserId());
response.setRow(row);
response.setCol(col);
response.setWinner(winner);
WebSocketSession session1 = onlineUserManager.getFromGameRoom(user1.getUserId());
WebSocketSession session2 = onlineUserManager.getFromGameRoom(user2.getUserId());
if (session1 == null) {
response.setWinner(user2.getUserId());
System.out.println("玩家1 掉线!");
}
if (session2 == null) {
response.setWinner(user1.getUserId());
System.out.println("玩家2 掉线!");
}
String respJson = objectMapper.writeValueAsString(response);
if (session1 != null) {
session1.sendMessage(new TextMessage(respJson));
}
if (session2 != null) {
session2.sendMessage(new TextMessage(respJson));
}
if (response.getWinner() != 0) {
System.out.println("游戏结束! 房间即将销毁! roomId=" + roomId + " 获胜方为: " + response.getWinner());
int winUserId = response.getWinner();
int loseUserId = response.getWinner() == user1.getUserId() ? user2.getUserId() : user1.getUserId();
userMapper.userWin(winUserId);
userMapper.userLose(loseUserId);
roomManager.remove(roomId, user1.getUserId(), user2.getUserId());
}
}
private void printBoard() {
System.out.println("[打印棋盘信息] " + roomId);
System.out.println("=====================================================================");
for (int r = 0; r < MAX_ROW; r++) {
for (int c = 0; c < MAX_COL; c++) {
System.out.print(board[r][c] + " ");
}
System.out.println();
}
System.out.println("=====================================================================");
}
private int checkWinner(int row, int col, int chess) {
// 检查行
for (int c = col - 4; c <= col; c++) {
try {
if (board[row][c] == chess
&& board[row][c + 1] == chess
&& board[row][c + 2] == chess
&& board[row][c + 3] == chess
&& board[row][c + 4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 检查列
for (int r = row - 4; r <= row; r++) {
try {
if (board[r][col] == chess
&& board[r + 1][col] == chess
&& board[r + 2][col] == chess
&& board[r + 3][col] == chess
&& board[r + 4][col] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 检查左对角线 (\)
for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {
try {
if (board[r][c] == chess
&& board[r + 1][c + 1] == chess
&& board[r + 2][c + 2] == chess
&& board[r + 3][c + 3] == chess
&& board[r + 4][c + 4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 检查右对角线 (/)
for (int r = row - 4, c = col + 4; r <= row && c >= col; r++, c--) {
try {
if (board[r][c] == chess
&& board[r + 1][c - 1] == chess
&& board[r + 2][c - 2] == chess
&& board[r + 3][c - 3] == chess
&& board[r + 4][c - 4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
return 0;
}
// getter/setter 省略...
public static void main(String[] args) {
Room room = new Room();
System.out.println(room.roomId);
}
}
11.1.1 房间属性详解
java
private String roomId; // 房间唯一标识,UUID生成
private User user1; // 玩家1
private User user2; // 玩家2
private int whiteUser; // 先手方的用户ID
private static final int MAX_ROW = 15; // 棋盘行数
private static final int MAX_COL = 15; // 棋盘列数
private int[][] board = new int[MAX_ROW][MAX_COL]; // 棋盘
roomId:用 UUID 生成的唯一字符串,保证每个房间号都不重复。user1/user2:两个玩家对象。注意,匹配成功创建房间时这两个还是 null,要等玩家跳转页面建立/game连接后才设置。whiteUser:先手方(白棋)的用户 ID。五子棋规则里先手有优势,这里约定先连入房间的玩家为先手。board:15x15 的二维数组,表示棋盘。约定:0= 该位置没有棋子1= 玩家 1 的棋子2= 玩家 2 的棋子
int 数组初始化默认全是 0,所以刚创建的棋盘是空的,不用手动初始化。
11.1.2 构造方法 --- 手动获取 Bean
java
public Room() {
roomId = UUID.randomUUID().toString();
onlineUserManager = JavaGobangApplication.context.getBean(OnlineUserManager.class);
roomManager = JavaGobangApplication.context.getBean(RoomManager.class);
userMapper = JavaGobangApplication.context.getBean(UserMapper.class);
}
Room 是通过 new Room() 创建的,不是 Spring 管理的 Bean,所以不能用 @Autowired 注入依赖。解决方案就是前面提到的 ------ 通过启动类存的静态 context 手动 getBean。
UUID.randomUUID().toString() 生成一个类似 a1b2c3d4-e5f6-7890-abcd-ef1234567890 的唯一字符串,作为房间号。
11.1.3 putChess --- 落子处理(核心中的核心)
这是每次玩家点击棋盘后调用的方法,也是整个游戏最核心的逻辑。
第一步:解析请求
java
GameRequest request = objectMapper.readValue(reqJson, GameRequest.class);
把前端发来的 JSON 字符串解析成 GameRequest 对象。GameRequest 包含 message、userId、row、col 四个字段。
第二步:判断是哪个玩家落的子
java
int chess = request.getUserId() == user1.getUserId() ? 1 : 2;
三元运算符:如果落子的是玩家 1,棋盘上记为 1;否则记为 2。
第三步:检查位置是否已有子
java
if (board[row][col] != 0) {
System.out.println("当前位置 (" + row + ", " + col + ") 已经有子了!");
return;
}
前端应该已经做过重复落子的校验了,但服务端再校验一次更安全。防止前端 bug 或恶意请求导致同一位置重复落子。
第四步:记录落子
java
board[row][col] = chess;
在棋盘数组对应位置写入 1 或 2。
第五步:打印棋盘
java
printBoard();
在控制台打印整个棋盘矩阵,方便开发者观察局势和调试。
第六步:胜负判定
java
int winner = checkWinner(row, col, chess);
调用 checkWinner 方法,传入刚落子的位置和棋子类型,判断是否分出胜负。返回获胜者的 userId,未分胜负返回 0。
第七步:构造响应
java
response.setMessage("putChess");
response.setUserId(request.getUserId());
response.setRow(row);
response.setCol(col);
response.setWinner(winner);
响应包含:消息类型、谁落的子、落在哪、是否分出胜负。两个玩家都会收到这个响应,前端根据这些信息更新棋盘显示。
第八步:检查玩家是否掉线
java
WebSocketSession session1 = onlineUserManager.getFromGameRoom(user1.getUserId());
WebSocketSession session2 = onlineUserManager.getFromGameRoom(user2.getUserId());
if (session1 == null) {
response.setWinner(user2.getUserId());
System.out.println("玩家1 掉线!");
}
if (session2 == null) {
response.setWinner(user1.getUserId());
System.out.println("玩家2 掉线!");
}
在发消息之前检查两个玩家的连接状态。如果有人掉线了,直接判对方赢。这是 "掉线即认输" 的规则。
注意两个 if 是独立的(不是 else if),理论上两个人可能同时掉线(虽然概率极低),这种情况下后一个 if 会覆盖前一个的 winner 设置。
第九步:广播响应
java
String respJson = objectMapper.writeValueAsString(response);
if (session1 != null) {
session1.sendMessage(new TextMessage(respJson));
}
if (session2 != null) {
session2.sendMessage(new TextMessage(respJson));
}
把响应同时发给两个玩家。发之前检查 session 是否为 null,避免空指针。两个玩家收到完全相同的响应,各自根据 userId 判断是 "我下的" 还是 "对手下的"。
第十步:赛后结算
java
if (response.getWinner() != 0) {
int winUserId = response.getWinner();
int loseUserId = response.getWinner() == user1.getUserId() ? user2.getUserId() : user1.getUserId();
userMapper.userWin(winUserId);
userMapper.userLose(loseUserId);
roomManager.remove(roomId, user1.getUserId(), user2.getUserId());
}
如果分出胜负(winner != 0):
- 确定赢家和输家的 userId。
- 赢家调用
userWin(总场数 + 1,胜场 + 1,分数 + 30)。 - 输家调用
userLose(总场数 + 1,分数 - 30)。 - 调用
roomManager.remove销毁房间,清理内存。
游戏结束,房间使命完成。
11.1.4 printBoard --- 打印棋盘
java
private void printBoard() {
System.out.println("[打印棋盘信息] " + roomId);
System.out.println("=====================================================================");
for (int r = 0; r < MAX_ROW; r++) {
for (int c = 0; c < MAX_COL; c++) {
System.out.print(board[r][c] + " ");
}
System.out.println();
}
System.out.println("=====================================================================");
}
双重循环遍历 15x15 棋盘,每行打印 15 个数字(0/1/2),行末换行。用分隔线包裹,方便在控制台查看。这是一个调试辅助方法,生产环境可以去掉。
11.1.5 checkWinner --- 胜负判定算法
五子棋的胜负规则:横、竖、左斜(\)、右斜(/)四个方向,任意一个方向有连续五个同色棋子即获胜。
核心思路:以刚落子的位置为中心,在四个方向上检查是否存在五连。因为只有刚落的子才可能形成新的五连,所以只需要检查经过这个点的线。
检查行(横向五连):
java
for (int c = col - 4; c <= col; c++) {
try {
if (board[row][c] == chess
&& board[row][c + 1] == chess
&& board[row][c + 2] == chess
&& board[row][c + 3] == chess
&& board[row][c + 4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
以落子列 col 为基准,从 col-4 到 col 依次尝试作为五连的起点。比如落子在第 7 列,就检查:
- 第 3~7 列是不是五连
- 第 4~8 列是不是五连
- 第 5~9 列是不是五连
- 第 6~10 列是不是五连
- 第 7~11 列是不是五连
共五种可能的五连组合,只要有一种成立就获胜。
try-catch 处理数组越界:当 c-4 小于 0 或 c+4 大于等于 15 时,访问数组会抛 ArrayIndexOutOfBoundsException,catch 住后 continue 跳过这个组合。这是一种简单粗暴但有效的边界处理方式,不用写复杂的下标判断。
检查列(纵向五连) :和行类似,只是把行和列互换,从 row-4 到 row 遍历。
检查左对角线(\ 方向):
java
for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {
行和列同时递增(从左上到右下),检查五种组合。
检查右对角线(/ 方向):
java
for (int r = row - 4, c = col + 4; r <= row && c >= col; r++, c--) {
行递增、列递减(从右上到左下),检查五种组合。
四个方向都没五连,返回 0:表示胜负未分,游戏继续。
11.1.6 main 方法
java
public static void main(String[] args) {
Room room = new Room();
System.out.println(room.roomId);
}
这是一个测试用的 main 方法,用来验证 UUID 生成和构造方法是否正常。实际运行时不会用到(Spring Boot 项目从 JavaGobangApplication.main 启动)。
12 游戏相关的数据结构
12.1 GameRequest.java --- 落子请求
java
package com.example.java_gobang.game;
import lombok.Data;
@Data
public class GameRequest {
private String message;
private int userId;
private int row;
private int col;
}
前端点击棋盘后发给服务器的请求:
| 字段 | 类型 | 含义 |
|---|---|---|
| message | String | 消息类型,这里是 "putChess" |
| userId | int | 谁落的子 |
| row | int | 落在哪一行(0~14) |
| col | int | 落在哪一列(0~14) |
用了 @Data,自动生成 getter/setter。
12.2 GameResponse.java --- 落子响应
java
package com.example.java_gobang.game;
import lombok.Data;
@Data
public class GameResponse {
private String message;
private int userId;
private int row;
private int col;
private int winner;
}
服务器广播给双方的落子结果:
| 字段 | 类型 | 含义 |
|---|---|---|
| message | String | 消息类型,"putChess" |
| userId | int | 谁落的子 |
| row | int | 落在哪一行 |
| col | int | 落在哪一列 |
| winner | int | 获胜者 ID,0 表示未分胜负 |
前端收到后,在对应位置画棋子,如果 winner != 0 就显示胜负界面。
12.3 GameReadyResponse.java --- 游戏就绪响应
java
package com.example.java_gobang.game;
import lombok.Data;
@Data
public class GameReadyResponse {
private String message;
private boolean ok;
private String reason;
private String roomId;
private int thisUserId;
private int thatUserId;
private int whiteUser;
}
两个玩家都进入房间后,服务器发给双方的 "游戏开始" 通知:
| 字段 | 类型 | 含义 |
|---|---|---|
| message | String | 消息类型,"gameReady" 或 "repeatConnection" |
| ok | boolean | 是否成功 |
| reason | String | 失败原因 |
| roomId | String | 房间号 |
| thisUserId | int | 接收方自己的 ID |
| thatUserId | int | 对手的 ID |
| whiteUser | int | 先手方的 ID |
前端拿到这些信息后:
- 显示自己和对手的用户名 / 分数
- 根据
whiteUser判断谁先下(自己是不是先手) - 渲染空棋盘,开始游戏
13 游戏 API:房间里的 WebSocket 处理器
13.1 GameAPI.java 完整代码
java
package com.example.java_gobang.api;
import com.example.java_gobang.game.*;
import com.example.java_gobang.model.User;
import com.example.java_gobang.model.UserMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import tools.jackson.databind.ObjectMapper;
import java.io.IOException;
@Component
public class GameAPI extends TextWebSocketHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private RoomManager roomManager;
@Autowired
private OnlineUserManager onlineUserManager;
@Resource
private UserMapper userMapper;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
GameReadyResponse resp = new GameReadyResponse();
User user = (User) session.getAttributes().get("user");
if (user == null) {
resp.setOk(false);
resp.setReason("用户尚未登录!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
Room room = roomManager.getRoomByUserId(user.getUserId());
if (room == null) {
resp.setOk(false);
resp.setReason("用户尚未匹配到!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
if (onlineUserManager.getFromGameHall(user.getUserId()) != null
|| onlineUserManager.getFromGameRoom(user.getUserId()) != null) {
resp.setOk(true);
resp.setReason("禁止多开游戏页面");
resp.setMessage("repeatConnection");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
onlineUserManager.enterGameRoom(user.getUserId(), session);
synchronized (room) {
if (room.getUser1() == null) {
room.setUser1(user);
room.setWhiteUser(user.getUserId());
System.out.println("玩家 " + user.getUsername() + " 已经准备就绪! 作为玩家1");
return;
}
if (room.getUser2() == null) {
room.setUser2(user);
System.out.println("玩家 " + user.getUsername() + " 已经准备就绪! 作为玩家2");
noticeGameReady(room, room.getUser1(), room.getUser2());
noticeGameReady(room, room.getUser2(), room.getUser1());
return;
}
}
resp.setOk(false);
resp.setReason("当前房间已满, 您不能加入房间");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}
private void noticeGameReady(Room room, User thisUser, User thatUser) throws IOException {
GameReadyResponse resp = new GameReadyResponse();
resp.setMessage("gameReady");
resp.setOk(true);
resp.setReason("");
resp.setRoomId(room.getRoomId());
resp.setThisUserId(thisUser.getUserId());
resp.setThatUserId(thatUser.getUserId());
resp.setWhiteUser(room.getWhiteUser());
WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(thisUser.getUserId());
webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
User user = (User) session.getAttributes().get("user");
if (user == null) {
System.out.println("[handleTextMessage] 当前玩家尚未登录! ");
return;
}
Room room = roomManager.getRoomByUserId(user.getUserId());
room.putChess(message.getPayload());
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
User user = (User) session.getAttributes().get("user");
if (user == null) {
return;
}
WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
if (session == exitSession) {
onlineUserManager.exitGameRoom(user.getUserId());
}
System.out.println("当前用户 " + user.getUsername() + " 游戏房间连接异常!");
noticeThatUserWin(user);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
User user = (User) session.getAttributes().get("user");
if (user == null) {
return;
}
WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
if (session == exitSession) {
onlineUserManager.exitGameRoom(user.getUserId());
}
System.out.println("当前用户 " + user.getUsername() + " 离开游戏房间!");
noticeThatUserWin(user);
}
private void noticeThatUserWin(User user) throws IOException {
Room room = roomManager.getRoomByUserId(user.getUserId());
if (room == null) {
System.out.println("当前房间已经释放, 无需通知对手!");
return;
}
User thatUser = (user == room.getUser1()) ? room.getUser2() : room.getUser1();
WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(thatUser.getUserId());
if (webSocketSession == null) {
System.out.println("对手也已经掉线了, 无需通知!");
return;
}
GameResponse resp = new GameResponse();
resp.setMessage("putChess");
resp.setUserId(thatUser.getUserId());
resp.setWinner(thatUser.getUserId());
webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
int winUserId = thatUser.getUserId();
int loseUserId = user.getUserId();
userMapper.userWin(winUserId);
userMapper.userLose(loseUserId);
roomManager.remove(room.getRoomId(), room.getUser1().getUserId(), room.getUser2().getUserId());
}
}
13.1.1 依赖注入
RoomManager:查房间、销毁房间。OnlineUserManager:管理游戏房间的在线状态。UserMapper:掉线判负时更新分数。
注意 UserMapper 用的是 @Resource,其他用的是 @Autowired。两者功能类似,都能注入依赖,只是来源不同(@Resource 是 Java 标准注解,@Autowired 是 Spring 的)。
13.2 afterConnectionEstablished --- 玩家进入游戏房间
玩家从大厅匹配成功后,前端跳转到 game_room.html,页面加载时建立 /game 的 WebSocket 连接。这个方法就是连接建立后调用的。
整个方法做了六道关卡的检查,一步步来:
13.2.1 第一关:用户是否登录
java
User user = (User) session.getAttributes().get("user");
if (user == null) {
resp.setOk(false);
resp.setReason("用户尚未登录!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
从 WebSocketSession 拿 User 对象。拿不到说明没登录,返回错误。
13.2.2 第二关:用户是否匹配到了房间
java
Room room = roomManager.getRoomByUserId(user.getUserId());
if (room == null) {
resp.setOk(false);
resp.setReason("用户尚未匹配到!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
通过 userId 查房间。如果房间不存在,说明这个玩家还没匹配到对手(可能直接访问了游戏房间页面而不是通过匹配进来的),返回错误。
13.2.3 第三关:是否多开
java
if (onlineUserManager.getFromGameHall(user.getUserId()) != null
|| onlineUserManager.getFromGameRoom(user.getUserId()) != null) {
resp.setOk(true);
resp.setReason("禁止多开游戏页面");
resp.setMessage("repeatConnection");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
检查大厅和房间任意一个是否已有这个用户的连接。有就说明多开了,返回 repeatConnection。
这里有个细节:玩家从大厅跳转房间时,大厅的 WebSocket 连接会断开,gameHall 里的记录会被清除。所以正常情况下进入房间时 gameHall 里应该已经没有记录了。如果还有,说明可能是多开。
13.2.4 第四关:登记在线状态
java
onlineUserManager.enterGameRoom(user.getUserId(), session);
前三关都过了,把玩家登记到 gameRoom 里,标记为 "在游戏房间在线"。
13.2.5 第五关:加入房间(关键!)
java
synchronized (room) {
if (room.getUser1() == null) {
room.setUser1(user);
room.setWhiteUser(user.getUserId());
System.out.println("玩家 " + user.getUsername() + " 已经准备就绪! 作为玩家1");
return;
}
if (room.getUser2() == null) {
room.setUser2(user);
System.out.println("玩家 " + user.getUsername() + " 已经准备就绪! 作为玩家2");
noticeGameReady(room, room.getUser1(), room.getUser2());
noticeGameReady(room, room.getUser2(), room.getUser1());
return;
}
}
synchronized (room) 对房间对象加锁,保证两个玩家不会同时设置 user1/user2 导致竞争。
情况一:user1 为空 当前玩家是第一个连入房间的,设为玩家 1,同时设为先手方(whiteUser)。然后直接 return------ 因为只有一个人,游戏还不能开始,等另一个人连进来。
情况二:user1 已有,user2 为空 当前玩家是第二个连入的,设为玩家 2。此时两个人都到齐了,调用 noticeGameReady 分别给两个玩家发 "游戏准备就绪" 的通知。
注意 noticeGameReady 调用了两次,参数不同:
- 第一次:给玩家 1 发,thisUser = 玩家 1,thatUser = 玩家 2
- 第二次:给玩家 2 发,thisUser = 玩家 2,thatUser = 玩家 1
这样每个玩家收到的响应里,thisUserId 是自己,thatUserId 是对手。
13.2.6 第六关:房间已满
java
resp.setOk(false);
resp.setReason("当前房间已满, 您不能加入房间");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
如果 user1 和 user2 都不为空,说明房间已经有两个人了。理论上不会有第三个人连进来(一个房间只匹配两个人),但还是做了兜底。
13.3 noticeGameReady --- 通知游戏开始
java
private void noticeGameReady(Room room, User thisUser, User thatUser) throws IOException {
GameReadyResponse resp = new GameReadyResponse();
resp.setMessage("gameReady");
resp.setOk(true);
resp.setReason("");
resp.setRoomId(room.getRoomId());
resp.setThisUserId(thisUser.getUserId());
resp.setThatUserId(thatUser.getUserId());
resp.setWhiteUser(room.getWhiteUser());
WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(thisUser.getUserId());
webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}
构造一个 GameReadyResponse,包含房间号、自己的 ID、对手的 ID、谁是先手。然后通过 thisUser 的 WebSocketSession 发出去。
前端收到 gameReady 消息后,就知道双方都准备好了,可以开始下棋了。
13.4 handleTextMessage --- 处理落子消息
java
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
User user = (User) session.getAttributes().get("user");
if (user == null) {
System.out.println("[handleTextMessage] 当前玩家尚未登录! ");
return;
}
Room room = roomManager.getRoomByUserId(user.getUserId());
room.putChess(message.getPayload());
}
这个方法非常简洁,只有三步:
- 拿用户身份(没登录就忽略)。
- 通过 userId 找到所在房间。
- 把原始 JSON 消息直接丢给
room.putChess()处理。
具体的落子逻辑、胜负判定、广播消息全在 Room 类里,GameAPI 只做转发。这是职责分离的设计 ------API 层负责通信,Room 负责游戏逻辑。
13.5 handleTransportError 和 afterConnectionClosed --- 玩家掉线 / 离开
两个方法逻辑相同:连接异常或连接关闭时触发。
java
User user = (User) session.getAttributes().get("user");
if (user == null) {
return;
}
WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
if (session == exitSession) {
onlineUserManager.exitGameRoom(user.getUserId());
}
System.out.println("当前用户 " + user.getUsername() + " 离开游戏房间!");
noticeThatUserWin(user);
- 拿用户身份,没登录直接返回。
- 检查当前断开的 session 是不是登记的那个 session(防多开误删),是的话从
gameRoom移除。 - 调用
noticeThatUserWin(user)------对手直接获胜。
13.6 noticeThatUserWin --- 掉线判负
java
private void noticeThatUserWin(User user) throws IOException {
// 1. 找房间
Room room = roomManager.getRoomByUserId(user.getUserId());
if (room == null) {
System.out.println("当前房间已经释放, 无需通知对手!");
return;
}
// 2. 找对手
User thatUser = (user == room.getUser1()) ? room.getUser2() : room.getUser1();
// 3. 找对手的连接
WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(thatUser.getUserId());
if (webSocketSession == null) {
System.out.println("对手也已经掉线了, 无需通知!");
return;
}
// 4. 通知对手获胜
GameResponse resp = new GameResponse();
resp.setMessage("putChess");
resp.setUserId(thatUser.getUserId());
resp.setWinner(thatUser.getUserId());
webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
// 5. 更新分数
int winUserId = thatUser.getUserId();
int loseUserId = user.getUserId();
userMapper.userWin(winUserId);
userMapper.userLose(loseUserId);
// 6. 销毁房间
roomManager.remove(room.getRoomId(), room.getUser1().getUserId(), room.getUser2().getUserId());
}
逐步拆解:
第一步:找房间 通过掉线玩家的 userId 查房间。如果房间已经被销毁了(比如游戏已经正常结束),就不用处理了。
第二步:找对手
java
User thatUser = (user == room.getUser1()) ? room.getUser2() : room.getUser1();
如果掉线的是玩家 1,对手就是玩家 2;反之亦然。这里用 == 比较引用(因为是同一个对象),没问题。
第三步:找对手的连接 如果对手也掉线了(两人同时断网),那没人需要通知,直接返回。
第四步:通知对手获胜 构造一个 GameResponse,winner 设为对手的 userId,发给对手。前端收到后显示 "你赢了!对手已断开连接"。
注意这里 message 设为 "putChess",和正常落子响应用同一个消息类型。前端通过 winner != 0 判断游戏结束,不关心是不是正常落子导致的。
第五步:更新分数 赢家 userWin(+30 分),输家 userLose(-30 分)。
第六步:销毁房间 调用 roomManager.remove 清理房间数据。
14 全流程串联:从登录到打完一局
现在把所有模块串起来,走一遍完整流程,你就彻底理解了。
14.1 阶段一:登录
- 用户在登录页输入用户名 "张三"、密码 "123",点击登录。
- 前端发 POST 请求到
/login,参数 username = 张三,password=123。 UserAPI.login调用userMapper.selectByName("张三"),从数据库查到张三的信息。- 密码匹配成功,把 User 对象存入 HttpSession(key="user")。
- 返回张三的用户信息(JSON),前端跳转到游戏大厅页面。
14.2 阶段二:进入大厅
- 大厅页面加载,建立 WebSocket 连接
ws://服务器/findMatch。 - 握手时
HttpSessionHandshakeInterceptor把 HttpSession 里的 user 拷贝到 WebSocketSession。 MatchAPI.afterConnectionEstablished被调用:- 从 WebSocketSession 拿到 user(张三)。
- 检查多开(没有)。
onlineUserManager.enterGameHall(张三的userId, session),登记到 gameHall。
- 张三点击 "开始匹配" 按钮。
- 前端发 JSON
{"message":"startMatch"}。 MatchAPI.handleTextMessage解析后调用matcher.add(张三)。- 张三分数 1000 < 2000,进入
normalQueue,同时notify唤醒匹配线程。
14.3 阶段三:匹配
- 匹配线程 t1 被唤醒,检查 normalQueue 大小。
- 如果只有张三一个人,继续 wait。
- 李四也登录、进大厅、点匹配,进入 normalQueue,再次 notify。
- t1 被唤醒,队列里有两个人了,取出张三和李四。
- 检查两人都在线(都在 gameHall 里)。
new Room()创建房间,roomManager.add把两人和房间关联。- 给张三和李四都发
{"message":"matchSuccess"}。 - 前端收到后跳转到游戏房间页面。
14.4 阶段四:进入房间
- 张三的浏览器先加载完,建立 WebSocket 连接
ws://服务器/game。 GameAPI.afterConnectionEstablished:- 拿到 user(张三)。
- 查到房间。
- 检查多开。
- 登记到 gameRoom。
synchronized(room)里,user1 为空,设张三为玩家 1 和先手方。return,等李四。
- 李四的浏览器加载完,也建立
/game连接。 - 同样的流程,到
synchronized(room)时,user1 已有,设李四为玩家 2。 - 两人到齐,调用
noticeGameReady给双方发gameReady消息。 - 前端收到,渲染棋盘,显示对手信息,游戏开始。张三是先手(白棋)。
14.5 阶段五:对战
- 张三点击棋盘 (7,7) 位置。
- 前端发 JSON
{"message":"putChess","userId":张三ID,"row":7,"col":7}。 GameAPI.handleTextMessage拿到消息,找到房间,调用room.putChess(json)。putChess:- 解析请求,chess=1(张三是玩家 1)。
- 检查 (7,7) 为空,写入 board 77=1。
- 打印棋盘。
- checkWinner 检查四个方向,没有五连,返回 0。
- 构造响应,winner=0。
- 检查两人都在线。
- 把响应同时发给张三和李四。
- 双方前端都在 (7,7) 位置画一个黑子(玩家 1 的子)。
- 李四点击 (7,8),类似流程,board 78=2,双方更新。
- 轮流落子......
14.6 阶段六:胜负
- 张三在某一步落子后,横向形成五连。
checkWinner检测到五连,返回张三的 userId。putChess构造响应,winner = 张三 ID。- 广播给双方,前端显示 "张三获胜!"。
winner != 0,进入赛后结算:userMapper.userWin(张三ID):张三总场数 + 1,胜场 + 1,分数 + 30(1000→1030)。userMapper.userLose(李四ID):李四总场数 + 1,分数 - 30(1000→970)。roomManager.remove销毁房间。
- 游戏结束,玩家可以回到大厅继续匹配。
14.7 特殊情况:中途掉线
- 张三在游戏中关闭了浏览器。
/game的 WebSocket 连接断开,GameAPI.afterConnectionClosed被调用。- 从 gameRoom 移除张三。
- 调用
noticeThatUserWin(张三):- 找到房间,找到对手李四。
- 李四还在线,给李四发 winner = 李四 ID 的响应。
- 李四前端显示 "你赢了!对手已断开连接"。
- 更新分数:李四 + 30,张三 - 30。
- 销毁房间。
- 游戏以李四获胜结束。
15 设计亮点与技术要点总结
15.1 HTTP + WebSocket 双协议协作
- HTTP 负责登录、注册、查询用户信息(一次性请求 - 响应)。
- WebSocket 负责匹配、落子、实时通知(长连接双向通信)。
- 两种协议各取所长,配合默契。
15.2 Session 传递身份
登录时把 User 存入 HttpSession,HttpSessionHandshakeInterceptor 在 WebSocket 握手时把 Session 属性拷贝过去,实现了跨协议的身份传递。不用 token,不用额外参数,简洁优雅。
15.3 三档匹配 + wait/notify
按分数分三个队列,每个队列一个独立线程,用 wait/notify 实现生产者 - 消费者模式。队列没人时线程休眠不占 CPU,有人入队时立即唤醒匹配。
15.4 内存管理在线状态
在线用户、匹配队列、房间都存在内存中(ConcurrentHashMap / Queue),不存数据库。因为这些是临时状态,服务器重启就清零没关系,而且内存操作速度快,适合实时性要求高的场景。用户信息和分数才需要持久化到数据库。
15.5 多开防护
在大厅和房间两个入口都做了多开检测,配合 session == exitSession 的精确移除判断,防止一个账号多处登录导致数据混乱。
15.6 掉线即认输
WebSocket 断开连接时自动判对手获胜,并更新分数、销毁房间。保证了游戏总有结果,不会出现 "人走了房间还挂着" 的资源泄漏。
15.7 手动获取 Bean 的技巧
通过启动类保存静态 ApplicationContext,让非 Spring 管理的对象(Room)也能拿到 Spring Bean。这是 Spring 项目中常见的变通技巧。
到这里,这个五子棋项目的后端的每一个文件、每一个方法、每一行关键代码都讲完了。从数据库设计到登录注册,从匹配队列到房间对战,从 WebSocket 配置到胜负判定,整个系统的脉络已经清清楚楚。这是一个非常经典的 Spring Boot + WebSocket 实时对战项目,适合作为学习 WebSocket 和游戏后端开发的入门案例。