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

玄策五子

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

项⽬背景

实现⼀个⽹⻚版五⼦棋对战程序.

⽀持以下核⼼功能:

• ⽤⼾模块: ⽤⼾注册, ⽤⼾登录, ⽤⼾天梯分数记录, ⽤⼾⽐赛场次记录.

• 匹配模块: 按照⽤⼾的天梯分数实现匹配机制.

• 对战模块: 实现两个玩家在⽹⻚端进⾏五⼦棋对战的功能.

核⼼技术

• 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 作为匹配按钮.

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

创建 game_hall.css

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 代码, 获取到⽤⼾信息.

html 复制代码
<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

java 复制代码
// 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 数据.

java 复制代码
@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 中. ⽅便后⾯的代码中获取到当前⽤⼾信息.(此处需要能够保存和表示用户的上线状态。)

java 复制代码
@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 来查询到对应的会话, 以便向客⼾端返回数据.

由于存在两个⻚⾯, 游戏⼤厅和游戏房间, 使⽤两个 哈希表 来分别存储两部分的会话.

java 复制代码
@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

java 复制代码
@Component
public class MatchAPI extends TextWebSocketHandler {

    @Autowired
    private OnlineUserManager onlineUserManager;
}

发现的小问题

线程安全问题:

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

所以HashMap改成了ConcurrentHashMap

创建匹配请求/响应对象

创建 game.MatchRequest

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

    public String getMessage() {
        return message;
    }

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

创建 game.MatchResponse

java 复制代码
// 这是表示一个 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数据包

java 复制代码
@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 中退出.

• 如果玩家当前在匹配队列中, 则直接从匹配队列⾥移除.
法一:

java 复制代码
@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处理方式一样:

java 复制代码
@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. 逻辑同上.
法一:

java 复制代码
@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处理方式一样:

java 复制代码
@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对象注入

java 复制代码
@Component
public class MatchAPI extends TextWebSocketHandler {

   @Autowired
   private Matcher matcher;
}
java 复制代码
@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.

java 复制代码
// 这个类表示 "匹配器", 通过这个类负责完成整个匹配功能
@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 的构造⽅法中, 创建⼀个线程, 使⽤该线程扫描每个队列, 把每个队列的头两个元素取出来,匹配到⼀组中.

java 复制代码
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对象

java 复制代码
@Component
public class Matcher {

    private ObjectMapper objectMapper;
}
java 复制代码
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。 所以我们把可能会产生问题的地方,尽可能充分的进行校验,这样的话可以做更好的一个稳定性。因此我们在这儿也再多做一层判定多做一层判定。
    遇到的小问题

  3. 线程安全问题
    使用到多线程代码的时候,一定要时刻注意"线程安全"问题

    synchronized
    指定一个"锁对象" ,也就是到底针对谁进行加锁~
    只有多个线程在尝试针对同一个锁对象进行加锁的时候,才会有互斥效果。此处进行加锁的时候,要明确,如果多个线程访问的是不同的队列,不涉及线程安全问题, 必须得是多个线程操作同一个队列,才需要加锁~
    因此在加锁的时候选取的锁对象,就normalQueue,highQueue,veryHighQueue这三个队列对象本身~~

  4. 忙等问题
    如果当前匹配队列中,就只有一个元素,或者没有元素,会出现什么效果呢?

    在这个代码中,就会出现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)上⾯的等待逻辑.

java 复制代码
// 操作匹配队列的方法.
    // 把玩家放到匹配队列中
    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

java 复制代码
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 的查询).

java 复制代码
@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 对象

java 复制代码
@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事件中增加明确提示,弹出警告框告知用户当前和服务器的连接已经断开,请重新登录,并自动跳转至登录页面,提升反馈及时性与操作引导性。

javascript 复制代码
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

匹配完成,进入对战准备阶段

两个玩家成功分配至同一房间后,匹配流程结束,系统可进一步推进至房间内的实时对战功能实现。

相关推荐
2501_9061505616 小时前
私有部署问卷系统操作实战记录-DWSurvey
java·运维·服务器·spring·开源
better_liang17 小时前
每日Java面试场景题知识点之-TCP/IP协议栈与Socket编程
java·tcp/ip·计算机网络·网络编程·socket·面试题
VX:Fegn089517 小时前
计算机毕业设计|基于springboot + vue校园社团管理系统(源码+数据库+文档)
前端·数据库·vue.js·spring boot·后端·课程设计
niucloud-admin17 小时前
java服务端——controller控制器
java·开发语言
To Be Clean Coder17 小时前
【Spring源码】通过 Bean 工厂获取 Bean 的过程
java·后端·spring
Fortunate Chen17 小时前
类与对象(下)
java·javascript·jvm
程序员水自流17 小时前
【AI大模型第9集】Function Calling,让AI大模型连接外部世界
java·人工智能·llm
‿hhh17 小时前
综合交通运行协调与应急指挥平台项目说明
java·ajax·npm·json·需求分析·个人开发·规格说明书
小徐Chao努力17 小时前
【Langchain4j-Java AI开发】06-工具与函数调用
java·人工智能·python
无心水17 小时前
【神经风格迁移:全链路压测】33、全链路监控与性能优化最佳实践:Java+Python+AI系统稳定性保障的终极武器
java·python·性能优化