当前位置: 首页 > article >正文

网页五子棋——匹配模块

目录

时序图

约定前后端交互接口

前端页面

game_hall.html

game_hall.css

获取用户信息

约定前后端交互接口

controller 层接口设计

service 层接口设计

前端请求

功能测试

前端实现

服务端实现

OnlineUserManager

创建请求/响应对象

处理连接成功

处理开始/结束匹配请求

Matcher

处理连接关闭

处理连接异常

创建游戏房间

修改前端逻辑

验证匹配功能


在实现用户匹配模块时需要使用到 WebSocket

当玩家发送请求时,服务器返回开始匹配响应:

 

 两个玩家匹配成功后,服务器推送 匹配成功 消息:

WebSocket 相关知识可参考:WebSocket_websocket csdn-CSDN博客

时序图

我们来理解一下匹配过程: 

1. 用户点击 开始匹配 按钮后,发送开始匹配请求给服务器

2. 服务器对用户信息进行校验,校验通过后,将用户放入匹配队列进行匹配,并返回开始匹配响应

3. 服务器为用户匹配到对手后,推送匹配成功消息给用户

4. 若用户在匹配过程中点击停止匹配,服务器则将用户从匹配队列移除,并返回停止匹配响应

约定前后端交互接口

前后端约定的交互接口,也是基于 WebSocket

[请求] ws://127.0.0.1:8080/findMatch

{
     "message": "START"/ "STOP" // 开始/结束匹配
}

在通过 WebSocket 传输请求数据时,数据中可以不必带有用户身份信息

当前用户的身份信息,在登录完成后,就自动保存到 HttpSession 中了,而在 WebSocket 中,可以拿到之前登录时保存的 HttpSession 信息 

[响应]

{
    "code": 200,
    "data": {
        "matchMessage": "START" / "STOP",
        "rival": null
    },
    "errorMessage": ""
}

客户端向服务器发送匹配请求后,服务器立即返回匹配响应,表示已经开始 / 结束匹配 

由于此时并未匹配到对手,因此 rival 为空

 匹配到对手后,服务器主动推送信息:

{
    "code": 200,
    "data": {
        "matchMessage": "SUCCESS",
        "rival": {"name": 'lisi', "score": 1000}
    },
    "errorMessage": ""
}

我们首先来实现前端页面 

前端页面

前端页面的内容比较简单: 一个 div 用于显示用户信息,一个 button 用于进行匹配

game_hall.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <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">
        五子棋
    </div>
    <div class="container">
        <div>
            <!-- 显示用户信息 -->
            <div id="screen"></div>
            <!-- 匹配按钮 -->
            <div id="match-button">开始匹配</div>
        </div>
    </div>
</body>
</html>

添加 css 样式

game_hall.css

#screen {
    width: 400px;
    height: 250px;
    background-color:antiquewhite;
    font-size: 20px;
    color: gray;
    border-radius: 10px;
    text-align: center;
    line-height: 100px;
}

#match-button {
    width: 400px;
    height: 50px;
    background-color: antiquewhite;
    font-size: 20px;
    color: gray;
    border-radius: 10px;
    text-align: center;
    line-height: 50px;
    margin-top: 10px;
}

要在页面上显示用户相关信息,因此,需要从后端获取用户信息

接下来,我们就继续实现用户信息的获取

获取用户信息

约定前后端交互接口

[请求] GET /getUserInfo

[响应]

{
    "code": 200,
    "data": {
        "userId": 1,
        "userName": "zhangsan",
        "score": 1000,
        "totalCount": 0,
        "winCount": 0
    },
    "errorMessage": ""
}

由于登录时将相关用户信息存储到 session 中了,因此,可以直接从 HttpSession 中获取用户信息,不必传递相关参数

controller 层接口设计

返回的响应类型:

@Data
public class UserInfoResult implements Serializable {
    /**
     * 用户 id
     */
    private Long userId;
    /**
     * 用户名
     */
    private String userName;
    /**
     * 天梯分数
     */
    private Long score;
    /**
     * 总场数
     */
    private Long totalCount;
    /**
     * 获胜场次
     */
    private Long winCount;
}

controller 接口主要完成的功能是:

1. 打印日志

2. 从 request 中获取 session

3. 调用 service 层方法进行业务逻辑处理

3. 构造响应并返回

若从 session 中获取用户信息失败,表明当前用户需要重新登录,因此,我们可以在 CommonResult 中添加  noLogin 方法,用于处理用户未登录的情况

    public static <T> CommonResult<T> noLogin() {
        CommonResult result = new CommonResult();
        result.code = 401;
        result.errorMessage = "用户未登录";
        return result;
    }

 getUserInfo:

  /**
     * 从 session 中获取用户信息
     * @param request
     * @return
     */
    @RequestMapping("/getUserInfo")
    public CommonResult<UserInfoResult> getUserInfo(HttpServletRequest request) {
        // 日志打印
        log.info("getUserInfo 从 HttpSession 中获取用户信息");
        // 检查当前请求是否已经有会话对象,如果没有现有的会话,则返回 null
        HttpSession session = request.getSession(false); 
        // 业务逻辑处理
        UserInfoDTO userInfoDTO = userService.getUserInfo(session);
        // 构造响应并返回
        if (null == userInfoDTO) {
            return CommonResult.noLogin();
        }
        return CommonResult.success(convertToUserInfoResult(userInfoDTO));
    }

UserInfoDTO:

@Data
public class UserInfoDTO implements Serializable {
    /**
     * 用户 id
     */
    private Long userId;
    /**
     * 用户名
     */
    private String userName;
    /**
     * 天梯分数
     */
    private Long score;
    /**
     * 总场数
     */
    private Long totalCount;
    /**
     * 获胜场次
     */
    private Long winCount;
}

类型转化:

    /**
     * 将 UserInfoDTO 转化为 UserInfoResult
     * @param userInfoDTO
     * @return
     */
    private UserInfoResult convertToUserInfoResult(UserInfoDTO userInfoDTO) {
        // 参数校验
        if (null == userInfoDTO) {
            throw new ControllerException(ControllerErrorCodeConstants.GET_USER_INFO_ERROR);
        }
        // 构造 UserInfoResult
        UserInfoResult userInfoResult = new UserInfoResult();
        userInfoResult.setUserId(userInfoDTO.getUserId());
        userInfoResult.setUserName(userInfoDTO.getUserName());
        userInfoResult.setScore(userInfoDTO.getScore());
        userInfoResult.setTotalCount(userInfoDTO.getTotalCount());
        userInfoResult.setWinCount(userInfoDTO.getWinCount());
        // 返回
        return userInfoResult;
    }

添加错误码:

public interface ControllerErrorCodeConstants {
    // ---------------------- 用户模块错误码 ----------------------
    ErrorCode REGISTER_ERROR = new ErrorCode(100, "注册失败");
    ErrorCode LOGIN_ERROR = new ErrorCode(101, "登录失败");
    ErrorCode GET_USER_INFO_ERROR = new ErrorCode(102, "获取用户信息失败");
}

service 层接口设计

定义业务接口:

    /**
     * 获取用户信息
     * @param session
     * @return
     */
    UserInfoDTO getUserInfo(HttpSession session);

getUserInfo() 要实现的逻辑:

1. 校验 session 是否为 null

2. 从 session 中获取用户信息

3. 构造响应并返回

    @Override
    public UserInfoDTO getUserInfo(HttpSession session) {
        // 参数校验
        if (null == session) {
            return null;
        }
        // 从 session 中获取用户信息
        UserInfo userInfo = (UserInfo) session.getAttribute(USER_INFO);
        if (null == userInfo) {
            return null;
        }
        // 构造响应并返回
        UserInfoDTO userInfoDTO = new UserInfoDTO();
        userInfoDTO.setUserId(userInfo.getUserId());
        userInfoDTO.setUserName(userInfo.getUserName());
        userInfoDTO.setScore(userInfo.getScore());
        userInfoDTO.setTotalCount(userInfo.getTotalCount());
        userInfoDTO.setWinCount(userInfo.getWinCount());
        return userInfoDTO;
    }

若从 session 中获取用户信息失败,直接返回 null

前端请求

    <script src="js/jquery.min.js"></script>
    <script>
        $.ajax({
            url: "/getUserInfo",
            type: "GET",
            success: function(result) {
                console.log(result)
                if(result.code == 200) {
                    let screenDiv = document.querySelector('#screen');
                    var user = result.data;
                    screenDiv.innerHTML = '玩家:' + user.userName + ' 分数:' 
                                            + user.score + '<br>比赛场次:' 
                                            + user.totalCount + ' 获胜场数:' + user.winCount;
                }else if(result.code == 401) {
                    location.assign("/login.html");
                }
            }
        }); 
    </script>

功能测试

运行程序,登录后进入 http://127.0.0.1:8080/game_hall.html:

用户信息正确显示

接下来,我们继续实现匹配功能

前端实现

添加点击事件,onclick() 需要完成的功能:

1. 判断连接是否正常

2. 判断当前是 开始匹配 还是 停止匹配

3. 若是开始匹配,则发送开始匹配请求

4. 若是停止匹配,则发送停止匹配请求

        // 为匹配按钮添加点击事件
        let matchBtn = document.querySelector('#match-button');
        matchBtn.onclick = function() {
            // 在发送 websocket 请求前,先判断连接是否正常
            if(webSocket.readyState == webSocket.OPEN) {
                if(matchBtn.innerHTML == "开始匹配") {
                    console.log("开始匹配");
                    webSocket.send(JSON.stringify({
                        message: "START",
                    }));
                } else if(matchBtn.innerHTML == "匹配中...(点击停止)") {
                    console.log("停止匹配");
                    webSocket.send(JSON.stringify({
                        message: "STOP",
                    }));
                }
            } else {
                alert("当前连接已断开!请重新登录");
                location.replace("/login.html");
            }
        }

webSocket.readyState 是 WebSocket 对象的一个属性,表示 WebSocket 连接的当前状态

WebSocket.CONNECTING:WebSocket 正在连接中,表示 WebSocket 对象正在尝试建立连接,但连接还未成功建立

WebSocket.OPEN:WebSocket连接已经建立并且可以进行通信,此时可以通过 WebSocket 发送和接收消息

WebSocket.CLOSING:WebSocket 正在关闭中,此时的 WebSocket 连接已经开始关闭过程,但仍然可以发送和接收一些消息

WebSocket.CLOSIN:WebSocket 连接已经关闭或无法建立连接,此时 WebSocket 不再可用,无法发送和接收消息

创建 WebSocket 实例,并挂载回调函数:

        // 初始化 webSocket
        let webSocketUrl = 'ws://127.0.0.1/findMatch';
        let webSocket = new WebSocket(webSocketUrl);
        // 处理服务器响应
        webSocket.onmessage = function(e) {
        }

        // 监听页面关闭事件,在页面关闭之前,手动调用 webSocker 的 close 方法
        // 防止连接还没断开就关闭窗口
        window.onbeforeunload = function() {
            webSocket.close();
        }

 onmessage() 中主要是对服务器返回的数据进行处理:

首先需要根据返回 code 进行处理:

code 为 200,响应成功,继续判断 matchMessage

code 不为 200,出现异常情况,直接跳转到 登录页面

在此时对于 code 不为 200 的情况,我们就先直接让其跳转到登录页面,后续再进行更细致的处理 

若 code 为 200,则继续根据 matchMessage 进行处理:

若返回的 matchMessage START,表示服务器开始进行匹配,将显示的内容修改为 正在进行匹配

若返回的 matchMessage STOP,表示服务器已停止进行匹配,将显示的内容修改为 开始匹配

若返回的 matchMessage SUCCESS,表示服务器已经匹配到对手,显示对手相关信息,跳转到游戏房间页面

        webSocket.onmessage = function(e) {
            // 处理服务器返回的响应数据
            let resp = JSON.parse(e.data);
            console.log(resp);
            let matchButton = document.querySelector("#match-button");
            if (resp.code != 200) {
                console.log("发生错误: " + resp.errorMessage);
                location.replace("/login.html");
                return;
            }
            if (resp.data.matchMessage == 'START') {
                console.log("成功进入匹配队列");
                matchButton.innerHTML = "匹配中...(点击停止)";
            } else if (resp.data.matchMessage == 'STOP') {
                console.log("结束匹配");
                matchButton.innerHTML = "开始匹配";
            } else if (resp.data.matchMessage == 'SUCCESS') {
                // 成功匹配到对手, 进入游戏房间
                console.log(resp.data.rival);
                alert("匹配到对手:" + resp.data.rival.name + " 分数:" + resp.data.rival.score);
                location.replace("/game_room.html");
            } else {
                console.log("接收到非法响应!" + resp.data);
            }
        }

  

服务端实现

首先需要创建 MatchHandler,继承自 TextWebSocketHandler,作为处理 WebSocket 请求的入口类:

@Component
@Slf4j
public class MatchHandler extends TextWebSocketHandler {

    /**
     * 连接建立
     * @param session
     * @throws Exception
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        
    }

    /**
     * 处理匹配请求和停止匹配请求
     * @param session
     * @param message
     * @throws Exception
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        
    }

    /**
     * 处理异常情况
     * @param session
     * @param exception
     * @throws Exception
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
       
    }

    /**
     * 连接关闭
     * @param session
     * @param status
     * @throws Exception
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
       
    }
}

接着,创建 WebSocketConfig,注册 MatchHanlder

@EnableWebSocket
@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
    @Autowired
    private MatchHandler matchHandler;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(matchHandler, "/findMatch")
                .addInterceptors(new HttpSessionHandshakeInterceptor()); // 添加拦截器
    }
}

addHandler 之后,添加 addInterceptors(new HttpSessionHandshakeInterceptor()),其作用是将之前登录过程中存放在 HttpSession 中的数据(主要是 UserInfo),存放到 WebSocket 的 session 中,方便后续获取当前用户信息

OnlineUserManager

注册完成后,我们需要创建一个 Manager 类来管理用户的在线状态

借助这个类,一方面可以判定用户是否在线,另一方面是可以从中获取到 Session 给客户端回话

因此,我们可以使用 哈希表 这样的结果来对用户在线状态进行管理,其中 key 用户 idvalue 为用户的 WebSocketSession

那么,可以使用 HashMap 来存储用户在线状态吗?

若使用 HashMap 来进行存储的话,同时有多个用户和服务器建立连接/断开连接,此时服务器就是在并发的针对 HashMap 进行修改,就很可能会出现线程安全问题,因此使用 HashMap 是不适合的,而 ConcurrentHashMap 更适合当前的场景

ConcurrentHashMap 能够做到读数据不加锁,且在进行写操作时锁的粒度更小,可以允许多个修改操作并发进行

对于用户的在线状态,用户可能在 游戏大厅 中,也可能在 游戏房间 中,因此,我们创建两个 ConcurrentHashMap,分别对 游戏大厅用户在线状态游戏房间用户在线状态 进行管理 

@Component
public class OnlineUserManager {
    /**
     * 游戏大厅用户在线状态
     */
    private ConcurrentHashMap<Long, WebSocketSession> hallMap = new ConcurrentHashMap<>();
    /**
     * 游戏房间用户在线状态
     */
    private ConcurrentHashMap<Long, WebSocketSession> roomMap = new ConcurrentHashMap<>();

}

OnlineUserManager 需要提供的主要功能有: 

当玩家建立好 WebSocket 连接时(进入游戏大厅 / 游戏房间),将键值对加入到OnlineUserManager 中

当玩家断开 WebSocket 连接时(离开游戏大厅 / 游戏房间),将键值对从 OnlineUserManager 中删除

在玩家连接建立好之后,能够随时通过 userId 来查询到对应的会话,以便向客户端返回数据

@Component
public class OnlineUserManager {
    /**
     * 游戏大厅用户在线状态
     */
    private ConcurrentHashMap<Long, WebSocketSession> hallMap = new ConcurrentHashMap<>();
    /**
     * 游戏房间用户在线状态
     */
    private ConcurrentHashMap<Long, WebSocketSession> roomMap = new ConcurrentHashMap<>();

    /**
     * 用户进入游戏房间,将用户信息存储到 roomMap 中
     * @param userId
     * @param session
     */
    public void enterGameRoom(Long userId, WebSocketSession session) {
        roomMap.put(userId, session);
    }

    /**
     * 用户退出游戏房间,将用户信息从 roomMap 中删除
     * @param userId
     */
    public void exitGameRoom(Long userId) {
        roomMap.remove(userId);
    }

    /**
     * 从 roomMap 中获取用户信息
     * @param userId
     * @return
     */
    public WebSocketSession getFromRoom(Long userId) {
        return roomMap.get(userId);
    }

    /**
     * 用户进入游戏大厅,将用户信息存储到 hallMap 中
     * @param userId
     * @param session
     */
    public void enterGameHall(Long userId, WebSocketSession session) {
        hallMap.put(userId, session);
    }

    /**
     * 用户退出游戏大厅,将用户信息从 hallMap 中删除
     * @param userId
     */
    public void exitGameHall(Long userId) {
        hallMap.remove(userId);
    }

    /**
     * 从 hallMap 中获取用户信息
     * @param userId
     * @return
     */
    public WebSocketSession getFromHall(Long userId) {
        return hallMap.get(userId);
    }
}

创建请求/响应对象

根据约定的前后端交互接口,来创建对应的请求/响应对象:

请求:

@Data
public class MatchParam implements Serializable {
    /**
     * 匹配信息
     */
    private String message;
}

响应:

@Data
public class MatchResult implements Serializable {
    /**
     * 匹配结果
     */
    private String matchMessage;
    private Rival rival;

    @Data
    public static class Rival {
        /**
         * 对手姓名
         */
        private String name;
        /**
         * 天梯分数
         */
        private Long score;
    }

    public MatchResult() {}
    public MatchResult(String matchMessage) {
        this.matchMessage = matchMessage;
    }
}

创建好之后 ,我们就可以开始处理 WebSocket 连接建立成功后的业务逻辑了

处理连接成功

 连接建立后(afterConnectionEstablished)需要处理的业务逻辑:

1. 从 session 中获取登录时存储的用户信息(UserInfo)

2. 使用 OnlineUserManager 来管理用户状态

3. 判断当前用户是否已经在线

4. 设置玩家的上线状态

其中,在设置玩家的上线状态之前,需要先判定用户是否已经在线,从而防止用户多开

什么是多开?

一个用户,同时打开多个浏览器,同时进行登录,进入游戏大厅/游戏房间,也就是一个账号登录两次

那么,多开会造成什么问题呢?

例如:

 

当 浏览器1 与 服务器 建立 WebSocket  连接成功时,服务器会在 OnlineUserManager 中保存键值对 userId: 1, WebSocketSession: Session1

而当 浏览器2 与 服务器 建立 WebSocket 连接成功时,服务器也会在 OnlineUserManager 中保存键值对  userId: 1, WebSocketSession: Session2

上述两次连接建立成功后,哈希表中存储的 key 是相同的,因此,后一次的 value(session2)会覆盖之前的 value(session1)

这种覆盖就会导致第一个浏览器的连接虽然未断开,但是服务器已经拿不到对应的 session 了,也就无法向这个浏览器推送数据

那么,应该如何禁止多开呢?

需要实现 账号登录成功之后,禁止该账号在其他地方再次登录,因此,在 WebSocket 连接建立之后,需要判断当前账号是否已经登录过(处于在线状态),若处于在线状态,则断开此次连接

    @Autowired
    private OnlineUserManager onlineUserManager;
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 从 session 中获取用户信息
        UserInfo userInfo = (UserInfo) session.getAttributes().get(USER_INFO);
        // 用户是否登录
        if (null == userInfo) {
            session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
                    CommonResult.noLogin())));
            return;
        }
        // 判断用户是否处于在线状态
        if (null != onlineUserManager.getFromHall(userInfo.getUserId())) {
            session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
                    CommonResult.repeatConnection())));
            return;
        }
        // 将用户设置为在线状态
        onlineUserManager.enterGameHall(userInfo.getUserId(), session);
        // 日志打印
        log.info("玩家 {} 进入游戏大厅", userInfo.getUserName());
    }

在使用 WebSocket sendMessage 方法发送数据时,先将需要返回的数据封装为 CommonResult 对象,再使用 JacksonUtil writeValueAsString 方法将 CommonResult 对象转化为 JSON 字符串,然后再包装上一层 TextMessage(表示一个文本格式的 WebSocket 数据包),进行传输

之前在实现用户登录时,我们在 UserService 中定义 session 的 key 为 USER_INFO,我们将其导入进来:

import static com.example.gobang_system.service.UserService.USER_INFO;

此外,我们约定 code 402 时,用户尝试多开,在 CommonResult 中添加方法:

    public static <T> CommonResult<T> repeatConnection() {
        CommonResult result = new CommonResult();
        result.code = 402;
        result.errorMessage = "禁止多开游戏界面!";
        return result;
    }

连接建立成功后,我们就可以处理 开始/ 结束 匹配请求

处理开始/结束匹配请求

开始/结束匹配请求在 handleTextMessage 中进行处理

handleTextMessage 实现:

1. 从 session 中拿到玩家信息

2. 解析客户端发送的请求

3. 判断请求类型,若是 START,则将玩家加入到匹配队列中;若是 STOP,则将玩家从匹配队列中删除

为了方便对用户匹配状态的管理,我们可以创建一个枚举类 MatchStatusEnum

@AllArgsConstructor
@Getter
public enum MatchStatusEnum {
    START(1, "开始匹配"),
    STOP(2, "停止匹配"),
    SUCCESS(3, "匹配成功");

    private final Integer code;
    private final String message;

    public static MatchStatusEnum forName(String name) {
        for (MatchStatusEnum matchStatus : MatchStatusEnum.values()) {
            if (matchStatus.name().equalsIgnoreCase(name)) {
                return matchStatus;
            }
        }
        return null;
    }
}

此外,我们还需要一个 匹配器(Matcher),来处理匹配的具体逻辑

    @Autowired
    private Matcher matcher;
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 处理匹配请求和停止匹配请求
        UserInfo userInfo = (UserInfo) session.getAttributes().get(USER_INFO);
        // 用户是否登录
        if (null == userInfo) {
            session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
                    CommonResult.noLogin())));
            return;
        }
        // 获取用户端发送的数据
        String payload = message.getPayload();
        // 将 JSON 字符串转化为 java 对象
        MatchParam matchParam = JacksonUtil.readValue(payload, MatchParam.class);
        if (MatchStatusEnum.START.name().equalsIgnoreCase(matchParam.getMessage())) {
            // 开始匹配
            matcher.addUserInfo(userInfo);
            session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
                    CommonResult.success(new MatchResult(MatchStatusEnum.START.name())))));
        } else if (MatchStatusEnum.STOP.name().equalsIgnoreCase(matchParam.getMessage())) {
            // 结束匹配
            matcher.removeUserInfo(userInfo);
            session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
                    CommonResult.success(new MatchResult(MatchStatusEnum.STOP.name())))));
        } else {
            session.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
                    CommonResult.fail(400, "错误的匹配信息"))));
        }
    }

接下来,我们就来实现匹配器

Matcher

在 Matcher 中创建三个队列(队列中存储 UserInfo 对象),分别表示不同段位的玩家(约定 score < 2000 一档;2000 <= score < 3000 一档,3000 <= score 一档)

提供 add 方法,供 MatcherHandler 调用,用于将玩家加入匹配队列

提供 remover 方法,供 MatcherHandler 调用,用于将玩家移除匹配队列

@Component
@Slf4j
public class Matcher {
    @Autowired
    private OnlineUserManager onlineUserManager;
    public static final int HIGH_SCORE = 2000;
    public static final int VERY_HIGH_SCORE = 3000;

    // 创建三个队列,分别用于匹配不同天梯分数的玩家
    private Queue<UserInfo> normalQueue = new LinkedList<>(); // score < 2000
    private Queue<UserInfo> highQueue = new LinkedList<>(); // 2000 <= score < 3000
    private Queue<UserInfo> veryHighQueue = new LinkedList<>(); // 3000 <= score

    /**
     * 将玩家放入匹配队列
     * @param userInfo
     */
    public void addUserInfo(UserInfo userInfo) {
        // 参数校验
        if (null == userInfo) {
            return;
        }
        // 放入对应队列进行匹配
        if (userInfo.getScore() < HIGH_SCORE) {
            offer(normalQueue, userInfo);
        } else if (userInfo.getUserId() >= HIGH_SCORE
                && userInfo.getUserId() < VERY_HIGH_SCORE) {
            offer(highQueue, userInfo);
        } else {
            offer(veryHighQueue, userInfo);
        }
    }

    /**
     * 将玩家从匹配队列中移除
     * @param userInfo
     */
    public void removeUserInfo(UserInfo userInfo) {
        // 参数校验
        if (null == userInfo) {
            return;
        }
        // 从对应队列中移除玩家
        if (userInfo.getScore() < HIGH_SCORE) {
            remove(normalQueue, userInfo);
        } else if (userInfo.getUserId() >= HIGH_SCORE
                && userInfo.getUserId() < VERY_HIGH_SCORE) {
            remove(highQueue, userInfo);
        } else {
            remove(veryHighQueue, userInfo);
        }
    }
}

 注意,由于是在 多线程情况 下进行 添加元素 和 移除元素 操作,因此在将玩家加入匹配队列和将玩家从匹配队列移除时,都需要对其操作进行加锁(直接对队列进行加锁即可)

此外,当有玩家加入到队列中时,需要唤醒对应线程从而进行匹配

offer:

    /**
     * 将玩家放入匹配队列中
     * @param queue
     * @param userInfo
     */
    private void offer(Queue<UserInfo> queue, UserInfo userInfo) {
        try {
            synchronized (queue) {
                // 将玩家添加到队列中
                queue.offer(userInfo);
                // 唤醒线程进行匹配
                queue.notify();
            }
        } catch (Exception e) {
            log.warn("向队列 {} 中添加玩家 {} 异常, e: ", queue.getClass().getName(),
                    userInfo.getUserName(), e);
        }
    }

 remove:

   /**
     * 从队列中移除玩家信息
     * @param queue
     * @param userInfo
     */
    private void remove (Queue<UserInfo> queue, UserInfo userInfo) {
        try {
            synchronized (queue) {
                // 将玩家从队列中移除
                queue.remove(userInfo);
            }
        } catch (Exception e) {
            log.warn("从队列 {} 中移除玩家 {} 异常, e: ", queue.getClass().getName(),
                    userInfo.getUserName(), e);
        }
    }

Matcher 的构造方法中,创建三个线程,用来扫描对应队列,将每个队列中的头两个元素取出来,匹配到一组:

    public Matcher() {
        // 创建扫描线程,进行匹配
        Thread normalThread = new Thread(() -> {
            while (true) {
                handlerMatch(normalQueue);
            }
        });

        Thread highThread = new Thread(() -> {
            while (true) {
                handlerMatch(highQueue);
            }
        });

        Thread veryHighThread = new Thread(() -> {
            while (true) {
                handlerMatch(veryHighQueue);
            }
        });

        // 启动线程
        normalThread.start();
        highThread.start();
        veryHighThread.start();

    }

实现 handlerMatch

由于handlerMatch 在单独的线程中调用,也需要考虑到访问队列的线程安全问题,因此,也需要对其进行加锁

在入口处使用 wait 进行等待,直到队列中存在两个以上的元素,唤醒线程消费队列

    private void handlerMatch(Queue<UserInfo> queue) {
        try {
            synchronized (queue) {
                // 若队列中为空 或 队列中只有一个玩家信息,阻塞等待
                while (queue.size() <= 1) {
                    queue.wait();
                }
                // 取出两个玩家进行匹配
                UserInfo user1 = queue.poll();
                UserInfo user2 = queue.poll();
            }
        } catch (InterruptedException e) {
            log.warn("Matcher 被提前唤醒", e);
        } catch (Exception e) {
            log.error("Matcher 处理异常", e);
        }
    }

若队列中两个玩家匹配成功,此时需要获取玩家对应的 session,从而通知两个玩家匹配成功

但在通知之前,需要判断两个玩家是否都在线

理论上来,匹配队列中的玩家一定是在线状态(前面的逻辑中进行了处理,当玩家断开连接时就将玩家从匹配队列中移除了),但这里再进行一次判断,避免前面的逻辑出现问题时带来的严重后果

1. 若两个玩家都已下线,此次匹配结束

2. 若一个玩家下线,则将另一个玩家放回匹配队列中重新进行匹配

3. session1 = session2,也就是得到的两个 session 相同,说明同一个玩家两次进入匹配队列,此时也需要将玩家放回匹配队列中

上述 session1 = session2 的情况理论上也不会出现,在之前的逻辑中,当用户下线时,就将其从匹配队列中移除,且禁止了用户多开,在此处再次进行校验,也是为了避免前面的逻辑出现问题时带来的严重后果

                // 判断两个玩家当前是否都在线
                if (null == session1 && null == session2) {
                    // 两个玩家都已下线
                    return;
                }
                if (null == session1) {
                    // 玩家1 已下线, 将 玩家2 重新放回匹配队列
                    queue.offer(user2);
                    log.info("玩家 {} 重新进行匹配", user2.getUserName());
                    return;
                }
                if (null == session2) {
                    // 玩家2 已下线, 将 玩家1 重新放回匹配队列
                    queue.offer(user1);
                    log.info("玩家 {} 重新进行匹配", user1.getUserName());
                    return;
                }
                if (session1 == session2) {
                    // 两个 session 相同,同一个玩家两次进入匹配队列
                    queue.offer(user1);
                    return;
                }

若两个不同的玩家都在线,此时需要将这两个玩家放入同一个游戏房间(后续实现),并向匹配成功的两个玩家发送响应数据:

                // TODO 为上述匹配成功的两个玩家创建游戏房间

                // 通知玩家匹配成功
                session1.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
                        CommonResult.success(convertToMatchSuccessResult(user2)))));
                session2.sendMessage(new TextMessage(JacksonUtil.writeValueAsString(
                        CommonResult.success(convertToMatchSuccessResult(user1)))));
            }

构造匹配成功响应类型: 

    private MatchResult convertToMatchSuccessResult(UserInfo rivalInfo) {
        // 参数校验
        if (null == rivalInfo) {
            return null;
        }
        // 构造 MatchResult
        MatchResult.Rival rival = new MatchResult.Rival();
        rival.setName(rivalInfo.getUserName());
        rival.setScore(rivalInfo.getScore());
        MatchResult result = new MatchResult();
        result.setMatchMessage(MatchStatusEnum.SUCCESS.name());
        result.setRival(rival);
        // 返回
        return result;
    }

处理连接关闭

afterConnectionClosed 连接断开时会将玩家从 onlineUserManager 中移除

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        log.info("匹配连接断开, code: {}, reason: {}",
                status.getCode(), status.getReason());
        // 玩家下线
        logoutFromHall(session);
    }

当连接出现异常时,也需要将玩家从 onlineUserManager 中移除,因此,我们在 logoutFromHall() 方法中实现对应逻辑

    private void logoutFromHall(WebSocketSession session) {
        try {
            if (null == session) {
                return;
            }
            UserInfo userInfo = (UserInfo) session.getAttributes().get(USER_INFO);
            if (null == userInfo) {
                return;
            }
            onlineUserManager.exitGameHall(userInfo.getUserId());
            matcher.removeUserInfo(userInfo);
            log.info("玩家 {} 从游戏大厅退出", userInfo.getUserName());
        } catch (Exception e) {
            log.warn("玩家 {} 退出游戏大厅时发生异常e: ", e);
        }
    }

在 连接建立成功 时,我们对用户的在线状态进行了判定:若玩家已经登录过了,此时就不能再进行登录了,返回对应响应,客户端接收到消息时,就会关闭 WebSocket 连接

而在 WebSocket 连接关闭过程中,会触发 afterConnectionClosed,从而调用 logoutFromHall 方法,但在方法中,会调用 onlineUserManager.exitGameHall(userInfo.getUserId()) 方法,将玩家从 游戏大厅 中删除,但是

当 浏览器1 与 服务器 建立 WebSocket  连接成功时,服务器会在 OnlineUserManager 中保存键值对 userId: 1, WebSocketSession: Session1

而当 浏览器2 与 服务器 建立  WebSocket  连接成功时,由于我们判定其为多开情况,因此未在 OnlineUserManager 保存键值对 userId: 1, WebSocketSession: Session2

但是从 session1 session2 中获取到的 userId 都为 1

此时,在 断开 浏览器2 与 服务器 之间的连接时,就会将  OnlineUserManager 中保存键值对 userId: 1, WebSocketSession: Session1 删除,此时也就出现了异常

因此,在断开连接时,需要进行进一步处理,从而保证在断开多开连接时,不影响原有连接

获取  OnlineUserManager 中保存的 session 信息(onlineSession),判断其与当前需要断开的 session 是否相同,若相同,则移除对应 session 信息,若不同,则不进行移除

    private void logoutFromHall(WebSocketSession session) {
        try {
            if (null == session) {
                return;
            }
            UserInfo userInfo = (UserInfo) session.getAttributes().get(USER_INFO);
            if (null == userInfo) {
                return;
            }
            // 存储的 session
            WebSocketSession onlineSession = onlineUserManager.getFromHall(userInfo.getUserId());
            // 判断获取到的 session 信息 与 onlineUserManager 中存储的 session 是否相同
            // 避免关闭多开时将玩家信息错误删除
            if (session == onlineSession) {
                // 将玩家从游戏大厅移除
                onlineUserManager.exitGameHall(userInfo.getUserId());
            }
            // 若玩家正在进行匹配,而 WebSocket 连接断开
            // 需要将其从匹配队列中移除
            matcher.removeUserInfo(userInfo);
            log.info("玩家 {} 从游戏大厅退出", userInfo.getUserName());
        } catch (Exception e) {
            log.warn("玩家 {} 退出游戏大厅时发生异常e: ", e);
        }
    }

 

处理连接异常

连接异常时与连接关闭时的处理逻辑基本相同:

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 打印错误信息
        log.error("匹配过程中出现异常: ", exception);
        logoutFromHall(session);
    }

创建游戏房间

接着,我们需要继续完成匹配成功时,为两个玩家创建游戏房间相关逻辑

我们创建 Room 类,表示游戏房间,游戏房间需要包含:

1. 房间 ID,房间 ID 必须是唯一值,作为房间的唯一身份标识,因此,可以使用 UUID 作为房间ID

2. 对弈玩家双方信息(user1 和 user2)

3. 记录先手方 ID

4. 使用一个二维数组,作为对弈的棋盘

@Data
public class Room {
    private static final int MAX_ROW = 15;
    private static final int MAX_COL = 15;
    /**
     * 房间 id
     */
    private String roomId;
    /**
     * 玩家1
     */
    private UserInfo user1;
    /**
     * 玩家2
     */
    private UserInfo user2;
    /**
     * 先手玩家 id
     */
    private Long whiteUserId;
    /**
     * 棋盘
     */
    private int[][] board = new int[MAX_ROW][MAX_COL];

    public Room() {
        // 使用 uuid 作为房间唯一标识
        roomId = UUID.randomUUID().toString();
    }
}

每当两个玩家匹配成功时,都会创建一个游戏房间,即 Room 对象会存在很多

因此,我们需要创建一个管理器来管理所有的 Room

可以使用一个 Hash 表来保存所有的房间对象,key 为 roomId,value 为 Room 对象,方便通过 房间 id 找到对应房间信息

再使用一个 Hash 表来保存 userId -> roomId 的映射,方便根据玩家 id 查找对应房间

@Component
public class RoomManager {
    /**
     * key: roomId
     * value: Room 对象
     */
    private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
    /**
     * key: userId
     * value: roomId
     * 方便根据玩家查询对应房间
     */
    private ConcurrentHashMap<Long, String> userIdToRoomId = new ConcurrentHashMap<>();
}

提供对应的增、删、查方法:

@Component
public class RoomManager {
    /**
     * key: roomId
     * value: Room 对象
     */
    private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
    /**
     * key: userId
     * value: roomId
     * 方便根据玩家查询对应房间
     */
    private ConcurrentHashMap<Long, String> userIdToRoomId = new ConcurrentHashMap<>();

    /**
     * 创建游戏房间
     * @param room
     * @param userId1
     * @param userId2
     */
    public void add(Room room, Long userId1, Long userId2) {
        rooms.put(room.getRoomId(), room);
        userIdToRoomId.put(userId1, room.getRoomId());
        userIdToRoomId.put(userId2, room.getRoomId());
    }

    /**
     * 删除游戏房间
     * @param roomId
     * @param userId1
     * @param userId2
     */
    public void remove(String roomId, Long userId1, Long userId2) {
        rooms.remove(roomId);
        userIdToRoomId.remove(userId1);
        userIdToRoomId.remove(userId2);
    }

    /**
     * 通过房间号获取游戏房间
     * @param roomId
     * @return
     */
    public Room getRoomByRoomId(String roomId) {
        return rooms.get(roomId);
    }

    /**
     * 通过玩家 id 获取游戏房间
     * @param userId
     * @return
     */
    public Room getRoomByUserId(Long userId) {
        String roomId = userIdToRoomId.get(userId);
        if (null == roomId) {
            return null;
        }
        return rooms.get(roomId);
    }
}

在 Matcher 中注入 RoomManager:

    @Autowired
    private RoomManager roomManager;

完善匹配逻辑: 

                // 为上述匹配成功的两个玩家创建游戏房间
                Room room = new Room();
                roomManager.add(room, user1.getUserId(), user2.getUserId());

修改前端逻辑

在前面,对于 code != 200 的情况,我们直接让页面跳转到登录页面,在这里,我们对其进行更进一步的处理:

code = 401:跳转到登录页面

code = 402:提示用户多开

code 为其他值:打印异常信息

        webSocket.onmessage = function(e) {
            // 处理服务器返回的响应数据
            let resp = JSON.parse(e.data);
            console.log(resp);
            let matchButton = document.querySelector("#match-button");
            if (resp.code != 200) {
                if (resp.code == 401) {
                    // 用户未登录
                    
                } else if (resp.code == 402) {
                    alert("检测到当前账号游戏多开!请检查登录情况!");
                   
                } else {
                    alert("异常情况:" + resp.errorMessage);
                }
                location.replace("/login.html");
                return;
            }
            if (resp.data.matchMessage == 'START') {
                console.log("成功进入匹配队列");
                matchButton.innerHTML = "匹配中...(点击停止)";
            } else if (resp.data.matchMessage == 'STOP') {
                console.log("结束匹配");
                matchButton.innerHTML = "开始匹配";
            } else if (resp.data.matchMessage == 'SUCCESS') {
                // 成功匹配到对手, 进入游戏房间
                console.log(resp.data.rival);
                alert("匹配到对手:" + resp.data.rival.name + " 分数:" + resp.data.rival.score);
                location.replace("/game_room.html");
            } else {
                console.log("接收到非法响应!" + resp.data);
            }
        }

验证匹配功能

实现完成后,运行程序,验证匹配功能是否正常

使用两个浏览器(或是无痕式窗口),登录两个账号:

点击开始匹配,观察打印信息:

结束匹配:

再新开一个窗口,尝试登录上述其中一个账号:

点击确定,跳转到登录页面:

 两个玩家都点击开始匹配:

匹配成功,显示对手相关信息,点击确定,跳转到游戏房间页面:

​​​​​​​

至此,我们的匹配功能就基本实现完成了 


http://www.kler.cn/a/551982.html

相关文章:

  • 基于暗通道先验的图像去雾算法解析与实现
  • 百度地图接入DeepSeek技术解析:AI如何重塑地图搜索体验?
  • 远离手机APP——数字排毒,回归生活本真
  • 深度学习-1.简介
  • 基于指纹识别技术的考勤打卡设计与实现(论文+源码)
  • Day4:强化学习之Qlearning走迷宫
  • WPF的Prism框架的使用
  • XML Schema anyAttribute 元素详解
  • cmake:定位Qt的ui文件
  • 【JavaEE进阶】MyBatis通过注解实现增删改查
  • 在 Visual Studio 中使用 C++ 利用 dump 文件查找问题原因和崩溃点
  • 【python】4_异常
  • [LeetCode力扣hot100]-链表
  • 扩散模型中的马尔可夫链设计演进:从DDPM到Stable Diffusion全解析
  • labelimg的xml文件转labelme的json文件
  • Android ListPreference使用
  • 图床 PicGo+GitHub+Typora的下载安装与使用
  • 基于 Spring Cloud + Sentinel 的全面流量治理方案
  • fatal: Out of memory, malloc failed (tried to allocate 524288000 bytes)
  • 《DeepSeek 一站式工作生活 AI 助手》