玄策五子
玄策五子
“玄策” 取自深远谋略,搭配 “五子” 点明项目核心,兼具古风与智慧感。
项⽬背景
实现⼀个⽹⻚版五⼦棋对战程序.
⽀持以下核⼼功能:
• ⽤⼾模块: ⽤⼾注册, ⽤⼾登录, ⽤⼾天梯分数记录, ⽤⼾⽐赛场次记录.
• 匹配模块: 按照⽤⼾的天梯分数实现匹配机制.
• 对战模块: 实现两个玩家在⽹⻚端进⾏五⼦棋对战的功能.
核⼼技术
• 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.html 的 js 部分代码.
• 点击匹配按钮, 就会进⼊匹配逻辑. 同时按钮上提⽰ “匹配中…(点击停止)” 字样.
• 再次点击匹配按钮, 则会取消匹配.
• 当匹配成功后, 服务器会返回匹配成功响应, ⻚⾯跳转到 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;
}
用到的知识
- 当我们修改了css样式/js文件之后,往往要在浏览器中使用ctrl+f5强制刷新,才能生效
否则浏览器可能仍然在执行旧版本的代码~~(浏览器自带缓存) - 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());
}
}
用到的知识
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();
}
}
}
小的知识点
- 这里提醒大家,咱们写代码的时候,要在关键环节多加一些日志。通过一些日志,我们一方面能够更好的理解程序是怎么运行的。另一方面我们也方便去出现问题之后,好去进行调试。这个都是很关键的内容
- 还是那句话,我们使用双重校验会更加稳妥,毕竟我们在一个稍微复杂的一些程序里面,这个逻辑可能会比较复杂。我们也不能保证说每一个地方都严谨,可能会有一些漏洞,会有一些bug。 所以我们把可能会产生问题的地方,尽可能充分的进行校验,这样的话可以做更好的一个稳定性。因此我们在这儿也再多做一层判定多做一层判定。
遇到的小问题
- 线程安全问题
使用到多线程代码的时候,一定要时刻注意“线程安全”问题
synchronized
指定一个“锁对象” ,也就是到底针对谁进行加锁~
只有多个线程在尝试针对同一个锁对象进行加锁的时候,才会有互斥效果。此处进行加锁的时候,要明确,如果多个线程访问的是不同的队列,不涉及线程安全问题, 必须得是多个线程操作同一个队列,才需要加锁~
因此在加锁的时候选取的锁对象,就normalQueue,highQueue,veryHighQueue这三个队列对象本身~~- 忙等问题:
如果当前匹配队列中,就只有一个元素,或者没有元素,会出现什么效果呢?
在这个代码中,就会出现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,页面关闭的时候自动清空~~)
验证匹配功能
- 玩家点击匹配之后,匹配按钮的文本不发生改变
分析之前写过的代码,点击按钮的时候,仅仅是给服务器发送了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.修复后功能验证通过:重新测试显示,点击“开始匹配”后按钮成功变为“匹配中”,再次点击可恢复为“开始匹配”,前后端日志一致,匹配队列的加入与移除逻辑正常。
- 匹配失败,服务端日志抛出空指针异常(NullPointerException):Matcher中的ObjectMapper忘记初始化
多人匹配功能的测试过程:
• 使用多个浏览器或无痕模式避免会话干扰
为防止同一浏览器中多个标签页共享会话(session)信息导致账号冲突,建议使用不同浏览器分别登录不同账号;若仅有一个浏览器(如Chrome),可使用“无痕模式”实现类似隔离效果。(无痕模式不会永久保存浏览历史、Cookie等数据,在关闭窗口后自动清除相关信息,适合用于多账号登录测试,也可用于保护隐私操作 。)
• 测试匹配功能发现匹配未成功
使用张三和李四两个账号分别在正常窗口和无痕窗口中登录,并同时发起匹配请求。虽然日志显示两名玩家已成功从匹配队列中取出,但系统出现异常,未能完成后续流程。
• 问题定位:空指针异常
查看服务端日志发现,错误发生在match类的handleMatch方法中,具体为抛出空指针异常(NullPointerException),原因为objectMapper对象未实例化。
• 问题修复方式
对objectMapper进行正确初始化,补充new ObjectMapper()实例创建代码,确保序列化操作可以正常执行。
• 重启服务器并重新验证
修改代码后重启服务,再次进行匹配测试,结果显示匹配逻辑已生效,前端收到“match success”响应。
• 404 页面出现的原因分析
匹配成功后页面跳转至 /game_room,但由于该页面尚未开发,服务器返回 404,属于预期中的正常现象,并非程序逻辑错误。
• 正确判断程序问题的关键
需结合代码实现细节分析现象是否合理,不能仅凭表面错误(如404)断定功能失败,理解前后端交互流程是准确调试的前提。
- 前端控制台显示 "受到了非法的响应! " (前端代码:’ 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
• 匹配完成,进入对战准备阶段
两个玩家成功分配至同一房间后,匹配流程结束,系统可进一步推进至房间内的实时对战功能实现。













2688

被折叠的 条评论
为什么被折叠?



