从零实现“在线五子棋对战“:WebSocket 实时通信 + 段位匹配

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 对象非常重要,它会被:

  1. 从数据库查出来(登录时)
  2. 存进 HttpSession(作为登录凭证)
  3. 通过拦截器拷贝到 WebSocketSession(匹配和游戏时识别身份)
  4. 放进匹配队列(Matcher 里)
  5. 放进房间(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 玩家输了,更新分数和战绩

注意 userWinuserLose 只传 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 的启动入口,它会:

  1. 创建 Spring 容器
  2. 扫描并注册所有 Bean
  3. 启动内嵌的 Tomcat 服务器
  4. 返回一个 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 注册流程

  1. 创建一个空的 User 对象。
  2. 设置用户名和密码(分数和战绩不用设,SQL 里写死了 1000/0/0)。
  3. 调用 userMapper.insert(user) 插入数据库。
  4. 插入成功,返回 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 连接。所以需要分表管理。

而且,分表还能实现多开检测 ------ 只要 gameHallgameRoom 任意一个表里有这个 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):把玩家加入队列尾部。offeradd 的区别是 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):

  1. 确定赢家和输家的 userId。
  2. 赢家调用 userWin(总场数 + 1,胜场 + 1,分数 + 30)。
  3. 输家调用 userLose(总场数 + 1,分数 - 30)。
  4. 调用 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-4col 依次尝试作为五连的起点。比如落子在第 7 列,就检查:

  • 第 3~7 列是不是五连
  • 第 4~8 列是不是五连
  • 第 5~9 列是不是五连
  • 第 6~10 列是不是五连
  • 第 7~11 列是不是五连

共五种可能的五连组合,只要有一种成立就获胜。

try-catch 处理数组越界:当 c-4 小于 0 或 c+4 大于等于 15 时,访问数组会抛 ArrayIndexOutOfBoundsException,catch 住后 continue 跳过这个组合。这是一种简单粗暴但有效的边界处理方式,不用写复杂的下标判断。

检查列(纵向五连) :和行类似,只是把行和列互换,从 row-4row 遍历。

检查左对角线(\ 方向)

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());
}

这个方法非常简洁,只有三步:

  1. 拿用户身份(没登录就忽略)。
  2. 通过 userId 找到所在房间。
  3. 把原始 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);
  1. 拿用户身份,没登录直接返回。
  2. 检查当前断开的 session 是不是登记的那个 session(防多开误删),是的话从 gameRoom 移除。
  3. 调用 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;反之亦然。这里用 == 比较引用(因为是同一个对象),没问题。

第三步:找对手的连接 如果对手也掉线了(两人同时断网),那没人需要通知,直接返回。

第四步:通知对手获胜 构造一个 GameResponsewinner 设为对手的 userId,发给对手。前端收到后显示 "你赢了!对手已断开连接"。

注意这里 message 设为 "putChess",和正常落子响应用同一个消息类型。前端通过 winner != 0 判断游戏结束,不关心是不是正常落子导致的。

第五步:更新分数 赢家 userWin(+30 分),输家 userLose(-30 分)。

第六步:销毁房间 调用 roomManager.remove 清理房间数据。


14 全流程串联:从登录到打完一局

现在把所有模块串起来,走一遍完整流程,你就彻底理解了。

14.1 阶段一:登录

  1. 用户在登录页输入用户名 "张三"、密码 "123",点击登录。
  2. 前端发 POST 请求到 /login,参数 username = 张三,password=123。
  3. UserAPI.login 调用 userMapper.selectByName("张三"),从数据库查到张三的信息。
  4. 密码匹配成功,把 User 对象存入 HttpSession(key="user")。
  5. 返回张三的用户信息(JSON),前端跳转到游戏大厅页面。

14.2 阶段二:进入大厅

  1. 大厅页面加载,建立 WebSocket 连接 ws://服务器/findMatch
  2. 握手时 HttpSessionHandshakeInterceptor 把 HttpSession 里的 user 拷贝到 WebSocketSession。
  3. MatchAPI.afterConnectionEstablished 被调用:
    • 从 WebSocketSession 拿到 user(张三)。
    • 检查多开(没有)。
    • onlineUserManager.enterGameHall(张三的userId, session),登记到 gameHall。
  4. 张三点击 "开始匹配" 按钮。
  5. 前端发 JSON {"message":"startMatch"}
  6. MatchAPI.handleTextMessage 解析后调用 matcher.add(张三)
  7. 张三分数 1000 < 2000,进入 normalQueue,同时 notify 唤醒匹配线程。

14.3 阶段三:匹配

  1. 匹配线程 t1 被唤醒,检查 normalQueue 大小。
  2. 如果只有张三一个人,继续 wait。
  3. 李四也登录、进大厅、点匹配,进入 normalQueue,再次 notify。
  4. t1 被唤醒,队列里有两个人了,取出张三和李四。
  5. 检查两人都在线(都在 gameHall 里)。
  6. new Room() 创建房间,roomManager.add 把两人和房间关联。
  7. 给张三和李四都发 {"message":"matchSuccess"}
  8. 前端收到后跳转到游戏房间页面。

14.4 阶段四:进入房间

  1. 张三的浏览器先加载完,建立 WebSocket 连接 ws://服务器/game
  2. GameAPI.afterConnectionEstablished
    • 拿到 user(张三)。
    • 查到房间。
    • 检查多开。
    • 登记到 gameRoom。
    • synchronized(room) 里,user1 为空,设张三为玩家 1 和先手方。return,等李四。
  3. 李四的浏览器加载完,也建立 /game 连接。
  4. 同样的流程,到 synchronized(room) 时,user1 已有,设李四为玩家 2。
  5. 两人到齐,调用 noticeGameReady 给双方发 gameReady 消息。
  6. 前端收到,渲染棋盘,显示对手信息,游戏开始。张三是先手(白棋)。

14.5 阶段五:对战

  1. 张三点击棋盘 (7,7) 位置。
  2. 前端发 JSON {"message":"putChess","userId":张三ID,"row":7,"col":7}
  3. GameAPI.handleTextMessage 拿到消息,找到房间,调用 room.putChess(json)
  4. putChess
    • 解析请求,chess=1(张三是玩家 1)。
    • 检查 (7,7) 为空,写入 board 77=1。
    • 打印棋盘。
    • checkWinner 检查四个方向,没有五连,返回 0。
    • 构造响应,winner=0。
    • 检查两人都在线。
    • 把响应同时发给张三和李四。
  5. 双方前端都在 (7,7) 位置画一个黑子(玩家 1 的子)。
  6. 李四点击 (7,8),类似流程,board 78=2,双方更新。
  7. 轮流落子......

14.6 阶段六:胜负

  1. 张三在某一步落子后,横向形成五连。
  2. checkWinner 检测到五连,返回张三的 userId。
  3. putChess 构造响应,winner = 张三 ID。
  4. 广播给双方,前端显示 "张三获胜!"。
  5. winner != 0,进入赛后结算:
    • userMapper.userWin(张三ID):张三总场数 + 1,胜场 + 1,分数 + 30(1000→1030)。
    • userMapper.userLose(李四ID):李四总场数 + 1,分数 - 30(1000→970)。
    • roomManager.remove 销毁房间。
  6. 游戏结束,玩家可以回到大厅继续匹配。

14.7 特殊情况:中途掉线

  1. 张三在游戏中关闭了浏览器。
  2. /game 的 WebSocket 连接断开,GameAPI.afterConnectionClosed 被调用。
  3. 从 gameRoom 移除张三。
  4. 调用 noticeThatUserWin(张三)
    • 找到房间,找到对手李四。
    • 李四还在线,给李四发 winner = 李四 ID 的响应。
    • 李四前端显示 "你赢了!对手已断开连接"。
    • 更新分数:李四 + 30,张三 - 30。
    • 销毁房间。
  5. 游戏以李四获胜结束。

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 和游戏后端开发的入门案例。

相关推荐
2601_962203517 小时前
【SpringAI入门】初识SpringAI
java
weixin_461408587 小时前
Mybatis-flex小记
java·开发语言·mybatis
2601_962177138 小时前
【MySQL篇】聚合查询,联合查询
android·java·mysql
それども8 小时前
IDEA接入Claude完整流程
java·ai·intellij-idea
小鹿的周先生8 小时前
Spring-AI-第2篇-ChatClient 实战:使用 DeepSeek 完成第一次 AI 对话
java·人工智能·spring
青梅味猪大肠9 小时前
【深入浅出C++】为什么虚表指针可以解决菱形继承
开发语言·c++
潘潘的嵌入式日记9 小时前
I²C 从机接收总被覆盖?双缓冲要在 STOP 时交接
c语言·开发语言·单片机
悲且狂9 小时前
SpringBoot项目改造注意事项(旧项目框架复用)
java·spring boot·后端
上海云盾-小余9 小时前
流量攻击复盘:为什么 WAF 完好,业务依旧瘫痪
运维·服务器·网络