1.认识WebSocket
WebSocket也是一种传输协议,本质上是对TCP协议的补充.
就像是网页实时聊天,TCP也不是不能做,但是会很复杂.TCP可以通过"轮询"来实现.所谓的"轮询"就是客户端1发送了一条数据,此时服务器收到了,客户端2向服务器发送请求,就可以拿到这条数据.但是我们我们并不知道什么时候客户端1发送数据,服务器和客户端2也不知道,此时客户端2就会不断的向服务器发送请求,直到客户端1发送了数据给服务器客户端2读取到了才算.就像是我们要追求女神.每天不停的给她发微信,还要一直盯着手机看.直到女神冷冷的回复我,我才作罢.(舔狗)
但是WebSocket就可以对上述的情况做出改善,它可以完成"消息推送".所谓"消息推送"就是服务器可以主动给客户端发送数据.因为我们当前都是客户端发送一个请求,服务器返回一个响应.而WebSocket可以主动给客户端发送消息,这样就避免了其他客户端在服务器没有收到数据的时候而一直访问服务器.而且WebSocket的消息推送是实时的,就是客户端1发送,客户端2不会等到天荒地老,虽然会消耗时间,但是过程是非常短暂的,保证时效.
1.1WebSocket的报文格式
FIN:我们在TCP断开连接的时候见到过,他表示的是结束报文段.其余三个是保留位,我们不做过多讨论.
opcode:描述了当前的报文段是什么类型.表示当前是一个文本帧还是一个二进制帧
也可以表示当前是一个ping帧还是一个pong帧. 就是WebSocket的"心跳包".
ping帧是由一端(客户端或服务器)发送到另一端,用来检测连接是否仍然有效。接收到ping帧的一端需要回应一个pong帧,作为确认。这样,发送方就知道对方还在线,连接没有中断。如果有一方发送ping帧,另外一方必须要发送pong帧,如果长时间未处理就会自动断开连接.
payload len:表示的是当前数据报中载荷的长度.他本身是可以变长的,一个WebSocket数据报可以承载的载荷长度是非常大的.
1.2WebSocket建立连接的过程
网页端和服务器建立连接.网页端会先给服务器发送一个HTTP请求.这个请求头中会包含特殊的header.
Connection:Upgrade
Upgrade:WebSocket
这两个协议就是告诉服务器要进行协议升级.如果服务器支持WebSocket,此时就会返回状态码为101,这是切换协议的专属状态码
请求头中的特殊header并不只有上面的两个,其实还有很多,这里不做过多介绍.
1.3编写简单的WebSocket代码
@Component
public class TestApi extends TextWebSocketHandler {
//连接建立之后触发,我们可以感知到建立连接完毕
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
super.afterConnectionEstablished(session);
}
//表示如果客户端给服务器发送文本消息,服务器就能看到内容
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
super.handleTextMessage(session, message);
session.sendMessage(message);
}
//传输异常触发
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
super.handleTransportError(session, exception);
}
//如果连接关闭执行
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
super.afterConnectionClosed(session, status);
}
}
afterConnectionEstablished(WebSocketSession session):这个方法是在建立连接后触发,我们就知道客户端和服务器已经建立连接.方法体中一般都是写建立连接后要处理的逻辑
handleTextMessage(WebSocketSession session, TextMessage message):通常是用于处理文本类型数据帧的方法或回调函数。其核心作用是对接收到的文本消息进行解析、业务逻辑处理或转发。
handleTransportError(WebSocketSession session, Throwable exception):当 WebSocket 连接在传输过程中发生异常时(如网络中断、协议错误、IO异常等),框架会自动调用该方法。
afterConnectionClosed(WebSocketSession session, CloseStatus status):连接关闭后自动调用该方法
@Configuration
@EnableWebSocket
public class WebConfig implements WebSocketConfigurer {
@Resource
private TestApi testApi;
private MatchController matchController;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(testApi,"/test");
registry.addHandler(matchController,"/findMatch").addInterceptors(new HttpSessionHandshakeInterceptor());
}
}
@EnableWebSocket注解:
这里我们需要注意,WebSocket的session和HTTP的session虽然都是会话,但是是不一样的.他们都是互相独立的.而不是一体,更不指的是一个session.
2.网页五子棋
有了上述WebSocket的介绍我们接下来进行一个网页五子棋的项目.WebSocket作用就是客户端1落子之后,服务器接收到落子请求之后会第一时间主动发送给客户端2.此时客户端2就可以看到客户端1的落子.
通过上面的介绍我们正式进入开发阶段.~~
一般在企业中开发之前,接口文档设计一般都是非常重要的.有了接口文档我们写代码也会方便非常多.但是我们这只是一个小的项目.如何定义我们自己规范好即可.
2.1开发准备
1.应用分层
我们这里也不是大项目,只需要controller,service,mapper,model,后续需要常量,枚举我们再进行添加即可
2.引入依赖
引入MyBatis,MySQL驱动依赖以及Java开发工具包lombok依赖,同上,后续需要什么依赖再添加
3.写配置文件
我们使用application.yml配置文件.在配置文件中连接数据库.mybatis的日志打印,以及驼峰转换
重点:xml文件实现对数据库的增删改查一定要遵照配置文件的格式
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/java_gopang?characterEncoding=utf8&useSSL=false
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/*Mapper.xml
configuration: # 配置打印 MyBatis⽇志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
2.2数据库设计
用户表设计
1.用户ID:userId int primary key auto_increment(自增主键)
2.用户名:varchar(20)
3.天梯分数:score int
4.游戏总场数:totalCount int
5.获胜场数:winCount int
建表老三样(必不可少)
isDelete: tynint(0-表示删除,1-表示未删除) 这里的删除都是逻辑删除,在数据库中并未删除
create_time:datetime 创建时间
update_time:datetime 更新时间
简单插入一些数据,这里有一个点,为了数据安全.存储到数据库的密码不能明文存储.因为是开发阶段,后续我们再优化.
2.3 用户注册
用户注册功能其实就是根据前端发送的请求向数据库插入一条数据.
/**
* 用户注册controller
* @param username 用户名
* @param password 密码
* @return 1
*/
@PostMapping("/register")
@ResponseBody
public Result<String> register(String username,String password){
if (!StringUtils.hasLength(username)||!StringUtils.hasLength(password)){
return Result.fail("账号或密码为空");
}
if (userService.login(username)!=null){
return Result.fail("用户名存在");
}
Integer register = userService.register(username, password);
return Result.success("注册成功"+register);
}
//用户注册service
public Integer register(String username,String password){
Integer insert = userMapper.insert(username, password);
return insert;
}
//注册mapper
Integer insert(String username,String password);
#插入到数据库中
<insert id="insert">
insert into user values (null,#{username},#{password},1000,0,0);
</insert>
执行流程:客户端发送注册请求,controller负责接收请求,调用service进行逻辑处理,service调用mapper对数据进行操作
插入成功后将成功的结果返回给客户端
2.4用户登录
和注册一样,用户的登录其实就是对数据的查询.对客户端传来的密码通过数据库存储的账号对应的密码进行校验.并将结果返回给客户端.在登录成功后设置session,为后续的请求进行校验.如果用户没有登录直接访问我们的游戏页面,此时实现游戏逻辑的后端就会校验是否有session.如果没有就返回登录界面
/**
* 用户登录controller
* @param username 用户名
* @param password 密码
* @return user对象
*/
@PostMapping("/login")
@ResponseBody
public Result<User> login(String username, String password, HttpServletRequest request){
log.info("用户名为 username {}",username);
//1.校验
if (!StringUtils.hasLength(username)||!StringUtils.hasLength(password)){
return Result.fail("账号或密码为空");
}
User temp = userService.login(username);
//用户不存在
if (temp==null){
return Result.fail("用户不存在");
}
if (!password.equals(temp.getPassword())) {
return Result.fail("密码错误");
}
request.getSession().setAttribute(Constants.USER_SESSION_KEY,temp);
User safetyUser=new User();
safetyUser.setUserId(temp.getUserId());
safetyUser.setUsername(temp.getUsername());
safetyUser.setScore(temp.getScore());
safetyUser.setTotalCount(temp.getTotalCount());
safetyUser.setWinCount(temp.getWinCount());
return Result.success(safetyUser);
}
//service
public User login(String username){
User user = userMapper.selectByName(username);
return user;
}
//登录
User selectByName(String username);
2.4游戏大厅
本博文第一个重头戏来了,请睁大眼睛.
2.4.1上线下线
平时我们玩游戏的时候会关注好友是不是在线,如果在线就一起搞两把.那么我们用户是否上线的后端是如何实现的呢?
WebSocket 提供了连接建立和关闭的事件回调,可通过这些事件实现用户上线下线的检测。
这里我们借用一种数据结构--哈希表.
.前面我们介绍到WebSocket Session和Http Session是两个不同的会话.那么我们如何知晓当前上线的用户是谁呢?其实很简单,我们只需要将HTTP Session中存储的用户信息(用户在登录的时候会设置session,session中存储的值就是当前登录用户的信息).通过方法拿到WebSocket 的Session中.这样我们就能知道当前是哪个用户上线了.哈希表在这里还有一个更重要的作用,就是可以保存当前用户的session会话,方便我们后续操作User对象.
而哈希表的作用就是在建立连接之后将当前登录用户的userId作为键,从http中获取的session作为值存储到哈希表中.我们认为当前哈希表中存储的值就是在线的用户.而用户下线就将哈希表中的值删除就是代表当前用户下线了.这里有一个很重要的问题,就是线程安全问题.当多个用户来进行上线下线,都会操作我们这个唯一的一个哈希表.就会造成安全问题,比如,当多个线程同时进行put操作时,可能会造成元素的丢失。比如两个线程同时要插入不同的元素,但它们的哈希值相同,导致在同一个桶的位置上,如果没有正确的同步,可能会导致其中一个线程的插入被覆盖。这就很严重了.明明用户登录了但是不在线.那么所谓的登录也就是"名存实亡".
那么如何解决呢?
在多线程章节我们介绍过一种线程安全的哈希表,那就是.ConcurrentHashMap.因为ConcurrentHashMap不是对整个哈希表加锁,而是对每个哈希桶进行加锁.本来哈希表存储数据就是比较分散的.产生锁竞争的几率不是很大,所以ConcurrentHashMap不会消耗我们太多的cpu资源.
package com.example.demo.model;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class OnlineUserManager {
//类似于游戏大厅,通过哈希表中是否还存在用户id来判断是否在线
//如果通过用户id没找到对应的哈希值,就证明用户下线了
//如果使用我们的HashMap可能会造成线程安全问题
//当多个线程同时执行put操作时,若两个不同的键哈希到同一桶且该位置为空,两个线程可能同时将节点插入该位置,导致其中一个线程的数据被覆盖。
//所以我们使用ConcurrentHashMap来存储,因为ConcurrentHashMap是线程安全的
//ConcurrentHashMap给每个哈系统都进行加锁,在哈希表(数据结构)中产生锁冲突的概率不大,所以也不会怎么消耗资源
private ConcurrentHashMap<Integer, WebSocketSession> gameHall=new ConcurrentHashMap<>();
/**
* 如果当前用户登录成功后,在登录页面的session就会被WebSocket拿到自己的session中
* registry.addHandler(matchController,"/findMatch").addInterceptors(new HttpSessionHandshakeInterceptor());
* 拿到之后将session和当前用户的userId存储到哈希表中
* 通过userId来扫描哈希表,如果当前用户id对应的value有值,就证明当前用户在线
* @param userId 用户Id作为键存储到哈希表中
* @param session http登录后获取的session,WebSocket将http的session拿到自己的session里
*/
public void enterGameHall(Integer userId,WebSocketSession session){
gameHall.put(userId,session);
}
/**
* 如果客户端和服务器断开连接之后,调用这个方法将用户在哈希中删除,就证明该用户下线了
* @param userId 用户id
*/
public void exitGameHall(Integer userId){
gameHall.remove(userId);
}
public WebSocketSession getFromGameHall(Integer userId){
WebSocketSession webSocketSession = gameHall.get(userId);
return webSocketSession;
}
}
客户端服务器建立连接之后,用户登录后,就证明上线了
public class MatchController extends TextWebSocketHandler {
@Autowired
private OnlineUserManager onlineUserManager;
private ObjectMapper mapper=new ObjectMapper();
//连接建立之后触发,我们可以感知到建立连接完毕
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
//客户端和服务器建立连接之后,用户登录之后玩家就上线
//将当前登录玩家的用户id和session信息存储到哈希表中,当前表中有这个键值对,就证明玩家是在线的
/**
* 1.先获取到当前是哪个玩家上线了,由于我们在配置websocket中将http的session信息拿到了我们的websocket中
* 此时websocket也就知道当前是哪个用户上线了
* 这个逻辑就是把HTTP的 Attribute拿到了我们的WebSocketSession中了
* 在登录逻辑中,登录成功后,往HttpSession中存储了User attribute = (User) session.getAttribute(Constants.USER_SESSION_KEY);
* 这样的一条数据,这条数据里面保存了当前登录的用户的身份信息,此时我们在注册WebSocket的时候通过下面的这个方法就拿到了HttpSession中的存储的user对象
* registry.addHandler(matchController,"/findMatch").addInterceptors(new HttpSessionHandshakeInterceptor());
*/
/*
还有一点,如果当前用户并没有登录,而是通过url直接访问game_hall.html游戏大厅页面,那么我们这个
User user = (User) session.getAttributes().get(Constants.USER_SESSION_KEY);
就获取不到对象,因为只有登录之后才会有session存储.所以就出现了空指针异常
*/
try {
User user = (User) session.getAttributes().get(Constants.USER_SESSION_KEY);
log.info("获取到当前的用户信息为 user {}",user);
/*
2.还有比较重要的一点就是,如果当前一个用户在多个浏览器登录自己的账号,就会造成"多开"
比如zhangsan,在edge浏览器登录获取到session存储到哈希表中为 userId=1,WebSocketSession=session1
此时又在谷歌浏览器里进行登录,session也会存储到哈希表中 userId=2,WebSocketSession=session2
这样第一次存储的WebSocketSession就会被第二次的覆盖掉了,session也"是名存实亡"
禁止多开:就是在将session存储到哈希表之前进行判断,看当前id是否已经存在,如果存在就直接报错
*/
WebSocketSession fromGameHall = onlineUserManager.getFromGameHall(user.getUserId());
if (fromGameHall!=null){
MatchResponse response=new MatchResponse();
response.setReason("您当前已经登录了");
response.setOk(false);
session.sendMessage(new TextMessage(mapper.writeValueAsString(response)));
session.close();
}
/**
* 3.拿到用户的身份信息我们就可以将用户的状态设置为在线了
*/
onlineUserManager.enterGameHall(user.getUserId(), session);
}catch (NullPointerException e){
e.printStackTrace();
MatchResponse response=new MatchResponse();
response.setOk(false);
response.setReason("您尚未登录");
session.sendMessage(new TextMessage(mapper.writeValueAsString(response)));
}
}
传输过程中发生异常以及主动断开连接,就证明该用户下线了
//传输异常触发
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
try {
//玩家下线,将userId在hashmap中移除
User user = (User) session.getAttributes().get(Constants.USER_SESSION_KEY);
/*
上面由于两个账号不能多开,如果同一个账号登录两次就会让第二个账号断开连接
但是因为是同一个用户,他们的userId都是一样的,为了防止我们之前登录的账号不会再哈希表中删除
就通过之前登录存储到哈希表中的session要是和当前的session一样再进行删除即可
因为即使userId一样,但是session却是不一样的
*/
WebSocketSession curSession = onlineUserManager.getFromGameHall(user.getUserId());
if (curSession==session){
onlineUserManager.exitGameHall(user.getUserId());
}
}catch (NullPointerException e){
e.printStackTrace();
MatchResponse response=new MatchResponse();
response.setOk(false);
response.setReason("您尚未登录");
session.sendMessage(new TextMessage(mapper.writeValueAsString(response)));
}
}
//如果连接关闭执行
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
try {
//玩家下线,将userId在hashmap中移除
User user = (User) session.getAttributes().get(Constants.USER_SESSION_KEY);
WebSocketSession curSession = onlineUserManager.getFromGameHall(user.getUserId());
if (curSession==session){
onlineUserManager.exitGameHall(user.getUserId());
}
onlineUserManager.exitGameHall(user.getUserId());
}catch (NullPointerException e){
e.printStackTrace();
MatchResponse response=new MatchResponse();
response.setOk(false);
response.setReason("您尚未登录");
session.sendMessage(new TextMessage(mapper.writeValueAsString(response)));
}
}
2.4.2 "多开"问题
什么是'多开'?
就是当前同一个用户在edge浏览器登录了自己的账号存储session1,又在谷歌浏览器登录了账号获取session2.这就会出现问题.由于是同一个账号.userId是同一个,谷歌浏览器登录后会将edge浏览器的session覆盖掉.导致session1虽然还在,但是哈希表中的值已经被session2覆盖.session1并不能表示上线,也就是"名存实亡".那我们该如何处理上述问题呢?
1.让第二次登录失效[本次项目使用]
2.将第一次登录挤掉,类似LOL,一台主机登录一个账号,另一台主机再次登录就将前一次的挤掉,博主能力有限,还没有掌握
那么我们如何用第一种方式实现呢?其实也很简单,我们不是将用户上线的信息存储在哈希表中嘛,那么在登录之前我们先扫描哈希表用userId看即将登录的用户的userId是否已经在哈希表中存储.如果已经存在哈希表中,那么我们就断开这次新的连接并且服务器会返回给前端错误信息.
这里我们定义一个MatchResponse类,用户返回错误信息.
try {
User user = (User) session.getAttributes().get(Constants.USER_SESSION_KEY);
log.info("获取到当前的用户信息为 user {}",user);
/*
2.还有比较重要的一点就是,如果当前一个用户在多个浏览器登录自己的账号,就会造成"多开"
比如zhangsan,在edge浏览器登录获取到session存储到哈希表中为 userId=1,WebSocketSession=session1
此时又在谷歌浏览器里进行登录,session也会存储到哈希表中 userId=2,WebSocketSession=session2
这样第一次存储的WebSocketSession就会被第二次的覆盖掉了,session也"是名存实亡"
禁止多开:就是在将session存储到哈希表之前进行判断,看当前id是否已经存在,如果存在就直接报错
*/
WebSocketSession fromGameHall = onlineUserManager.getFromGameHall(user.getUserId());
if (fromGameHall!=null){
MatchResponse response=new MatchResponse();
response.setReason("您当前已经登录了");
response.setOk(false);
session.sendMessage(new TextMessage(mapper.writeValueAsString(response)));
session.close();
}
这么做虽然规避了多开问题,但是session.close()关闭连接,因为两个用户是同一个用户,所以他们的userId也是相同的,所以关闭连接后已经上线的那个用户可能因为userId一样也会关闭连接.
那我们要怎么做?
虽然userId是一样的,但是两次由于是两个浏览器他们的session是不同的,我们只需要将当前的session和初次登录的时候的session比较下看是否相等.如果不相等就不要下线.
2.5匹配器的实现
2.5.1简介
当前如果有玩家进入游戏大厅,点击了开始匹配,此时就进入队列等待另一位对手也进入队列,当两个玩家分数相近,就离开游戏大厅进入游戏房间.就像是我们平常开一把LOL,匹配完对手选择完应用后等待其他玩家选择英雄.全部玩家准备就绪之后就可以进入游戏界面.当然我们这里不是很复杂的匹配.我们只是根据当前玩家的"天梯分数"进行匹配.如果分数相近,就放到一个匹配队列里.
2.5.2具体实现
首先我们定义三个队列,将小于2000分的玩家放入normalQueen队列,将大于等于2000的并且小于3000的放入highQueen队列,将大于3000分的放入veryHighQueue中.此时我们就算是完成了根据玩家天梯分数来分别进行匹配.
2.5.2.1 根据分数将玩家分配到不同的队列
代码展示:
/**
* 多个线程操作一个队列会存在线程安全问题,我们需要保证线程安全
*/
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private RoomManager roomManager;
private ObjectMapper objectMapper=new ObjectMapper();
private Queue<User> nomalQueue=new LinkedList<>();
private Queue<User> highQueue=new LinkedList<>();
private Queue<User> veryHighQueue=new LinkedList<>();
//根据玩家天梯分加入相应的队列
public void add(User user){
if (user.getScore()<2000){
synchronized (nomalQueue){
nomalQueue.offer(user);
nomalQueue.notify();
log.info("玩家"+user.getUsername()+"加入队列");
}
}else if (user.getScore()>=2000&&user.getScore()<3000){
synchronized (highQueue){
highQueue.offer(user);
highQueue.notify();
log.info("玩家"+user.getUsername()+"加入队列");
}
}else {
synchronized (veryHighQueue){
veryHighQueue.offer(user);
veryHighQueue.notify();
log.info("玩家"+user.getUsername()+"加入队列");
}
}
}
public void remove(User user){
if (user.getScore()<2000){
synchronized (nomalQueue){
nomalQueue.remove(user);
log.info("玩家"+user.getUsername()+"离开队列");
}
}else if (user.getScore()>=2000&&user.getScore()<3000){
synchronized (highQueue){
highQueue.remove(user);
log.info("玩家"+user.getUsername()+"离开队列");
}
}else {
synchronized (veryHighQueue){
veryHighQueue.remove(user);
log.info("玩家"+user.getUsername()+"离开队列");
}
}
}
有关于线程安全的问题我们会在后面总结中讲解.
我们这里定义三个线程来无限循环分别扫描三个队列.
//用三个线程扫描三个队列
public Matcher(){
Thread t1=new Thread(()->{
while (true){
handlerQueue(nomalQueue);
}
});
t1.start();
Thread t2=new Thread(()->{
while (true){
handlerQueue(highQueue);
}
});
t2.start();
Thread t3=new Thread(()->{
while (true){
handlerQueue(veryHighQueue);
}
});
t3.start();
}
如果有玩家点击开始匹配,前端就会发送开始匹配的请求到达我们的后端.
后端会进行判断,判断当前玩家是开始匹配还是结束了匹配
假设玩家这里点击了开始匹配,前端就会发送一个"startMatch",后端接收到请求就会执行开始匹配的代码.
当后端接收到请求后,首先返回给客户端一个开始匹配的响应,然后调用我们的匹配器.
进入匹配器之后将当前点击"开始匹配"的玩家根据天梯分进入相对应的队列.负责扫描队列的线程发现有玩家进来了,就会将玩家放入具体匹配逻辑处理的方法中,然后继续扫描等待第二个用户的加入.
此时又有一个玩家进来了,天梯分也是小于2000的,此时也会进入normalQueen队列 ,此时第二个玩家也会被线程扫描到.加入到具体匹配逻辑.
此时,normalQueen队列就有两个玩家就可以开始匹配了.
当双方玩家全部准备就绪之后,我们就为他们提供游戏房间.双方进入游戏房间之后就可以准备开始游戏了.
2.5.2.2游戏房间及游戏房间管理器
对于每一场对局我们都会生成一个新的房间,每个房间都会由房间管理器来对每个房间进行管理
这里我们定义一个对象,来表示当前一个房间.
public class Room {
private String roomId;
private User user1;
private User user2;
public Room() {
// 构造 Room 的时候生成一个唯一的字符串表示房间 id.
// 使用 UUID 来作为房间 id
roomId = UUID.randomUUID().toString();
}
private Integer whiteUser;
}
房间包含的信息有:房间Id,玩家1和玩家2.这里我们使用UUID生成唯一的房间ID.而whiteUser表示的是当前先手下棋的玩家ID,也就是哪个玩家执白子,哪个人就先下棋.
其实房间内还包含落子处理,以及胜负判定等.这里我们后续再进行补充.
定义一个房间管理器.使用哈希表来管理每个房间
public class RoomManager {
ConcurrentHashMap<String,Room> rooms=new ConcurrentHashMap<>();
ConcurrentHashMap<Integer,String> userIdToRoomId=new ConcurrentHashMap<>();
public void add(Room room,Integer userId1,Integer userId2){
rooms.put(room.getRoomId(),room);
userIdToRoomId.put(userId1,room.getRoomId());
userIdToRoomId.put(userId2,room.getRoomId());
}
public void remove(String roomId,Integer userId1,Integer userId2){
rooms.remove(roomId);
userIdToRoomId.remove(userId1);
userIdToRoomId.remove(userId2);
}
public Room getRoomByRoomId(String roomId){
Room room = rooms.get(roomId);
return room;
}
//根据用户id,找到房间id,在找到房间id对应的房间
public Room getRoomByUserId(Integer userId){
String roomId = userIdToRoomId.get(userId);
if (roomId==null){
return null;
}
return rooms.get(roomId);
}
}
这里我们维护了房间ID到房间的映射,以及玩家Id到房间ID的映射,也就是可以通过房间id找到对应的房间.也可以根据当前房间中的玩家Id来找到对应的房间id,进而找到房间.
2.5.2.3通知玩家双方匹配成功
前面我们维护了每个玩家的session信息,此时就派上用场了
WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());
我们拿到了玩家各自的session信息,此时通过WebSocket session来通知玩家双方匹配成功.
2.5.2.4点击开始匹配就开始进入队列
2.5.2.5 连接关闭
如果我们连接关闭之后,此时有也要将玩家在匹配队列中移除
2.5.2.6 线程安全问题
前面我们学习多线程的时候说到过,如果多个线程同时修改同一个变量就可能会存在线程安全问题.线程安全问题使我们在开发中不得不考虑的.就像上面的匹配队列我们就应该考虑是否会有线程安全问题.如果此时有两个线程,一个往队列中添加元素,一个线程从队列中删除元素,此时就会有线程安全问题.我们就要针对每个队列对他的添加元素和删除元素进行加锁,我们在添加元素的时候删除元素的线程就要进入等待,等待添加元素的线程执行完操作之后再删除元素.
我们还要对具体实现匹配逻辑进行加锁,因为扫描三个队列的线程都会操作这一个方法.在这个方法中,matchQueue 是一个共享资源,多个线程可能同时尝试向队列中添加用户(offer)或从队列中移除用户(poll)。
比如线程 A 和线程 B 同时从队列中移除用户(poll()),可能导致某些用户被重复匹配或遗漏。
2.6 游戏房间逻辑
2.6.1 连接新的webSocket协议
可能有人会差异,为什么要再建立一个webSocket连接.这里我们在游戏房间内和匹配大厅内使用两套逻辑.更好的解耦合,使我们的代码逻辑更加清晰
具体连接前面已经介绍过了.这里就不在介绍了.重写父类的那四个方法.
2.6.2具体逻辑
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
GameReadyResponse resp=new GameReadyResponse();
//1.获取当前玩家
User user=(User) session.getAttributes().get(Constants.USER_SESSION_KEY);
//判断当前玩家是不是空
if (user==null){
resp.setOk(false);
resp.setReason("您尚未登录");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
//2.判断是不是多开,在游戏大厅登录并且在游戏房间内也登录也算多开
if (onlineUserManager.getFromGameHall(user.getUserId())!=null||
onlineUserManager.getFromGameRoom(user.getUserId())!=null){
resp.setReason("您已经登录了");
resp.setOk(false);
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
//3.判断当前玩家是不是已经进入了房间
Room room = roomManager.getRoomByUserId(user.getUserId());
if (room==null){
resp.setOk(false);
resp.setReason("用户尚未进入房间");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
//4.让当前玩家在游戏房间内上线
onlineUserManager.enterGameRoom(user.getUserId(),session);
log.info("玩家 {} 进入游戏房间",user.getUsername());
//5.把两个玩家加入到房间
//现在把两个玩家加入是因为如果之前在匹配的时候加入跳转到游戏房间页面的时候可能有玩家掉线了
//在游戏房间内加入玩家,就避免了页面跳转带来的问题
synchronized (room){
if (room.getUser1()==null){
room.setUser1(user);
log.info("第一个进入房间的玩家是 {}",room.getUser1());
//先进入的玩家执白子(先手权)
room.setWhiteUser(user.getUserId());
return;
}
if (room.getUser2()==null){
room.setUser2(user);
log.info("第二个进入房间的玩家是 {}",room.getUser2());
//两个玩家都加入游戏后,开始加载游戏,就像lol,10个玩家全部读条完毕,进入游戏
//通知玩家一
noticeGameReady(room,room.getUser1(),room.getUser2());
//通知玩家二
noticeGameReady(room,room.getUser2(),room.getUser1());
return;
}
resp.setOk(false);
resp.setReason("当前房间已经满了");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}
}
这是建立连接后我们要处理的逻辑.
2.6.2.1 玩家处理
1.获取当前玩家的信息(这里还是通过HTTP session获取到webSocket session的方法)
//1.获取当前玩家
User user=(User) session.getAttributes().get(Constants.USER_SESSION_KEY);
2.如果当前玩家并没有登录,而是直接访问我们的游戏界面(game_room.html),将信息返回给客户端
//判断当前玩家是不是
if (user==null){
resp.setOk(false);
resp.setReason("您尚未登录");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
3.处理多开(这里我们认为玩家在游戏大厅登录并且在游戏房间内也有该玩家,我们也认为是"多开")
//2.判断是不是多开,在游戏大厅登录并且在游戏房间内也登录也算多开
if (onlineUserManager.getFromGameHall(user.getUserId())!=null||
onlineUserManager.getFromGameRoom(user.getUserId())!=null){
resp.setReason("您已经登录了");
resp.setOk(false);
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
4.通过房间管理器中的通过玩家id获取房间方法拿到我们的房间信息.如果房间为空,表示当前用户未进入房间,将信息返回给客户端
//3.判断当前玩家是不是已经进入了房间
Room room = roomManager.getRoomByUserId(user.getUserId());
if (room==null){
resp.setOk(false);
resp.setReason("用户尚未进入房间");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
5.将玩家对应的webSocket session存储(也可以表示用户在线)
//4.让当前玩家在游戏房间内上线
onlineUserManager.enterGameRoom(user.getUserId(),session);
log.info("玩家 {} 进入游戏房间",user.getUsername());
6.把两个玩家加入到房间中
为什么不在匹配成功的时候设置将玩家信息存入到房间对象中.因为匹配成功后涉及到页面跳转,如果页面跳转了,此时有玩家正好断网,就出现了问题.所以我们在这里处理房间内玩家信息
此时我们先判断user1是否为空,如果为空.就将第一个进入的玩家设置在user1中,并且让他拥有先手权 room.setWhiteUser(user.getUserId());
当第二个玩家进入后将他设置到user2中.此时两个玩家都已经准备就绪,服务器就会给玩家双方返回信息告知游戏准备开始
如果当前房间已经有两个人第三个人想加入就会提示房间满了
//5.把两个玩家加入到房间
//现在把两个玩家加入是因为如果之前在匹配的时候加入跳转到游戏房间页面的时候可能有玩家掉线了
//在游戏房间内加入玩家,就避免了页面跳转带来的问题
synchronized (room){
if (room.getUser1()==null){
room.setUser1(user);
log.info("第一个进入房间的玩家是 {}",room.getUser1());
//先进入的玩家执白子(先手权)
room.setWhiteUser(user.getUserId());
return;
}
if (room.getUser2()==null){
room.setUser2(user);
log.info("第二个进入房间的玩家是 {}",room.getUser2());
//两个玩家都加入游戏后,开始加载游戏,就像lol,10个玩家全部读条完毕,进入游戏
//通知玩家一
noticeGameReady(room,room.getUser1(),room.getUser2());
//通知玩家二
noticeGameReady(room,room.getUser2(),room.getUser1());
return;
}
resp.setOk(false);
resp.setReason("当前房间已经满了");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}
//告知玩家的信息(服务器响应)
private void noticeGameReady(Room room, User thisUser, User thatUser) throws IOException {
GameReadyResponse response=new GameReadyResponse();
response.setMessage("gameReady");
response.setRoomId(room.getRoomId());
response.setReason("");
response.setOk(true);
response.setThisUserId(thisUser.getUserId());
response.setThatUserId(thatUser.getUserId());
response.setWhiteUser(room.getWhiteUser());
// 把当前的响应数据传回给玩家.
WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(thisUser.getUserId());
webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
}
这中间有一个非常严重的问题,没错,又是我们的线程安全问题.如果此时两个玩家都是都是点击开始匹配,成功后就会同时进入房间中.两个人都在判断user1为空,都将自己设置为user1.此时就出现了严重的问题.所以我们要对其进行加锁.保证只有一个玩家来设置user1.关于加锁的对象问题,当前我们都是操作房间对象,对房间进行加锁即可.
2.6.2.2 连接关闭处理
和匹配处理一样,获取对象后判断当前的session和之前保存的session是否一样,如果一样就可以关闭连接.
后续游戏进行时玩家意外断开连接的逻辑处理我们后面再处理
//异常导致连接关闭
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
//1.获取当前对象
User user= (User) session.getAttributes().get(Constants.USER_SESSION_KEY);
//2.看当前玩家是否在房间内
WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
//3.如果当前会话和之前存储的是同一个会话我们再进行下线
if (exitSession==session){
onlineUserManager.exitGameRoom(user.getUserId());
log.info("当前玩家 {} 离开房间",user.getUsername());
}
noticeThatUserWin(user);
}
//正常关闭连接
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
//1.获取当前对象
User user= (User) session.getAttributes().get(Constants.USER_SESSION_KEY);
//2.看当前玩家是否在房间内
WebSocketSession exitSession = onlineUserManager.getFromGameRoom(user.getUserId());
//3.如果当前会话和之前存储的是同一个会话我们再进行下线
if (exitSession==session){
onlineUserManager.exitGameRoom(user.getUserId());
log.info("当前玩家 {} 离开房间",user.getUsername());
}
}
2.6.2.3 根据客户端请求处理落子,胜负判定的操作
客户端将落子请求传给handleTransportError方法.我们在Room对象中做出对应的落子处理
接下来我们看Room对象具体处理落子以及胜负判定.
1.生成二维数组表示棋盘
//生成一个二维数组,表示棋盘
private int[][] board=new int[Constants.MAX_ROW][Constants.MAX_COL];
2.处理落子请求
首先我们从客户端获取信息,得出当前这个棋子是谁下的以及下的是哪行哪列
在服务器端,我们设置三种状态
0---表示当前位置未落子(初始化就是全0)
1---表示当前位置玩家1落子
2---表示当前位置玩家2落子
int chess=request.getUserId()==user1.getUserId()?1:2;表示看当前客户端传来的是否是玩家1落子,如果不是就是玩家2.
if语句我们在客户端已经进行判定了,但是这里为了安全性再次进行判断当前位置已经存在棋子.
//1.处理落子请求
GameRequest request=objectMapper.readValue(payload, GameRequest.class);
GameResponse response=new GameResponse();
//这里 0--表示当前未落子;1--表示玩家1落的子;2--表示玩家2落的子
int chess=request.getUserId()==user1.getUserId()?1:2;
Integer row = request.getRow();
Integer col = request.getCol();
if (board[row][col] != 0) {
// 在客户端已经针对重复落子进行过判定了. 此处为了程序更加稳健, 在服务器再判定一次.
System.out.println("当前位置 (" + row + ", " + col + ") 已经有子了!");
return;
}
board[row][col]=chess;
board[row][col]=chess; 将当前玩家落子位置记录.
3.打印棋盘(没有实际意义,只是对我们维护调试代码有很大帮助)
private void printBoard() {
System.out.println("=================="+roomId+"===================");
for (int r=0;r<Constants.MAX_ROW;r++){
for (int c=0;c<Constants.MAX_COL;c++){
System.out.print(board[r][c]+" ");
}
System.out.println();
}
System.out.println("==============================================");
}
4.进行胜负判定
判断所有列
判断所有行
判断对角线
代码展示:
private int checkWinner(int row, int col, int chess) {
// 1. 检查所有的行
// 先遍历这五种情况
for (int c = col - 4; c <= col; c++) {
// 针对其中的一种情况, 来判定这五个子是不是连在一起了~
// 不光是这五个子得连着, 而且还得和玩家落的子是一样~~ (才算是获胜)
try {
if (board[row][c] == chess
&& board[row][c + 1] == chess
&& board[row][c + 2] == chess
&& board[row][c + 3] == chess
&& board[row][c + 4] == chess) {
// 构成了五子连珠! 胜负已分!
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
// 如果出现数组下标越界的情况, 就在这里直接忽略这个异常.
continue;
}
}
// 2. 检查所有列
for (int r = row - 4; r <= row; r++) {
try {
if (board[r][col] == chess
&& board[r + 1][col] == chess
&& board[r + 2][col] == chess
&& board[r + 3][col] == chess
&& board[r + 4][col] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 3. 检查左对角线
for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {
try {
if (board[r][c] == chess
&& board[r + 1][c + 1] == chess
&& board[r + 2][c + 2] == chess
&& board[r + 3][c + 3] == chess
&& board[r + 4][c + 4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 4. 检查右对角线
for (int r = row - 4, c = col + 4; r <= row && c >= col; r++, c--) {
try {
if (board[r][c] == chess
&& board[r + 1][c - 1] == chess
&& board[r + 2][c - 2] == chess
&& board[r + 3][c - 3] == chess
&& board[r + 4][c - 4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 胜负未分, 就直接返回 0 了.
return 0;
}
5.将两个玩家的落子信息以及是否分出胜负告知双方
还有一个用户如果在游戏中掉线我们直接判定另外一个玩家胜利
/4. 给房间中的所有客户端都返回响应.
response.setMessage("putChess");
response.setUserId(request.getUserId());
response.setRow(row);
response.setCol(col);
response.setWinner(winner);
//注意要给两个玩家都要返回落子信息
WebSocketSession session1 = onlineUserManager.getFromGameRoom(user1.getUserId());
WebSocketSession session2 = onlineUserManager.getFromGameRoom(user2.getUserId());
// 万一当前查到的会话为空(玩家已经下线了) 特殊处理一下
if (session1 == null) {
// 玩家1 已经下线了. 直接认为玩家2 获胜!
response.setWinner(user2.getUserId());
System.out.println("玩家1 掉线!");
}
if (session2 == null) {
// 玩家2 已经下线. 直接认为玩家1 获胜!
response.setWinner(user1.getUserId());
System.out.println("玩家2 掉线!");
}
// 把响应构造成 JSON 字符串, 通过 session 进行传输.
String respJson = objectMapper.writeValueAsString(response);
if (session1 != null) {
session1.sendMessage(new TextMessage(respJson));
}
if (session2 != null) {
session2.sendMessage(new TextMessage(respJson));
}
6.游戏结束,更新天梯分数后,销毁房间
//5.游戏结束,销毁房间
if (winner!=0){
System.out.println("游戏结束! 房间即将销毁! roomId=" + roomId + " 获胜方为: " + response.getWinner());
// 更新获胜方和失败方的信息.
int winUserId = response.getWinner();
int loseUserId = response.getWinner() == user1.getUserId() ? user2.getUserId() : user1.getUserId();
//操作数据库更新玩家场数以及天梯分
userMapper.userWin(winUserId);
userMapper.userLose(loseUserId);
// 销毁房间
roomManager.remove(roomId, user1.getUserId(), user2.getUserId());
}
2.6.2.4 游戏中突然有人掉线的处理
如果游戏进行时突然有人掉线,此时如果我们不处理的话,另一名玩家既不能落子,也不能退出游戏,此时我们就要进行处理.
我们这里的解决方法就是在连接关闭之后进行判定,看房间以及对手是否还存在,如果未掉线的玩家也离开了房间,那我们直接返回即可,如果为掉线的玩家还在游戏房间,那么,就要指定他为获胜方.
private void noticeThatUserWin(User user) throws IOException {
// 1. 根据当前玩家, 找到玩家所在的房间
Room room = roomManager.getRoomByUserId(user.getUserId());
if (room == null) {
// 这个情况意味着房间已经被释放了, 也就没有 "对手" 了
System.out.println("当前房间已经释放, 无需通知对手!");
return;
}
// 2. 根据房间找到对手
User thatUser = (user == room.getUser1()) ? room.getUser2() : room.getUser1();
// 3. 找到对手的在线状态
WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(thatUser.getUserId());
if (webSocketSession == null) {
// 这就意味着对手也掉线了!
System.out.println("对手也已经掉线了, 无需通知!");
return;
}
// 4. 构造一个响应, 来通知对手, 你是获胜方
GameResponse resp = new GameResponse();
resp.setMessage("putChess");
resp.setUserId(thatUser.getUserId());
resp.setWinner(thatUser.getUserId());
webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
// 5. 更新玩家的分数信息
int winUserId = thatUser.getUserId();
int loseUserId = user.getUserId();
userMapper.userWin(winUserId);
userMapper.userLose(loseUserId);
// 6. 释放房间对象
roomManager.remove(room.getRoomId(), room.getUser1().getUserId(), room.getUser2().getUserId());
}