【项目】玄策五子——匹配模块


玄策五子
“玄策” 取自深远谋略,搭配 “五子” 点明项目核心,兼具古风与智慧感。

项⽬背景

实现⼀个⽹⻚版五⼦棋对战程序.
⽀持以下核⼼功能:
• ⽤⼾模块: ⽤⼾注册, ⽤⼾登录, ⽤⼾天梯分数记录, ⽤⼾⽐赛场次记录.
• 匹配模块: 按照⽤⼾的天梯分数实现匹配机制.
• 对战模块: 实现两个玩家在⽹⻚端进⾏五⼦棋对战的功能.

在这里插入图片描述

核⼼技术

• Spring/SpringBoot/SpringMVC
• WebSocket
• MySQL
• MyBatis
• HTML/CSS/JS/AJAX

需求分析和概要设计

整个项⽬分成以下模块
• ⽤⼾模块
• 匹配模块
• 对战模块
用户模块
⽤⼾模块主要负责⽤⼾的注册, 登录, 分数记录功能.
使⽤ MySQL 数据库存储数据.
客⼾端提供⼀个登录⻚⾯+注册⻚⾯.
服务器端基于 Spring + MyBatis 来实现数据库的增删改查.
匹配模块
⽤⼾登录成功, 则进⼊游戏⼤厅⻚⾯.
游戏⼤厅中, 能够显⽰⽤⼾的名字, 天梯分数, ⽐赛场数和获胜场数.
同时显⽰⼀个 “匹配按钮”.
点击匹配按钮则⽤⼾进⼊匹配队列, 并且界⾯上显⽰为 “取消匹配” .
再次点击则把⽤⼾从匹配队列中删除.
如果匹配成功, 则跳转进⼊到游戏房间⻚⾯.
⻚⾯加载时和服务器建⽴ websocket 连接. 双⽅通过 websocket 来传输 “开始匹配”, “取消匹配”, “匹配成功” 这样的信息.
对战模块
玩家匹配成功, 则进⼊游戏房间⻚⾯.
每两个玩家在同⼀个游戏房间中.
在游戏房间⻚⾯中, 能够显⽰五⼦棋棋盘. 玩家点击棋盘上的位置实现落⼦功能.
并且五⼦连珠则触发胜负判定, 显⽰ “你赢了” “你输了”.
⻚⾯加载时和服务器建⽴ websocket 连接. 双⽅通过 websocket 来传输 “准备就绪”, “落⼦位置”, “胜负” 这样的信息.
• 准备就绪: 两个玩家均连上游戏房间的 websocket 时, 则认为双⽅准备就绪.
• 落⼦位置: 有⼀⽅玩家落⼦时, 会通过 websocket 给服务器发送落⼦的⽤⼾信息和落⼦位置, 同时服务器再将这样的信息返回给房间内的双⽅客⼾端. 然后客⼾端根据服务器的响应来绘制棋⼦位置.
• 胜负: 服务器判定这⼀局游戏的胜负关系. 如果某⼀⽅玩家落⼦, 产⽣了五⼦连珠, 则判定胜负并返回胜负信息. 或者如果某⼀⽅玩家掉线(⽐如关闭⻚⾯), 也会判定对⽅获胜.

匹配模块

让多个用户,在游戏大厅中能够进行匹配,系统会把实力相近的两个玩家凑成一桌,进行对战~

前后端交互接口

匹配这样的功能,也是依赖消息推送机制的~~
在这里插入图片描述
玩家发送匹配请求,这个事情是确定(点击了匹配按钮,就会发送匹配请求)
服务器啥时候告知玩家匹配结果(你到底排到了谁?)就需要等待匹配结束的时候才能告知~
正是因为服务器自己也不确定,啥时候能够告知玩家匹配的结果,因此就需要依赖消息推送机制。
当服务器这里匹配成功之后,就主动的告诉当前排到的所有玩家,“你排到了"

接下来约定的前后端交互接口,也都是基于websocket来展开的~
websocket可以传输文本数据,也能传输二进制数据~
此处就直接设计成让websocket传输json格式的文本数据即可

连接:
ws://127.0.0.1:8080/findMatch

请求:
客户端通过websocket给服务器发送一个json格式的文本数据
{
message: ‘startMatch’ / ‘stopMatch’, //开始/结束匹配
}

在通过websocket传输请求数据的时候,数据中是不必带有用户身份信息~
当前用户的身份信息,在前面登录完成后,就已经保存到HttpSession中了
websocket里,也是能拿到之前登录好的HttpSession中的信息的.

响应1: (收到请求后⽴即响应)
{
ok: true, // 是否成功. ⽐如⽤⼾ id 不存在, 则返回 false
reason: ‘’, // 错误原因
message: ‘startMatch’ / ‘stopMatch’
}

这个响应是客户端给服务器发送匹配请求之后,服务器立即返回的匹配响应

响应2: (匹配成功后的响应)
{
ok: true, // 是否成功. ⽐如⽤⼾ id 不存在, 则返回 false
reason: ‘’, // 错误原因
message: ‘matchSuccess’,
}

这个响应是真正匹配到对手之后,服务器主动推送回来的信息~
匹配到的对手不需要在这个响应中体现,仍然都放到服务器这边来保存即可~

备注:
• ⻚⾯这端拿到匹配响应之后, 就跳转到游戏房间.
• 如果返回的响应 ok 为 false, 则弹框的⽅式显⽰错误原因, 并跳转到登录⻚⾯.

客⼾端开发

实现⻚⾯基本结构

先来实现匹配页面,游戏大厅
在这里插入图片描述
创建 game_hall.html
主要包含
• #screen ⽤于显⽰玩家的分数信息
• button#match-button 作为匹配按钮.

<div class="nav">
	联机五⼦棋
</div>
<div class="container">
	<div>
 		<div id="screen"></div>
 		<button id="match-button">开始匹配</button>
	</div>
</div>

创建 game_hall.css

#screen {
    width: 400px;
    height: 200px;
    font-size: 20px;
    background-color: gray;
    color: white;
    border-radius: 10px;

    text-align: center;
    line-height: 100px;
}

#match-button {
    width: 400px;
    height: 50px;
    font-size: 20px;
    color: white;
    background-color: orange;
    border: none;
    outline: none;
    border-radius: 10px;

    text-align: center;
    line-height: 50px;
    margin-top: 20px;
}

#match-button:active {
    background-color: gray;
}

编写 JS 代码, 获取到⽤⼾信息.

<script src="js/jquery.min.js"></script>
<script>
	 $.ajax({
		 method: 'get',
		 url: '/userInfo',
		 success: function(data) {
			 let screen = document.querySelector('#screen');
			 screen.innerHTML = '玩家: ' + data.username + ', 分数: ' + 
			 data.score + "<br> ⽐赛场次: " + data.totalCount + ", 获胜场次: " + data.winCount;
	 	}
	 });
</script>

实现匹配功能

编辑 game_hall.htmljs 部分代码.
• 点击匹配按钮, 就会进⼊匹配逻辑. 同时按钮上提⽰ “匹配中…(点击停止)” 字样.
• 再次点击匹配按钮, 则会取消匹配.
• 当匹配成功后, 服务器会返回匹配成功响应, ⻚⾯跳转到 game_room.html

// 1. 和服务器建⽴连接. 路径要写作 /findMatch, 不要写作 /findMatch/
// 此处进行初始化 websocket, 并且实现前端的匹配逻辑. 
let websocket = new WebSocket('ws://127.0.0.1:8080/findMatch');
        
// 2. 点击开始匹配
// 给匹配按钮添加一个点击事件
let matchButton = document.querySelector('#matchButton');
matchButton.onclick = function() {
	// 在触发 websocket 请求之前, 先确认下 websocket 连接是否好着呢
	if (websocket.readyState == websocket.OPEN) {
		// 如果当前 readyState 处在 OPEN 状态, 说明连接好着的
        // 这里发送的数据有两种可能, 开始匹配/停止匹配
        	if(matchButton.innerHTML == '开始匹配'){
            	console.log("开始匹配");
                websocket.send(JSON.stringify({ // JS对象转JSON字符串
                	message: 'startMatch',
                }));
			}else if(matchButton.innerHTML == '匹配中···(点击停止)'){
            	console.log("停止匹配");
                websocket.send(JSON.stringify({
	            	message:'stopMatch',
                }));
			}
		}else{
        // 这是说明连接当前是异常的状态
        	alert("当前您的连接已断开!请重新登录!");
            location.assign('/login.html');
        }
	}

    // 3. 处理服务器的响应
    websocket.onmessage = function(e) {
    	// 处理服务器返回的响应数据. 这个响应就是针对 "开始匹配" / "结束匹配" 来对应的
        // 解析得到的响应对象. 返回的数据是一个 JSON 字符串, 解析成 js 对象
        let resp = JSON.parse(e.data); // JSON字符串转JS对象
        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 = '开始匹配';
		}else if(resp.message == ' matchSuccess'){
			// 已经匹配到对手了
            console.log("匹配到对手!进入游戏房间!");
            location.assign("/game_room.html");
		}else{
            console.log("受到了非法的响应!message=" + resp.message);
            }
        }

        websocket.onopen = function() {
            console.log("onopen");
        } 
        websocket.onclose = function() {
            console.log("onclose");
        }
        websocket.onerror = function() {
            console.log("onerror");
        }
	// 4. 监听窗⼝关闭事件,当窗⼝关闭时,主动去关闭websocket连接,防⽌连接还没断开就关闭窗⼝,server端会抛异常。
    window.onbeforeunload = function() {  // 监听页面关闭事件. 在页面关闭之前, 手动调用这里的 websocket 的 close 方法. 
        websocket.close;
    }

用到的知识

  1. 当我们修改了css样式/js文件之后,往往要在浏览器中使用ctrl+f5强制刷新,才能生效
    否则浏览器可能仍然在执行旧版本的代码~~(浏览器自带缓存)
  2. JSON字符串和JavaScript对象的转换
    JSON字符串转成JavaScript对象:JSON.parse
    JavaScript 对象转成JSON字符串:JSON.stringify
    JSON字符串和Java对象的转换
    JSON字符串转成Java 对象:ObjectMapper.readValue
    Java 对象转成JSON字符串:ObjectMapper.writeValueAsString

服务器开发

创建并注册 MatchAPI 类

创建 api.MatchAPI , 继承⾃ TextWebSocketHandler 作为处理 websocket 请求的⼊⼝类.
• 准备好⼀个 ObjectMapper, 后续⽤来处理 JSON 数据.

@Component
public class MatchAPI extends TextWebSocketHandler {
	 private ObjectMapper objectMapper = new ObjectMapper();
	 
	 @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 {
	 }
}

修改 config.WebSocketConfig , 把 MatchAPI 注册进去.

• 在 addHandler 之后, 再加上⼀个 .addInterceptors(new HttpSessionHandshakeInterceptor()) 代码, 这样可以把之前登录过程中往 HttpSession 中存放的数据(主要是 User 对象), 放到 WebSocket 的 session 中. ⽅便后⾯的代码中获取到当前⽤⼾信息.(此处需要能够保存和表示用户的上线状态。)

@Configuration
@EnableWebSocket //让spring框架能认识到这样一个类是配置websocket类,再基于这个类进一步找到Test API,才能真正处理websocket相关的请求
public class WebSocketConfig implements WebSocketConfigurer {
 @Autowired
 private TestAPI testAPI;
 @Autowired
 private MatchAPI matchAPI;
 
 @Override
 public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
 registry.addHandler(testAPI, "/test");
 // 通过 .addInterceptors(new HttpSessionHandshakeInterceptor() 这个操作来把 HttpSession ⾥的属性放到 WebSocket 的 session 中
 // 然后就可以在 WebSocket 代码中 WebSocketSession ⾥拿到 HttpSession 中的attribute.
 registry.addHandler(matchAPI, "/findMatch").addInterceptors(new HttpSessionHandshakeInterceptor());
 }
}

用到的知识

  1. registry.addHandler(matchAPI,"/findMatch").addInterceptors(new HttpSessionHandshakeInterceptor());
    这段代码是在Spring框架中配置WebSocket处理器的一部分,具体作用如下:
    注册MatchAPI处理器:通过registry.addHandler(matchAPI, “/findMatch”)将MatchAPI类注册为WebSocket处理器,处理来自/findMatch路径的WebSocket请求。当客户端连接到/findMatch路径时,将触发MatchAPI类中的方法。
    添加HttpSessionHandshakeInterceptor拦截器:.addInterceptors(new HttpSessionHandshakeInterceptor())的作用是添加一个拦截器,用于在WebSocket握手过程中,将之前登录过程中存储在HttpSession中的用户信息(如User对象)复制到WebSocket的session中。这样,在后续的WebSocket处理过程中,可以方便地获取到当前用户的信息。
    综上所述,这段代码的主要目的是注册一个WebSocket处理器,并确保在WebSocket连接建立时,可以从HttpSession中获取用户信息,以便在WebSocket处理过程中使用。

实现⽤⼾管理器

创建 game.OnlineUserManager 类, ⽤于管理当前⽤⼾的在线状态. 本质上是 哈希表 的结构. key为⽤⼾ id, value 为⽤⼾的 WebSocketSession.
借助这个类, ⼀⽅⾯可以判定⽤⼾是否是在线, 同时也可以进⾏⽅便的获取到 Session 从⽽给客⼾端回话.
• 当玩家建⽴好 websocket 连接, 则将键值对加⼊ OnlineUserManager 中.
• 当玩家断开 websocket 连接, 则将键值对从 OnlineUserManager 中删除.
• 在玩家连接好的过程中, 随时可以通过 userId 来查询到对应的会话, 以便向客⼾端返回数据.
由于存在两个⻚⾯, 游戏⼤厅和游戏房间, 使⽤两个 哈希表 来分别存储两部分的会话.

@Component
public class OnlineUserManager {
 	private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();
 	private ConcurrentHashMap<Integer, WebSocketSession> gameRoom = new ConcurrentHashMap<>();
 	public void enterGameHall(int userId, WebSocketSession session) {	
 		gameHall.put(userId, session);
 	}
 	// 只有当前⻚⾯退出的时候, 能销毁⾃⼰的 session
 	// 避免当⼀个 userId 打开两次 游戏⻚⾯, 错误的删掉之前的会话的问题.
 	public void exitGameHall(int userId) {
 		gameHall.remove(userId);
	}
	public WebSocketSession getSessionFromGameHall(int userId) {
		17 return gameHall.get(userId);
	}

	public void enterGameRoom(int userId, WebSocketSession session) {
		gameRoom.put(userId, session);
	}

	public void exitGameRoom(int userId) {
	gameRoom.remove(userId);
	}

	public WebSocketSession getSessionFromGameRoom(int userId) {
		return gameRoom.get(userId);
	}
}

记得给 MatchAPI 注⼊ OnlineUserManager

@Component
public class MatchAPI extends TextWebSocketHandler {

    @Autowired
    private OnlineUserManager onlineUserManager;
}

发现的小问题
线程安全问题:
头开始是使用HashMap来存储用户的在线状态的. 如果是多线程访问同一个HashMap就容易出现线程安全问题~ 如果同时有多个用户和服务器建立连接/断开连接,此时服务器就是并发的在针对HashMap进行修改~
所以HashMap改成了ConcurrentHashMap

创建匹配请求/响应对象

创建 game.MatchRequest

// 这是表示一个 websocket 的匹配请求
public class MatchRequest {
    private  String message = "";

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

创建 game.MatchResponse

// 这是表示一个 websocket 的匹配响应
public class MatchResponse {
    private  boolean ok;
    private  String reason;
    private String message;

    public boolean isOk() {
        return ok;
    }

    public void setOk(boolean ok) {
        this.ok = ok;
    }

    public String getReason() {
        return reason;
    }

    public void setReason(String reason) {
        this.reason = reason;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }
}

处理连接成功

实现 afterConnectionEstablished ⽅法.
• 通过参数中的 session 对象, 拿到之前登录时设置的 User 信息.
• 使⽤ onlineUserManager 来管理⽤⼾的在线状态.
• 先判定⽤⼾是否是已经在线, 如果在线则直接返回出错 (禁⽌同⼀个账号多开).
• 设置玩家的上线状态.
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
先通过ObjectMapper 把MatchResponse 对象转成JSON字符串,然后在包装上一层TextMessage,再进行传输.
TextMessage就表示一个文本格式的websocket数据包

@Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 玩家上线,加入到 OnlineUserManager 中

        // 1. 先获取到当前用户的身份信息(谁在游戏大厅中, 建立的连接)
        try {
            User user = (User) session.getAttributes().get("user");

            // 2. 先判定当前用户是否已经登录过(已经是在线状态), 如果是已经在线, 就不该继续进行后续逻辑.
                if(onlineUserManager.getFromGameHall(user.getUserId())!=null||onlineUserManager.getFronGameRoom(user.getUserId())!=null){
                // 当前用户已经登录了!!
                // 针对这个情况要告知客户端, 你这里重复登录了.
                MatchResponse response = new MatchResponse();
                response.setOk(false);
                response.setReason("当前禁止多开!");
                session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
                session.close();
                return;
            }
            // 3. 拿到了身份信息之后, 就可以把玩家设置成在线状态了
            onlineUserManager.enterGameHall(user.getUserId(), session);
            System.out.println("玩家" + user.getUsername() + "进入游戏大厅!");
        }catch (NullPointerException e) {
            e.printStackTrace();
            // 出现空指针异常, 说明当前用户的身份信息是空, 用户未登录呢.
            // 把当前用户尚未登录这个信息给返回回去
            MatchResponse response  = new MatchResponse();
            response.setOk(false);
            response.setReason("您尚未登陆!不能进行后续匹配功能!");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
        }

    }

User user = (User) session.getAttributes().get("user");
此处的代码, 之所以能够 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 的情况

发现的小问题

多开问题
一个用户,同时打开多个浏览器,同时进行登录,进入游戏大厅~

在这里插入图片描述
当浏览器1建立websocket请求时,服务器这边就会在OnlineUserManager中保存键值对: userld=1,WebSocketSession=session1
当浏览器2 建立websocket连接时,服务器又会在OnlineUserManager中保存键值对:userld=1,WebSocketSession=session2
这两次连接,尝试往哈希表中存储两个键值对.两个键值对的key是一样的,后来的value会覆盖之前的value。上述这种覆盖,就会导致第一个浏览器的连接“名存实亡"已经拿不到对应的WebSocketSession了,也就无法给这个浏览器推送数据了

多开会产生上述问题,我们的程序是否应该允许多开呢?对于大部分游戏来说,都是禁止多开的,禁止同一个账号在不同的主机上登录!!!
因此我们要做的,不是直接解决会话覆盖的问题,而是要从源头上禁止游戏多开!!!
1)账号登录成功之后,禁止在其他地方再登录(我采取这种方式,更好实现一些)
2)账号登录之后,后续其他位置的登录会把前面的登录给踢掉
在连接建立逻辑这里,做出了判定:如果玩家已经登陆过,就不能再登录,同时关闭websocket连接。

又出现一个小问题:websocket连接关闭的过程中,也会触发afterConnectionclosed,在这个方法里,会有一个exitGameHall这个动作,会按userId把先前登陆存好的正常的键值对删除。
所以断开连接的时候在afterConnectionclosed也做个判定,如果当前要断开连接的session和登陆的是同一个,则exitGameHall断开。同样的,handleTransportError也加个判定
在这里插入图片描述

处理连接关闭

实现 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)));
        }

法二:
或者和后面GameAPI处理方式一样:

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status)
throws Exception {
	 User user = (User) session.getAttributes().get("user");
	 if (user == null) {
		 System.out.println("[onClose] 玩家尚未登录!");
		 return;
	 }
	 WebSocketSession existSession = onlineUserManager.getSessionFromGameHall(user.getUserId());
	 if (existSession != session) {
		 System.out.println("当前的会话不是玩家游戏中的会话, 不做任何处理!");
		 return;
	 }
	 System.out.println("玩家离开匹配⻚⾯: " + user.getUserId());
	 onlineUserManager.exitGameHall(user.getUserId());
	 // 如果玩家在匹配中, 则关闭⻚⾯时把玩家移出匹配队列
	 matcher.remove(user);
}

处理连接异常

实现 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)));
        }
    }

法二:
或者和后面GameAPI处理方式一样:

@Override
public void handleTransportError(WebSocketSession session, Throwable exception)
throws Exception {
	 User user = (User) session.getAttributes().get("user");
	 if (user == null) {
		 System.out.println("[onError] 玩家尚未登录!");
		 return;
	 }
	 WebSocketSession existSession = onlineUserManager.getSessionFromGameHall(user.getUserId());
	 if (existSession != session) {
		 System.out.println("当前的会话不是玩家游戏中的会话, 不做任何处理!");
		 return;
	 }
	 System.out.println("匹配⻚⾯连接出现异常! userId: " + user.getUserId() + ", message: " + exception.getMessage());
	 onlineUserManager.exitGameHall(user.getUserId());
	 // 如果玩家在匹配中, 则关闭⻚⾯时把玩家移出匹配队列
	 matcher.remove(user);
}

一些小问题
处理 WebSocket 连接断开时存在的代码逻辑问题

WebSocket 断开连接后的响应发送逻辑存在问题
在 Match API 中,当 WebSocket 连接已断开时,代码中仍尝试在 catch 块中向客户端发送响应。但由于连接已经关闭,此时发送消息无效,客户端无法接收。

• 1.空指针异常触发场景需合理处理
当获取的用户对象(User)为空时,若继续访问其属性会触发空值异常。这种情况下不应再尝试发送响应,而应直接返回,避免无效操作。
• 2.异常日志信息应清晰且避免误导
原有的打印异常堆栈信息容易让人误以为是系统严重错误,实际该情况属于预期之内(如用户未登录),因此建议替换为更明确的日志内容。

修正 Match API 中的不当逻辑
不应该在连接关闭之后, 还尝试发送消息给客户端,将 Match API 中断开连接后尝试发送响应的代码注释掉,统一修改日志输出格式。将原有的异常堆栈打印替换为自定义日志输出,例如:“Match API.[方法名]:当前用户未登录”,使日志更具可读性和业务意义。
GameAPI 中的处理方式更为合理
在 GameAI 相关逻辑中,发现用户对象为空时直接使用 return 返回,不再进行后续响应发送,符合连接已关闭的实际情况,处理方式正确。
也可以用改为GameAPI 中的处理方式:直接返回,与 GameAPI 保持一致,防止对已关闭的连接执行无效操作。

处理开始匹配/取消匹配请求

实现 handleTextMessage
• 先从会话中拿到当前玩家的信息.
• 解析客⼾端发来的请求
• 判定请求的类型, 如果是 startMatch, 则把⽤⼾对象加⼊到匹配队列. 如果是 stopMatch, 则把⽤⼾对象从匹配队列中删除.
• 此处需要实现⼀个 匹配器 对象, 来处理匹配的实际逻辑.

记得在MatchAPI 类中将后面实现匹配器的Matcher类 matcher对象注入

@Component
public class MatchAPI extends TextWebSocketHandler {

   @Autowired
   private Matcher matcher;
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message)
throws Exception {
	 // 1. 拿到⽤⼾信息.
	 User user = (User) session.getAttributes().get("user");
	 if (user == null) {
		 System.out.println("[onMessage] 玩家尚未登录!");
		 return;
	 }
	 System.out.println("开始匹配: " + user.getUserId() + " message: " + message.toString());
	 // 2. 解析读到的数据为 json 对象
	 MatchRequest request = objectMapper.readValue(message.getPayload(), MatchRequest.class);
	 MatchResponse response = new MatchResponse();
	 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("⾮法的匹配请求!");
	 }
	 session.sendMessage(new
	 TextMessage(objectMapper.writeValueAsString(response)));
}

匹配算法思路

目标:从带匹配的玩家中,选出分数尽量相近的玩家~
把整个所有的玩家,按照分数,划分成三类:
Normal score<2000
High score>=2000 && score < 3000
VeryHigh score >= 3000
给这三个等级,分配三个不同的队列。根据当前玩家的分数,来把这个玩家的用户信息,放到对应的队列里~ 接下来再搞一个专门的线程,去不停的扫描这个匹配队列。只要说队列里的元素(匹配中的玩家)凑成了一对~把这一对玩家取出来,放到一个游戏房间中。
当前的匹配实现,比较粗糙,只是单纯的搞了三个段位的队列~ 如果要想匹配的更加精细,就可以多搞几个队列~

实现匹配器(1)

创建 game.Matcher 类.
• 在 Matcher 中创建三个队列 (队列中存储 User 对象), 分别表⽰不同的段位的玩家. (此处约定 <2000档, 2000-3000 ⼀档, >3000 ⼀档)
• 提供 add ⽅法, 供 MatchAPI 类来调⽤, ⽤来把玩家加⼊匹配队列.
• 提供 remove ⽅法, 供 MatchAPI 类来调⽤, ⽤来把玩家移出匹配队列.
• 同时 Matcher 找那个要记录 OnlineUserManager, 来获取到玩家的 Session.

// 这个类表示 "匹配器", 通过这个类负责完成整个匹配功能
@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;

    // 操作匹配队列的方法.
    // 把玩家放到匹配队列中
    public void add(User user) {
        if (user.getScore()<2000) {
            normalQueue.offer(user);
            System.out.println("把玩家" + user.getUsername() + "加入到了 normalQueue中!");
        } else if (user.getScore()>=2000 && user.getScore()<3000) {
            highQueue.offer(user);
            System.out.println("把玩家" + user.getUsername() + "加入到了 highQueue 中!");
        } else {
            veryhighQueue.offer(user);
            System.out.println("把玩家" + user.getUsername() + "加入到了 veryhighQueue中!");
        }
    }

    // 当玩家点击停止匹配的时候, 就需要把玩家从匹配队列中删除
    public void remove(User user){
        if (user.getScore() < 2000) {
                normalQueue.remove(user);
            System.out.println("把玩家 " + user.getUsername() + " 移除了 normalQueue!");
        } else if (user.getScore() >= 2000 && user.getScore() < 3000) {
            highQueue.remove(user);
            System.out.println("把玩家 " + user.getUsername() + " 移除了 highQueue!");
        } else {
            veryhighQueue.remove(user);
            System.out.println("把玩家 " + user.getUsername() + " 移除了 veryHighQueue!");
        }
    }
}

实现匹配器(2)

修改 game.Matcher , 实现匹配逻辑.
在 Matcher 的构造⽅法中, 创建⼀个线程, 使⽤该线程扫描每个队列, 把每个队列的头两个元素取出来,匹配到⼀组中.

public Matcher() {
    // 创建三个线程, 分别针对这三个匹配队列, 进行操作.
    Thread t1 = new Thread() {
        @Override
        public void run() {
            // 扫描 normalQueue
            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();
}

实现 handlerMatch
• 由于 handlerMatch 在单独的线程中调⽤. 因此要考虑到访问队列的线程安全问题. 需要加上锁.
• 每个队列分别使⽤队列对象本⾝作为锁即可.
• 在⼊⼝处使⽤ wait 来等待, 直到队列中达到 2 个元素及其以上, 才唤醒线程消费队列.

记得在 Matcher 创建ObjectMapper对象

@Component
public class Matcher {

    private ObjectMapper objectMapper;
}
private void handleMatch(Queue<User> matchQueue) {
        synchronized (matchQueue) {
            try {
                // 1. 检测队列中元素个数是否达到 2
                //  保证只有⼀个玩家在队列的时候, 不会被出队列. 从⽽能⽀持取消功能.
                //    队列的初始情况可能是 空
                //    如果往队列中添加一个元素, 这个时候, 仍然是不能进行后续匹配操作的.
                //    因此在这里使用 while 循环检查是更合理的~~
                while (matchQueue.size() < 2) {
                    matchQueue.wait();
                }
                // 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) {
                    // 如果得到的两个 session 相同, 说明是同⼀个玩家两次进⼊匹配队列
                    // 例如玩家点击开始匹配后, 刷新⻚⾯, 重新再点开始匹配
                    // 此时也把玩家放回匹配队列
                    matchQueue.offer(player1);
                    return;
                }

                // 4. 把这两个玩家放到一个游戏房间中.
                // TODO 一会再实现这里

                // 5. 给玩家反馈信息: 你匹配到对手了~
                //    通过 websocket 返回一个 message 为 'matchSuccess' 这样的响应
                //    此处是要给两个玩家都返回 "匹配成功" 这样的信息.
                //    因此就需要返回两次
                MatchResponse response1 = new MatchResponse();
                response1.setOk(true);
                response1.setMessage("matchSuccess");
                session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(response1)));

                MatchResponse response2 = new MatchResponse();
                response2.setOk(true);
                response2.setMessage("matchSuccess");
                session2.sendMessage(new TextMessage(objectMapper.writeValueAsString(response2)));
            } catch (IOException | InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

小的知识点

  1. 这里提醒大家,咱们写代码的时候,要在关键环节多加一些日志。通过一些日志,我们一方面能够更好的理解程序是怎么运行的。另一方面我们也方便去出现问题之后,好去进行调试。这个都是很关键的内容
  2. 还是那句话,我们使用双重校验会更加稳妥,毕竟我们在一个稍微复杂的一些程序里面,这个逻辑可能会比较复杂。我们也不能保证说每一个地方都严谨,可能会有一些漏洞,会有一些bug。 所以我们把可能会产生问题的地方,尽可能充分的进行校验,这样的话可以做更好的一个稳定性。因此我们在这儿也再多做一层判定多做一层判定。

遇到的小问题

  1. 线程安全问题
    使用到多线程代码的时候,一定要时刻注意“线程安全”问题
    在这里插入图片描述
    synchronized
    指定一个“锁对象” ,也就是到底针对谁进行加锁~
    只有多个线程在尝试针对同一个锁对象进行加锁的时候,才会有互斥效果。此处进行加锁的时候,要明确,如果多个线程访问的是不同的队列,不涉及线程安全问题, 必须得是多个线程操作同一个队列,才需要加锁~
    因此在加锁的时候选取的锁对象,就normalQueue,highQueue,veryHighQueue这三个队列对象本身~~
  2. 忙等问题
    如果当前匹配队列中,就只有一个元素,或者没有元素,会出现什么效果呢?
    在这里插入图片描述
    在这个代码中,就会出现handlerMatch一进入方法就快速返回,然后再次进入方法…循环速度飞快,但是却没有啥实质的意义。这个过程中CPU占用率会非常高
    忙等~
    在调用完 handlerMatch之后,加上个sleep(500)?
    这个方案确实可以但是不是很完美。如果使用sleep,意味着当有玩家匹配到之后,可能要500ms之后才能真正得到匹配的返回结果,玩家还是可以感觉到这500ms的。降低玩家延迟反馈的方法:可以通过减小 sleep 时间来加快循环速度,从而减少延迟。将 sleep 值调小(如设为 50),程序循环执行得更快,响应更迅速。产生CPU 占用问题:循环速度越快,CPU 使用率越高,导致系统资源消耗增加。
    两难困境:减小 sleep 虽可提升响应速度,但会显著增加 CPU 负担,难以兼顾性能与效率。
    解决方案:我们学过wait/notify。在扫描线程中,使用wait来等待再合适不过。当真正有玩家进入匹配队列之后,就调用notify来唤醒线程~

多线程环境下如何正确使用 wait 与 notify 机制进行匹配队列的管理
必须使用与加锁对象一致的对象调用 wait 方法
wait 方法的执行包含三个关键步骤:进入 wait 后会先释放持有的锁,进入等待状态,直到收到通知(notify)后被唤醒,并尝试重新获取锁。所以调用 wait 方法时,必须确保该方法作用于当前已加锁的对象(如matchQueue),否则会导致不合法的监视器状态异常;直接调用 wait() 默认作用于 this 对象,若未对 this 加锁则不可行。
wait 可能抛出 InterruptedException,需统一处理异常
原代码中已存在 try-catch 捕获 IOException,现需将 InterruptedException 一并捕获;由于两者均采用打印堆栈的处理方式,可合并为一个 catch 块统一处理。
新增玩家进入匹配队列后应触发对应的通知操作
在 add 方法中,每当有玩家加入 normal queue、high queue 或 very high queue 时,都应分别调用对应队列的 notify 方法,以唤醒正在等待的线程。三个匹配队列独立运行,互不干扰:
normal、high 和 veryhigh 三个队列各自拥有独立的等待和通知逻辑,彼此之间不产生影响。
应使用 while 而非 if 判断等待条件
原先使用 if 判断 “队列元素少于两个则等待“ 存在风险,因为唤醒后条件可能已不再成立;改用 while 循环可在每次唤醒后重新校验条件,确保安全性。使用循环条件检查可避免虚假唤醒带来的问题
需深入理解多线程中的等待-通知机制及其正确写法
匹配功能依赖于精确的线程协作,重点在于掌握 wait/notify 与锁的配合、条件变量的循环检查等核心编程实践,避免并发错误。

遇到的困难
catch无法折叠
在这里插入图片描述
catch块中的处理逻辑要一致,这样才能折叠,否则处理逻辑不一样,怎么合并呢? 到底以哪个的逻辑为准
在这里插入图片描述

需要给上⾯的插⼊队列元素, 删除队列元素也加上锁.
• 插⼊成功后要通知唤醒(notify)上⾯的等待逻辑.

// 操作匹配队列的方法.
    // 把玩家放到匹配队列中
    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!");
        }
    }

创建房间类

匹配成功之后, 需要把对战的两个玩家放到同⼀个房间对象中.
通过匹配的方式,自动给你加入到一个游戏房间。还可以手动创建游戏房间,这一局游戏,进行的“场所”就可以称为是一个“游戏房间”,游戏房间中最关键的信息就是玩家信息~
创建 game.Room
• ⼀个房间要包含⼀个房间 ID, 使⽤ UUID 作为房间的唯⼀⾝份标识.
• 房间内要记录对弈的玩家双⽅信息.
• 记录先⼿⽅的 ID
• 记录⼀个 ⼆维数组 , 作为对弈的棋盘.
• 记录⼀个 OnlineUserManager, 以备后⾯和客⼾端进⾏交互.
• 当然, 少不了 ObjectMapper 来处理 json

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() {
	 // 使⽤ uuid 作为唯⼀⾝份标识
	 roomId = UUID.randomUUID().toString();
 }
 // getter / setter ⽅法略
}

需要的知识点
UUID
UUID表示“世界上唯一的身份标识”
通过一系列的算法,能够生成一串字符串(一组十六进制表示的数字),两次调用这个算法,生成的这个字符串都是不相同的。
任意次调用,每次得到的结果都不相同
UUID内部具体如何实现的(算法实现细节)不去深究~~Java中直接有现成的类,可以帮我们一下就生成一个UUID

实现房间管理器

在这里插入图片描述
Room 对象会存在很多. 每两个对弈的玩家, 都对应⼀个 Room 对象.
需要⼀个管理器对象来管理所有的 Room.
关于RoomManager,希望能够根据房间id找到房间对象,也希望能够根据玩家id,找到玩家所属的房间
创建 game.RoomManager
• 使⽤⼀个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象
• 再使⽤⼀个 Hash 表, 保存 userId -> roomId 的映射, ⽅便根据玩家来查找所在的房间.
• 提供增, 删, 查的 API. (查包含两个版本, 基于房间 ID 的查询和基于⽤⼾ ID 的查询).

@Component
public class RoomManager {
	 // key 为 roomId, value 为⼀个 Room 对象
	 private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
	 private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();
	 public void addRoom(Room room, int userId1, int userId2) {
		 rooms.put(room.getRoomId(), room);
		 userIdToRoomId.put(userId1, room.getRoomId());
		 userIdToRoomId.put(userId2, room.getRoomId());
	 }
	 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 getRoomByRoomId(roomId);
	 }
	
	 public void removeRoom(String roomId, int userId1, int userId2) {
		 rooms.remove(roomId);
		 userIdToRoomId.remove(userId1);
		 userIdToRoomId.remove(userId2);
	 }
 }

实现匹配器(3)

完善刚才匹配逻辑中的 TODO. 创建房间, 并把玩家放到这个房间中.
先给 Matcher 找那个注⼊ RoomManager 对象

@Component
public class Matcher {
// ......

// 房间管理器
@Autowired
private RoomManager roomManager;

// ......
}
然后修改 Matcher.handlerMatch, 补完之前 TODO 的内容. 
private void handlerMatch(Queue<User> matchQueue) {
// ......

// 3. 将这两个玩家加⼊到游戏房间中.
Room room = new Room();
roomManager.addRoom(room, player1.getUserId(), player2.getUserId());

// ......
}

验证匹配功能和多开处理

运⾏程序, 验证匹配功能和多开处理是否正常
发现的错误

验证匹配功能的时候,模拟多个用户登录的情况,最好使用多个浏览器,避免同一个浏览器中的cookie/session信息相互干扰 如果只有一个浏览器,并且是chrome的话chrome有个无痕模式(不会记录历史记录,也 不会记录cookie,页面关闭的时候自动清空~~)

验证匹配功能

  1. 玩家点击匹配之后,匹配按钮的文本不发生改变
    分析之前写过的代码,点击按钮的时候,仅仅是给服务器发送了websocket请求,告诉服务器我要开始匹配了~ 服务器会立即返回一个响应,“进入匹配队列成功”,然后页面再修改按钮的文本~
    出现问题的原因: 服务器这边在处理匹配请求的时候,按理说,是要立即就返回一个websocket响应的. 实际上在服务器代码这里构造了响应对象,但是忘记sendMessage,给发回去了

当前匹配模块已初步实现,但在测试过程中发现并修复了服务器未正确返回WebSocket响应的问题,导致前端按钮状态未及时更新。
开始检查:
• 1.匹配功能基本流程已通:程序能够启动服务器,用户可通过登录页面成功登录,并进入游戏大厅,日志显示玩家“张三”已成功连接并加入大厅。
• 2.点击“开始匹配”后按钮文本未更新:前端界面在点击“开始匹配”后,按钮文字未从“开始匹配”变为“匹配中”,交互反馈缺失。
• 3.问题定位为服务器未发送响应消息:尽管服务器日志显示玩家已加入normal队列,但前端未收到对应的WebSocket响应,导致无法触发UI更新。
• 4.前端逻辑依赖服务器响应更新UI:按钮文本的变更由onmessage事件处理,需服务器返回“进入匹配队列成功”等消息后才执行修改,而非点击时立即更改。
• 5.浏览器控制台无响应日志输出:前端onmessage中设置了通用的日志打印,但实际未打印任何响应内容,表明消息未送达。
• 6.服务器代码遗漏send操作:在处理匹配请求后,构造了响应对象response,但未调用session.sendMessage()将其发送回客户端。
• 7.修复方式为补充sendMessage调用:在服务器端添加session.sendMessage(new TextMessage(jsonString)),将响应对象序列化为JSON字符串并发送。
• 8.重启服务器后需重新建立连接:服务器重启导致原有WebSocket连接断开,前端必须重新登录以创建新连接。
• 9.修复后功能验证通过:重新测试显示,点击“开始匹配”后按钮成功变为“匹配中”,再次点击可恢复为“开始匹配”,前后端日志一致,匹配队列的加入与移除逻辑正常。

  1. 匹配失败,服务端日志抛出空指针异常(NullPointerException):Matcher中的ObjectMapper忘记初始化

多人匹配功能的测试过程:
使用多个浏览器或无痕模式避免会话干扰
为防止同一浏览器中多个标签页共享会话(session)信息导致账号冲突,建议使用不同浏览器分别登录不同账号;若仅有一个浏览器(如Chrome),可使用“无痕模式”实现类似隔离效果。(无痕模式不会永久保存浏览历史、Cookie等数据,在关闭窗口后自动清除相关信息,适合用于多账号登录测试,也可用于保护隐私操作 。)
测试匹配功能发现匹配未成功
使用张三和李四两个账号分别在正常窗口和无痕窗口中登录,并同时发起匹配请求。虽然日志显示两名玩家已成功从匹配队列中取出,但系统出现异常,未能完成后续流程。
问题定位:空指针异常
查看服务端日志发现,错误发生在match类的handleMatch方法中,具体为抛出空指针异常(NullPointerException),原因为objectMapper对象未实例化。
问题修复方式
对objectMapper进行正确初始化,补充new ObjectMapper()实例创建代码,确保序列化操作可以正常执行。
重启服务器并重新验证
修改代码后重启服务,再次进行匹配测试,结果显示匹配逻辑已生效,前端收到“match success”响应。
404 页面出现的原因分析
匹配成功后页面跳转至 /game_room,但由于该页面尚未开发,服务器返回 404,属于预期中的正常现象,并非程序逻辑错误。
正确判断程序问题的关键
需结合代码实现细节分析现象是否合理,不能仅凭表面错误(如404)断定功能失败,理解前后端交互流程是准确调试的前提。

  1. 前端控制台显示 "受到了非法的响应! " (前端代码:’ matchSuccess’ 空格和页面缓存导致)

在这里插入图片描述
开始寻找解决办法:
发现message=matchSuccess 说明前端收到了后端发送的响应matchSuccess
在这里插入图片描述
根据前端代码只有可能是后端发送的响应message与’matchSuccess’不符,但是明明一样啊。
开始检查前端代码:’ matchSuccess’ 发现空格
在这里插入图片描述
去掉空格后重启程序,前端控制台依然显示 "受到了非法的响应! "
这时候很有可能是前端页面缓存未刷新,刷新页面果然成功了
长记性
按F12开发工具, 勾选Disable catch禁止页面缓存,就不会出现这种情况啦
在这里插入图片描述

验证多开处理

  • 当前虽然能够禁止一个账户多开效果(主要是禁止在多个客户端进行匹配),但是在界面上并没有一个明确的提示。此处要调整前端代码,当监测到多开的时候,就给用户一个更加明确的提示

游戏多开登录的检测及前端用户体验优化,重点在于完善多开限制的提示逻辑
多开登录现象验证:通过在两个无痕浏览器标签页中使用相同账号(张三/123)登录,发现虽能显示登录成功,但实际服务器已返回 “禁止多开” 的响应,并关闭了WebSocket连接。
现有机制的部分有效性:服务器在检测到多开时会主动关闭连接(session.close),从而阻止用户进行后续的匹配操作,有效避免了同一账户在多个客户端中匹配到自身的情况。
用户体验存在的问题:尽管连接已被关闭,前端页面未给出明确提示,导致用户误以为登录成功;只有在尝试匹配时才提示“连接已断开,请重新登录”,此时已进入功能异常流程,体验不佳。
改进方案提出:在前端WebSocket的onclose事件中增加明确提示,弹出警告框告知用户当前和服务器的连接已经断开,请重新登录,并自动跳转至登录页面,提升反馈及时性与操作引导性。

websocket.onclose = function() {
           console.log("onclose");
           alert("当前检测到多开! 请使用其他账号登录!");
           location.replace("/login.html");
       }

优化后的效果验证:修改代码后重启服务,再次测试多开登录,第二个客户端立即弹出提示并跳回登录页,显著提升了用户感知的清晰度与系统反馈的即时性。
注意事项:修改JavaScript代码后必须使用Ctrl+F5强制刷新页面,以避免浏览器缓存导致旧版本脚本仍在运行,造成改动未生效的误解。

小结

匹配模块的工作流程,从用户点击匹配到服务器处理、队列管理及房间创建的全过程
匹配流程启动
用户点击 “开始匹配” 按钮后,触发前端JavaScript的点击事件回调,通过WebSocket向服务器发送匹配请求。
客户端发送匹配请求
客户端在点击事件中发送包含message: "startMatch"字段的WebSocket请求至服务器,标识当前操作为开始匹配。
服务器接收并解析请求
服务器在MatchAPI类的handleTextMessage方法中接收到请求,使用ObjectMapper解析JSON数据为MatchRequest对象,提取其中的message字段判断操作类型。
将玩家加入匹配队列
若message为startMatch,则调用匹配服务的add操作,将当前用户加入匹配队列;若为停止匹配,则执行移除操作。
服务器返回响应
服务器在成功将用户加入队列后,立即通过WebSocket向客户端返回响应,内容包含ok、reason和message三个字段,用于告知客户端操作结果。
客户端处理响应
客户端在onmessage事件中接收服务器响应,打印日志并更新界面按钮文本,表示已进入匹配状态。
匹配队列与扫描线程
匹配服务中存在一个共享的匹配队列,并由多个后台线程持续扫描该队列,调用handleMatch方法进行匹配逻辑处理。
线程阻塞机制
当队列中玩家数量少于2人时,扫描线程在wait()处阻塞,等待新玩家加入以唤醒匹配过程。
第二个玩家加入匹配
当另一玩家也点击“开始匹配”,重复前述流程将其加入同一匹配队列,此时队列人数达到2人,触发唤醒机制。
匹配成功并创建房间
扫描线程被唤醒后检测到足够玩家,取出两名玩家并为其创建新的游戏房间,生成唯一房间ID。
房间信息注册到房间管理器
房间创建后,将以下三组映射关系注册至房间管理器的哈希表中:
◦ 房间ID → 房间对象
◦ 玩家1 ID → 房间ID
◦ 玩家2 ID → 房间ID
匹配完成,进入对战准备阶段
两个玩家成功分配至同一房间后,匹配流程结束,系统可进一步推进至房间内的实时对战功能实现。

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TT哇

谢谢谢谢!开心!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值