文章目录
- 一、核心功能及技术
- 二、效果演示
- 三、创建项目
- 扩展:WebSocket 框架知识
 
- 四、需求分析和概要设计
- 五、数据库设计与配置 Mybatis
- 六、实现用户模块功能
- 6.1 数据库代码编写
- 6.2 前后端交互接口
- 6.3 服务器开发
- 6.4 客户端开发
 
- 七、实现匹配模块功能
- 7.1 前后端交互接口
- 7.2 客户端开发
- 7.3 服务器开发
 
- 八、实现对战模块功能
一、核心功能及技术
核心功能:
- 注册,登录,退出
- 大厅记录用户相关信息,如天梯分数
- 将分数相差不大的同水平选手进行匹配
- 大厅开始匹配,成功后两名玩家进入同一游戏房间,可随时取消匹配
- 多玩家可同时在线,两两玩家随机对弈
- 实时记录玩家游戏信息,管理游戏场数,分数等
- 无论哪一方玩家对弈时退出或掉线,另一方自动获胜
核心技术:
- Spring/SpringBoot/SpringMVC
- WebSocket
- MySQL
- MyBatis
- HTML/CSS/JS/AJAX(canvas API)
注意: WebSocket 和 canvas API 是实现本项目的两个核心技术,前者我们后续会稍许讲解,后者涉及到前端知识,我们就不多赘述,大家可参考以下链接去了解更多关于 canvas 的知识点!https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API
二、效果演示
-  登录 
  
-  注册 
  
-  游戏大厅 
  
-  游戏房间,及分出胜负后的效果 
  
三、创建项目
创建一个springboot项目,具体步骤与前面的博客记录一样,这里就不再重复赘述,大家自行参考以下博客:
1.springboot项目的基本创建
2.添加mybatis框架支持
注意: 除开以前的老几样框架,本次项目我们还引入了一个新的知识点 WebSocket 如下:

扩展:WebSocket 框架知识
参考另一篇博客 https://editor.csdn.net/md/?articleId=130700875
四、需求分析和概要设计
整个项目分成以下模块:
- 用户模块
- 匹配模块
- 对战模块
用户模块
-  用户模块主要负责用户的注册, 登录,退出,分数记录功能. 
-  使用 MySQL 数据库存储用户数据. 
-  客户端提供一个登录页面+注册页面. 
-  服务器端基于 Spring + MyBatis 来实现数据库的增删改查. 
匹配模块
-  注册后,用户登录成功, 则进入游戏大厅页面. 
-  游戏大厅中, 能够显示用户的名字, 天梯分数, 比赛场数和获胜场数. 
-  同时显示一个 “匹配按钮”. 
-  点击匹配按钮则用户进入匹配队列, 并且界面上开始匹配按钮显示为 “取消匹配” . 
-  再次点击取消匹配“”则把用户从匹配队列中删除. 
-  如果匹配成功, 则跳转进入到游戏房间页面. 
-  游戏大厅页面加载时和服务器建立 websocket 连接. 双方通过 websocket 来传输 “开始匹配”, “取消匹配”, “匹配成功” 这样的信息. 
对战模块
-  玩家匹配成功, 则进入游戏房间页面. 
-  每两个玩家在同一个游戏房间中. 
-  在游戏房间页面中, 能够显示五子棋棋盘. 玩家点击棋盘上的位置实现落子功能. 
-  并且五子连珠则触发胜负判定, 显示 “你赢了” “你输了”. 
-  游戏房间页面加载时和服务器建立 websocket 连接. 双方通过 websocket 来传输 “准备就绪”, “落子位置”, “胜负” 这样的信息. 
准备就绪: 两个玩家均连上游戏房间的 websocket 时, 则认为双方准备就绪.
落子位置: 有一方玩家落子时, 会通过 websocket 给服务器发送落子的用户信息和落子位置, 同时服务器再将这样的信息返回给房间内的双方客户端. 然后客户端根据服务器的响应来绘制棋子位置.
胜负: 服务器判定这一局游戏的胜负关系. 如果某一方玩家落子, 产生了五子连珠, 则判定胜负并返回胜负信息. 或者如果某一方玩家掉线(比如关闭页面), 也会判定对方获胜.
五、数据库设计与配置 Mybatis

创建如下:
create database if not exists java_gobang DEFAULT CHARACTER SET utf8;
use java_gobang;
drop table if exists user;
create table user(
                     userId int primary key auto_increment,
                     username varchar(50) unique,
                     password varchar(500),
                     score int, -- 天梯分数
                     totalCount int, -- 比赛总场次
                     winCount int -- 获胜场次
);
注意: 为了避免部署项目时云服务器数据库不支持中文,创建数据库时先提前设置好字符集。并且我们会将密码进行加密,所以密码的长度我们设置大一点
配置 Mybatis:
spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/java_gobang?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
  mapper-locations: classpath:mapper/**Mapper.xml
logging:
  pattern:
    console: "[%-5level] - %msg%n"
创建 mapper 目录,保存 .xml 文件,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.mapper.UserMapper">
<mapper>
六、实现用户模块功能
前面已经配置好了Mybatis文件,下面我们具体来实现 !!
注意: 用户模块不会涉及到消息推送,我们还是通过AJAX向后端发送请求处理后端响应即可 !
6.1 数据库代码编写
- 创建实体类
 创建 model 目录,添加 User 实体类,添加 @Data 注解,提供get,set方法
@Data
public class User {
    private int userId;
    private String username;
    private String password;
    private int score;
    private int totalCount;
    private int winCount;
 }
- 创建 UserMapper 接口
 创建 mapper 接口目录,添加 UserMapper 接口类
此处主要提供四个方法:
- selectByName: 根据用户名查找用户信息. 用于实现登录.
- insert: 新增用户. 用户实现注册.
- userWin: 用于给获胜玩家修改分数.
- userLose: 用户给失败玩家修改分数.
package com.example.java_gobang.mapper;
import com.example.java_gobang.model.User;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper {
    // 往数据库里插入一个用户. 用于注册功能.
    void insert(User user);
    // 根据用户名, 来查询用户的详细信息. 用于登录功能
    User selectByName(String username);
    // 总比赛场数 + 1, 获胜场数 + 1, 天梯分数 + 30
    void userWin(int userId);
    // 总比赛场数 + 1, 获胜场数 不变, 天梯分数 - 30
    void userLose(int userId);
}
- .xml文件实现UserMapper 接口
<?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.mapper.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>
6.2 前后端交互接口
需要明确用户模块的前后端交互接口. 这里主要涉及到三个部分.
- 登录接口
请求:
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=zhangsan&password=123
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: ‘zhangsan’,
score: 1000,
totalCount: 10,
winCount: 5
}
如果登录失败, 返回的是一个 userId 为 0 的对象.
- 注册接口
请求:
POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=zhangsan&password=123
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: ‘zhangsan’,
score: 1000,
totalCount: 10,
winCount: 5
}
如果注册失败(比如用户名重复), 返回的是一个 userId 为 0 的对象.
- 获取用户信息
请求:
GET /userInfo HTTP/1.1
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: ‘zhangsan’,
score: 1000,
totalCount: 10,
winCount: 5
}
6.3 服务器开发
创建 api.UserAPI 类
主要实现四个方法:
- login: 用来实现登录逻辑.
- register: 用来实现注册逻辑.
- getUserInfo: 用来实现登录成功进入大厅后显示用户信息.
- logout:用来实现退出游戏功能
代码如下:
@RestController
public class UserAPI {
    @Resource
    private UserMapper userMapper;
    //需要提前添加 BCrypt 依赖
    BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
    //登录更新 加上了密码加密
    @PostMapping("/login")
    @ResponseBody
    public Object login(String username, String password, HttpServletRequest request){
        // 查询用户是否在数据库中存在
        User user = userMapper.selectByName(username);
        // 没有查到
        if(user == null) {
            System.out.println("登录失败!");
            return new User();
        }else {
            //查到了,但密码不一样
            if(!bCryptPasswordEncoder.matches(password,user.getPassword())) {
                return new User();
            }
            // 匹配成功,创建 session
            request.getSession().setAttribute("user",user);
            return user;
        }
    }
    //注册更新,加上了密码加密
    @PostMapping("/register")
    @ResponseBody
    public Object register(String username,String password){
        User user1 = userMapper.selectByName(username);
        if(user1 != null){
            System.out.println("当前用户已存在");
            return new User();
        }else{
            User user2 = new User();
            user2.setUsername(username);
            String password1 = bCryptPasswordEncoder.encode(password);
            user2.setPassword(password1);
            userMapper.insert(user2);
            return user2;
        }
    }
    @RequestMapping("/logout")
    public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException {
        HttpSession session = request.getSession(false);
        try{
            session.removeAttribute("user");
            response.sendRedirect("login.html");
        }catch (NullPointerException e){
            System.out.println("session.removeAttribute()这里没有设置拦截器,直接访问logout页面退出会空指针异常");
        }
    }
    @GetMapping("/userInfo")
    @ResponseBody
    public Object getUserInfo(HttpServletRequest req) {
        try {
            HttpSession httpSession = req.getSession(false);
            User user = (User) httpSession.getAttribute("user");//拿到的是登录的用户信息
            // 拿着这个 user 对象, 去数据库中找, 找到最新的数据
            User newUser = userMapper.selectByName(user.getUsername());
            return newUser;
        } catch (NullPointerException e) {
            return new User();
        }
    }
}
注意: 上述逻辑实现,以及密码加密解密等操作和在线音乐平台的实现一样,若不懂大家可以去参考音乐博客 !!
6.4 客户端开发
- login.html 登录页面
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录页面</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/login.css">
</head>
<body id="body">
<div class="nav">
    <img src="image/touxiang.png" alt="">
    <span>五子棋对战平台</span>
    <!-- 空白元素, 用来占位置 -->
</div>
<div class="login-container">
    <div class="login-dialog">
        <!-- 登录界面的对话框 -->
        <h3>用户登录</h3>
        <!-- 这个表示一行 -->
        <div class="row">
            <span>用户名</span>
            <input type="text" id="username" placeholder="请输入用户名">
        </div>
        <div class="row">
            <span>密码</span>
            <input type="password" id="password" placeholder="请输入密码">
        </div>
        <!-- 提交按钮 -->
        <div class="submit-row1">
            <button id="submit">登录</button>
                 
            <input type="button" id="submit2" value="注册" onclick="toregister()">
        </div>
    </div>
</div>
<script src="./js/jquery.min.js"></script>
<style>
    #body {
        background-image: url("image/roombeijing.png");
        background-size:100% 100%;
        background-attachment: fixed;
    }
</style>
<script type="text/javascript">
    function toregister() {
        window.location.href = "register.html";
    }
</script>
<script>
    let submitButton = document.querySelector("#submit");
    submitButton.onclick = function (){
        let usernameInput = document.querySelector("#username");
        let passwordInput = document.querySelector("#password");
        if(usernameInput.value.trim() == ""){
            alert("请输入用户名!");
            usernameInput.focus();
            return;
        }
        if(passwordInput.value.trim() == ""){
            alert('请输入密码!');
            passwordInput.focus();
            return;
        }
        //通过 jQuery 中的 AJAX 和服务器进行交互
        $.ajax({
            type: 'post',
            url: '/login',
            data: {
                username: usernameInput.value,
                password: passwordInput.value,
            },
            success: function (body){
                //请求执行成功之后的回调函数
                //判定当前是否登录成功
                // 登录成功返回 当前的 User对象,失败返回一个空的 User 对象
                if(body && body.userId > 0){
                    // alert("登录成功!");
                    location.assign('/game_hall.html');
                }else{
                    alert("用户名或密码错误!")
                }
            },
            error: function (){
                //请求执行失败之后的回调函数
                alert("登录失败!")
            }
        });
    }
</script>
</body>
</html>
- register.html 注册页面
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>注册页面</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/login.css">
</head>
<body>
<div class="nav">
    <img src="image/touxiang.png" alt="">
    <span>五子棋对战平台</span>
</div>
<!-- 版心 -->
<div class="login-container">
    <!-- 中间的登陆框 -->
    <div class="login-dialog">
        <h3>用户注册</h3>
        <div class="row">
            <span>用户名</span>
            <input type="text" id="username" placeholder="请输入用户名">
        </div>
        <div class="row">
            <span>密码</span>
            <input type="password" id="password" placeholder="请输入密码">
        </div>
        <div class="row">
            <span>确认密码</span>
            <input type="password" id="password2" placeholder="请确认密码">
        </div>
        <div class="submit-row2">
            <button id="submit">注册</button>
                 
            <input type="button" id="submit2" value="返回" onclick="toregister()">
        </div>
    </div>
</div>
<script src="./js/jquery.min.js"></script>
<script type="text/javascript">
    function toregister() {
        window.location.href = "login.html";
    }
</script>
<script>
    let submitButton = document.querySelector("#submit");
    submitButton.onclick = function () {
        let username = document.querySelector("#username");
        let password1 = document.querySelector("#password");
        let password2 = document.querySelector("#password2");
        if (username.value.trim() == "") {
            alert("请输入用户名!");
            username.focus();
            return;
        }
        if (password1.value.trim() == "") {
            alert('请输入密码!');
            password1.focus();
            return;
        }
        if (password2.value.trim() == "") {
            alert('请再次输入密码!');
            password2.focus();
            return;
        }
        if (password1.value.trim() != password2.value.trim()) {
            alert('两次输入的密码不同!');
            passwrod1.value = "";
            password2.value = "";
            return;
        }
        $.ajax({
            type: 'post',
            url: '/register',
            data: {
                username: username.value,
                password: password1.value,
            },
            success: function (body) {
                //请求执行成功之后的回调函数
                if (body && body.username) {
                    alert("注册成功!");
                    location.assign('/login.html');
                } else {
                    alert("注册失败!当前用户已经存在!")
                }
            },
            error: function () {
                //请求执行失败之后的回调函数
                alert("注册失败!")
            }
        });
    }
</script>
</body>
</html>
七、实现匹配模块功能
7.1 前后端交互接口
先通过 WebSocket 将前后端连接起来
前端初始化 websocket ,连接:
ws://127.0.0.1:8080/findMatch
后端创建匹配类 MatchAPI,并创建 WebSocketConfig 类实现WebSocketConfigurer接口,来连接匹配类 MatchAPI 和 前端,实现如下:
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")
                //在注册websocket API的时候,就需要把前面准备的 Httpsession(用户登录会给Httpsession保存用户信息) 搞过来(搞到websocket的session中)
                //因为在匹配中,你需要把用户相关的信息发送给服务器,服务器根据此信息进行水平匹配
                .addInterceptors(new HttpSessionHandshakeInterceptor());
        webSocketHandlerRegistry.addHandler(gameAPI, "/game")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}
注意: 第一个参数为后端匹配的类,第二个参数也必须和前端规定的路径保持一致 !!
在 addHandler 之后, 再加上一个 .addInterceptors(new HttpSessionHandshakeInterceptor()) 代码, 这样可以把之前登录过程中往 HttpSession 中存放的数据(主要是 User 对象), 放到 WebSocket 的 session 中. 方便后面的代码中获取到当前用户信息.
实现连接后,我们来看具体的请求和响应 !!
请求:
 {
 message: ‘startMatch’ / ‘stopMatch’,
 }
响应1: (收到请求后立即响应)
 {
 ok: true, // 是否成功. 比如用户 id 不存在, 则返回 false
 reason: ‘’, // 错误原因
 message: ‘startMatch’ / ‘stopMatch’
 }
响应2: (匹配成功后的响应)
 {
 ok: true, // 是否成功. 比如用户 id 不存在, 则返回 false
 reason: ‘’, // 错误原因
 message: ‘matchSuccess’,
 }
注意:
- 页面这端拿到匹配响应之后, 就跳转到游戏房间.
- 如果返回的响应 ok 为 false, 则弹框的方式显示错误原因, 并跳转到登录页面
7.2 客户端开发
创建 game_hall.html 游戏大厅页面, 主要包含:
- #screen 用于显示玩家的分数信息
- button#match-button 作为匹配按钮.
game_hall.html 的 js 部分代码功能:
- 点击匹配按钮, 就会进入匹配逻辑. 同时按钮上提示 “匹配中…(点击取消)” 字样.
- 再次点击匹配按钮, 则会取消匹配.
- 当匹配成功后, 服务器会返回匹配成功响应, 页面跳转到 game_room.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>游戏大厅</title>
    <link rel="stylesheet" href="css/common.css">
    <link rel="stylesheet" href="css/game_hall.css">
</head>
<body>
<div class="nav">
    <img src="image/touxiang.png" alt="">
    <span>五子棋对战大厅</span>
    <div class="spacer"></div>
    <a href="logout">退出登录[Logout]</a>
</div>
    <!-- 整个页面的容器元素 -->
    <div class="container">
        <!-- 这个 div 在 container 中是处于垂直水平居中这样的位置的 -->
        <div>
            <!-- 展示用户信息 -->
            <div id="screen"></div>
            <!-- 匹配按钮 -->
            <div id="match-button">开 始 匹 配 ( Play Game )</div>
        </div>
    </div>
    <script src="js/jquery.min.js"></script>
    <script>
        $.ajax({
            type: 'get',
            url: '/userInfo',
            success: function(body) {
                let screenDiv = document.querySelector('#screen');
                screenDiv.innerHTML = '您的信息如下:' + '<br> 姓名: ' + body.username + "," + "天梯分数: " + body.score
                    + "<br> 比赛场次: " + body.totalCount + "," + "获胜场数: " + body.winCount
            },
            error: function() {
                alert("获取用户信息失败!");
            }
        });
        // 此处进行初始化 websocket, 并且实现前端的匹配逻辑. 
        // 此处的路径必须写作 /findMatch, 千万不要写作 /findMatch/ 
        let websocketUrl = 'ws://' + location.host + '/findMatch';//location.host访问 game_hall 同样的IP和端口号,不在此处写死,更加灵活
        let websocket = new WebSocket(websocketUrl);
        websocket.onopen = function() {
            console.log("onopen");
        }
        websocket.onclose = function() {
            console.log("onclose");
        }
        websocket.onerror = function() {
            console.log("onerror");
        }
        // 监听页面关闭事件. 在页面关闭之前, 手动调用这里的 websocket 的 close 方法. 
        window.onbeforeunload = function() {
            websocket.close();
        }
        // 一会重点来实现, 要处理服务器返回的响应
        websocket.onmessage = function(e) {
            // 处理服务器返回的响应数据. 这个响应就是针对 "开始匹配" / "结束匹配" 来对应的
            // 解析得到的响应对象. 返回的数据是一个 JSON 字符串, 解析成 js 对象 JSON.stringify反之
            //扩展:JSON 转换为 Java对象:objectMapper.readValue  反之:writeValueAsString
            let resp = JSON.parse(e.data);
            let matchButton = document.querySelector('#match-button');
            if (!resp.ok) {
                console.log("游戏大厅中接收到了失败响应! " + resp.reason);
                return;
            }
            if (resp.message == 'startMatch') {
                // 开始匹配请求发送成功
                console.log("进入匹配队列成功!");
                matchButton.innerHTML = '匹 配 中 ! ! ( 点 击 停 止 )'
            } else if (resp.message == 'stopMatch') {
                // 结束匹配请求发送成功
                console.log("离开匹配队列成功!");
                matchButton.innerHTML = '开 始 匹 配 ( Play Game )';
            } else if (resp.message == 'matchSuccess') {
                // 已经匹配到对手了. 
                console.log("匹配到对手! 进入游戏房间!");
                // location.assign("/game_room.html");
                location.replace("/game_room.html");//避免用户在浏览器使用回退功能造成逻辑出错,我们在此使用 replace,不会回退到上一历史页面
            } else if (resp.message == 'repeatConnection') {
                alert("同一账号禁止多开! 请使用其他账号登录!");
                location.replace("/login.html");
            } else {
                console.log("收到了非法的响应! message=" + resp.message);
            }
        }
        // 给匹配按钮添加一个点击事件
        let matchButton = document.querySelector('#match-button');
        matchButton.onclick = function() {
            // 在触发 websocket 请求之前, 先确认下 websocket 连接是否好着呢~~ 
            if (websocket.readyState == websocket.OPEN) {
                // 如果当前 readyState 处在 OPEN 状态, 说明连接好着的~
                // 这里发送的数据有两种可能, 开始匹配/停止匹配~
                if (matchButton.innerHTML == '开 始 匹 配 ( Play Game )') {
                    console.log("开 始 匹 配 ( Play Game )");
                    websocket.send(JSON.stringify({
                        message: 'startMatch',
                    }));
                } else if (matchButton.innerHTML == '匹 配 中 ! ! ( 点 击 停 止 )') {
                    console.log("停止匹配");
                    websocket.send(JSON.stringify({
                        message: 'stopMatch',
                    }));
                }
            } else {
                // 这是说明连接当前是异常的状态
                alert("当前您的连接已经断开! 请重新登录!");
                location.replace('/login.html');
            }
        }
    </script>
</body>
</html>
7.3 服务器开发
创建 api.MatchAPI, 继承自 TextWebSocketHandler 作为处理 websocket 请求的入口类,具体如何实现与前端实现 websocket 连接,前面已经写好了的
// 通过这个类来处理匹配功能中的 websocket 请求
@Component
public class MatchAPI extends TextWebSocketHandler {
    //处理JSON格式
    private ObjectMapper objectMapper = new ObjectMapper();
    @Autowired
    private OnlineUserManager onlineUserManager;
    @Autowired
    private Matcher matcher;
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        
    }
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
       
    }
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
       
    }
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
    
    }
}
下面我们就来考虑如何实现这几个重写方法 !!!先做好其他准备
- 实现用户管理器
创建 game.OnlineUserManager 类, 用于管理当前用户的在线状态. 本质上是 哈希表 的结构. key 为用户 id, value 为用户的 WebSocketSession.
借助这个类, 一方面可以判定用户是否是在线, 同时也可以进行方便的获取到 Session 从而给客户端回话.
- 当玩家建立好 websocket 连接, 则将键值对加入 OnlineUserManager 中.
- 当玩家断开 websocket 连接, 则将键值对从 OnlineUserManager 中删除.
- 在玩家连接好的过程中, 随时可以通过 userId 来查询到对应的会话, 以便向客户端返回数据.
由于存在两个页面, 游戏大厅和游戏房间, 使用两个 哈希表 来分别存储两部分的会话.
代码实现如下:
package com.example.java_gobang.game;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;
//这个类用来管理 用户在大厅和游戏房间里的状态
@Component
public class OnlineUserManager {
    // 这个哈希表就用来表示当前用户在游戏大厅在线状态.
    // 避免同时有多个用户并发和服务器建立/断开连接,这里采用线程安全的 ConcurrentHashMap
    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);
    }
}
- 创建匹配请求/响应对象
创建 game.MatchRequest 类
package com.example.java_gobang.game;
// 这是表示一个 websocket 的匹配请求
public class MatchRequest {
    private String message = "";
    public String getMessage() {
        return message;
    }
    public void setMessage(String message) {
        this.message = message;
    }
}
创建 game.MatchResponse 类
// 这是表示一个 websocket 的匹配响应
@Data
public class MatchResponse {
    private boolean ok;
    private String reason;
    private String message;
 }
- 处理连接成功
实现 afterConnectionEstablished 方法.
-  通过参数中的 session 对象, 拿到之前登录时设置的 User 信息. 
-  先判定用户是否是已经在线, 如果在线则直接返回出错 (禁止同一个账号多开). 
-  使用 onlineUserManager 来管理用户的在线状态,设置玩家的上线状态. 
代码实现如下:
@Override
 public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 玩家上线进入到游戏大厅, 将用户信息加入到 OnlineUserManager 中
        // 1. 先获取到当前用户的身份信息(谁在游戏大厅中, 建立的连接)
        //    此处的代码, 之所以能够 getAttributes, 全靠了在注册 Websocket 的时候,
        //    加上的 .addInterceptors(new HttpSessionHandshakeInterceptor());
        //    这个逻辑就把 HttpSession 中的 Attribute 都给拿到 WebSocketSession 中了
        //    在 Http 登录逻辑中, 往 HttpSession 中存了 User 数据: httpSession.setAttribute("user", user);
        //    此时就可以在 WebSocketSession 中把之前 HttpSession 里存的 User 对象给拿到了.
        //    注意, 此处拿到的 user, 是有可能为空的!!
        //    如果之前用户压根就没有通过 HTTP 来进行登录, 直接就通过 /game_hall.html 这个 url 来访问游戏大厅页面
        //    此时就会出现 user 为 null 的情况
        try {
            User user = (User) session.getAttributes().get("user");
            // 2. 先判定当前用户是否已经登录过(已经是在线状态), 如果是已经在线, 就不该继续进行后续逻辑.禁止多开(同一账号登录多处)
            // 如果使用浏览器多开,会使用户的状态在hash表中同一个key对应两个value,而后一个value会将前一个value覆盖,
            // 所以第一个浏览器的连接就会失效,拿不到websocketsession,也就无法给它推送数据了
            if (onlineUserManager.getFromGameHall(user.getUserId()) != null
                    || onlineUserManager.getFromGameRoom(user.getUserId()) != null) {
                // 当前用户已经登录了!!
                // 针对这个情况要告知客户端, 你这里重复登录了.
                MatchResponse response = new MatchResponse();
                response.setOk(true);
                response.setReason("您已登录,当前禁止多开!");
                response.setMessage("repeatConnection");
                // TestMessage 表示一个文本格式的 websocket 数据包
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
                // 此处直接关闭有些太激进了, 还是返回一个特殊的 message response.setMessage("repeatConnection"), 供客户端来进行判定, 由客户端负责进行处理
                // 并且这里的直接关闭会触发后面的 afterConnectionClosed,通过用户id使先登录的浏览器也会断开websocket连接,不科学,我们只需要禁止重复登录就行
                // session.close();
                return;
            }
            // 3. 拿到了身份信息之后, 就可以把玩家设置成在线状态了
            onlineUserManager.enterGameHall(user.getUserId(), session);
            System.out.println("玩家 " + user.getUsername() + " 进入游戏大厅!");
        } catch (NullPointerException e) {
            System.out.println("[MatchAPI.afterConnectionEstablished] 当前用户未登录!");
            // e.printStackTrace(); 不直接打印异常调用栈了,我们在控制台自定义日志输出
            // 出现空指针异常, 说明当前用户的身份信息是空, 用户未登录呢.
            // 把当前用户尚未登录这个信息给返回回去~~
            MatchResponse response = new MatchResponse();
            response.setOk(false);
            response.setReason("您尚未登录! 不能进行后续匹配功能!");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        }
    }
- 处理开始匹配/取消匹配请求
实现 handleTextMessage 方法
- 先从会话中拿到当前玩家的信息.
- 解析客户端发来的请求
- 判定请求的类型, 如果是 startMatch, 则把用户对象加入到匹配队列. 如果是 stopMatch, 则把用户对象从匹配队列中删除.
- 此处需要实现一个 匹配器类Matcher, 来处理匹配的实际逻辑.
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 实现处理开始匹配请求和处理停止匹配请求.
        User user = (User) session.getAttributes().get("user");
        // 获取到客户端给服务器发送的数据
        String payload = message.getPayload();
        // 当前这个数据载荷是一个 JSON 格式的字符串, 就需要把它转成 Java 对象. MatchRequest
        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));
    }
- 实现匹配器
创建 game.Matcher 类.并按照如下要求实现
- 在 Matcher 中创建三个队列 (队列中存储 User 对象), 分别表示不同的段位的玩家.
 (此处约定 <2000 一档, 2000-3000 一档, >3000 一档)
- 提供 add 方法, 供 MatchAPI 类来调用, 用来把玩家加入匹配队列.
- 提供 remove 方法, 供 MatchAPI 类来调用, 用来把玩家移出匹配队列.
- 同时 Matcher 找那个要记录 OnlineUserManager, 来获取到玩家的 Session.
- 在 Matcher 的构造方法中, 创建一个线程, 使用该线程扫描每个队列, 把每个队列的头两个元素取出来, 匹配到一组中.
- 实现 handlerMatch 处理匹配方法
1.由于 handlerMatch 在单独的线程中调用. 因此要考虑到多用户访问队列的线程安全问题. 需要加上锁.
2.每个队列分别使用队列对象本身作为锁即可.
3.在入口处使用 wait 来等待, 直到队列中达到 2 个元素及其以上, 才唤醒线程消费队列.插入成功后要通知唤醒上面的等待逻辑.
4.需要给上面的插入队列元素, 删除队列元素也加上锁
代码实现如下:
// 这个类表示 "匹配器", 通过这个类负责完成整个匹配功能
@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();//有玩家进入匹配队列时,就唤醒该队列 ,对应handlerMatch方法中的 wait
            }
            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() {
                // 扫描 normalQueue
                while (true) { //在这里循环速度极快,一进入handlerMatch就快速返回,但是当队列中的个数小于2时,这样快速的扫描就没有什么意义,且CPU占用率很高,即出现了忙等
                    handlerMatch(normalQueue);
                    //针对上面的忙等,可以在调用完 handlerMatch 后,进行sleep
                    //这样做可以,但是不完美,比如玩家已经匹配到对手,却还要等待休眠结束后,才能进行游戏
                }
            }
        };
        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 {
                // 1. 检测队列中元素个数是否达到 2
                //    队列的初始情况可能是 空
                //    如果往队列中添加一个元素, 这个时候, 仍然是不能进行后续匹配操作的.
                //    因此在这里使用 while 循环检查是更合理的~~ 只有当 size大于2后,才能进行后续操作
                while (matchQueue.size() < 2) {
                    matchQueue.wait();//队列中的数目一直小于2时,即一直还没有玩家加入队列直到数目达到2以上,就进行线程等待
                }
                // 2. 尝试从队列中取出两个玩家
                User player1 = matchQueue.poll();
                User player2 = matchQueue.poll();
                System.out.println("匹配出两个玩家: " + player1.getUsername() + ", " + player2.getUsername());
                // 3. 获取到玩家的 websocket 的会话
                //    获取到会话的目的是为了告诉玩家, 你排到了~~
                WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
                WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());
                // 理论上来说, 匹配队列中的玩家一定是在线的状态.
                // 因为前面的逻辑里进行了处理, 当玩家断开连接的时候就把玩家从匹配队列中移除了.
                // 但是此处仍然进行一次判定~~
                if (session1 == null) {
                    // 如果玩家1 现在不在线了, 就把玩家2 重新放回到匹配队列中
                    matchQueue.offer(player2);
                    return;
                }
                if (session2 == null) {
                    // 如果玩家2 现在下线了, 就把玩家1 重新放回匹配队列中
                    matchQueue.offer(player1);
                    return;
                }
                // 当前能否排到两个玩家是同一个用户的情况嘛? 一个玩家入队列了两次??
                // 理论上也不会存在~~
                // 1) 如果玩家下线, 就会对玩家移出匹配队列
                // 2) 又禁止了玩家多开.
                // 但是仍然在这里多进行一次判定, 以免前面的逻辑出现 bug 时带来严重的后果.
                if (session1 == session2) {
                    // 把其中的一个玩家放回匹配队列.
                    matchQueue.offer(player1);
                    return;
                }
                // 4. 把这两个玩家放到一个游戏房间中.
                Room room = new Room();
                roomManager.add(room, player1.getUserId(), player2.getUserId());
                // 5. 给玩家反馈信息: 你匹配到对手了~
                //    通过 websocket 返回一个 message 为 'matchSuccess' 这样的响应
                //    此处是要给两个玩家都返回 "匹配成功" 这样的信息.
                //    因此就需要返回两次
                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();
            }
        }
    }
}
通过上述匹配器进行玩家匹配,成功匹配出两名玩家后,我们就将玩家加入到一个游戏房间中进行后续具体的游戏操作。所以接下来我们需要完成游戏房间的设置
- 创建房间类
创建 game.Room 类
- 一个房间要包含一个房间 ID, 使用 UUID 作为房间的唯一身份标识.
- 房间内要记录对弈的玩家双方信息.
- 记录先手方的 ID
- 记录一个 二维数组 , 作为对弈的棋盘.
- 记录一个 OnlineUserManager, 以备后面和客户端进行交互.
- 当然, 少不了 ObjectMapper 来处理 json
@Data
public class Room {
    private String roomId;
    // 玩家1
    private User user1;
    // 玩家2
    private User user2;
    // 先手方的用户 id
    private int whiteUserId = 0;
    // 棋盘, 数字 0 表示未落子位置. 数字 1 表示玩家 1 的落子. 数字 2 表示玩家 2 的落子
    private static final int MAX_ROW = 15;
    private static final int MAX_COL = 15;
    private int[][] chessBoard = new int[MAX_ROW][MAX_COL];
    private ObjectMapper objectMapper = new ObjectMapper();
    private OnlineUserManager onlineUserManager;
    public Room() {
        // 构造 Room 的时候生成一个唯一的字符串表示房间 id.
        // 使用 UUID 来作为房间 id
        roomId = UUID.randomUUID().toString();
        // 通过入口类中记录的 context 来手动获取到前面的 RoomManager 和 OnlineUserManager
        onlineUserManager = JavaGobangApplication.context.getBean(OnlineUserManager.class);
        roomManager = JavaGobangApplication.context.getBean(RoomManager.class);
        userMapper = JavaGobangApplication.context.getBean(UserMapper.class);
    }
}
具体的游戏操作,我们在后面的对战模块进行补写 !!
- 实现房间管理器
Room 对象会存在很多. 每两个对弈的玩家, 都对应一个 Room 对象.需要一个管理器对象RoomMannager 来管理所有的 Room.
创建 game.RoomManager
- 使用一个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象
- 再使用一个 Hash 表, 保存 userId -> roomId 的映射, 方便根据玩家来查找所在的房间.
- 提供增, 删, 查的 API. (查包含两个版本, 基于房间 ID 的查询和基于用户 ID 的查询).
代码实现如下:
// 房间管理器类.
// 这个类也希望有唯一实例.
@Component
public class RoomManager {
    private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
    //将玩家id 和 游戏房间id 的映射关系存储起来
    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);
    }
    
    //通过玩家id 找到玩家所在的游戏房间
    public Room getRoomByUserId(int userId) {
        String roomId = userIdToRoomId.get(userId);
        if (roomId == null) {
            // userId -> roomId 映射关系不存在, 直接返回 null
            return null;
        }
        return rooms.get(roomId);
    }
}
- 处理连接关闭
实现 afterConnectionClosed
- 主要的工作就是把玩家从 onlineUserManager 中退出.
- 退出的时候要注意判定, 当前玩家是否是多开的情况(一个userId, 对应到两个 websocket 连接). 如果一个玩家开启了第二个 websocket 连接, 那么这第二个 websocket 连接不会影响到玩家从 OnlineUserManager 中退出.
- 如果玩家当前在匹配队列中, 则直接从匹配队列里移除.
代码实现如下:
@Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        try {
            // 玩家下线, 从 OnlineUserManager 中删除
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if (tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            // 如果玩家正在匹配中, 而 websocket 连接断开了, 就应该移除匹配队列
            matcher.remove(user);
        } catch (NullPointerException e) {
            System.out.println("[MatchAPI.afterConnectionClosed] 当前用户未登录!");
            // e.printStackTrace();
            // 不应该在连接关闭之后, 还尝试发送消息给客户端
//            MatchResponse response = new MatchResponse();
//            response.setOk(false);
//            response.setReason("您尚未登录! 不能进行后续匹配功能!");
//            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        }
    }
}
- 理连接异常
实现 handleTransportError. 逻辑同上.
@Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        try {
            // 玩家下线, 从 OnlineUserManager 中删除
            User user = (User) session.getAttributes().get("user");
            WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
            if (tmpSession == session) {
                onlineUserManager.exitGameHall(user.getUserId());
            }
            // 如果玩家正在匹配中, 而 websocket 连接断开了, 就应该移除匹配队列
            matcher.remove(user);
        } catch (NullPointerException e) {
            System.out.println("[MatchAPI.handleTransportError] 当前用户未登录!");
//            e.printStackTrace();
//            MatchResponse response = new MatchResponse();
//            response.setOk(false);
//            response.setReason("您尚未登录! 不能进行后续匹配功能!");
//            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
            //上述websocket连接都已经断开了,还怎么能发送响应呢 !!!
        }
    }
八、实现对战模块功能
至此,上述逻辑能成功实现当多个玩家开始匹配游戏,两两玩家能被分配到一个游戏房间中进行游戏,至于如何实现游戏逻辑,我们接着来看 !!!



![[OOD设计] - 电梯系统设计](https://img-blog.csdnimg.cn/696c3dab2a584c27872af573b4278a6f.png)















