基于 SpringBoot + MyBatis 的在线五子棋对战

文章目录

  • 1. 项目设计
  • 2. 效果图展示
  • 3. 创建项目以及配置文件
    • 3.1 创建项目
    • 3.2 配置文件
      • 3.2.1 在 application.properties 中添加配置文件
      • 3.2.2 在 resources 目录下创建mapper
  • 4. 数据库设计与实现
  • 5. 登录注册模块
    • 5.1 设计登录注册交互接口
    • 5.2 设置登录注册功能返回的响应类
    • 5.3 使用 BCrypt 对密码进行加密
    • 5.4 完成 MyBatis 操作
    • 5.5 后端的实现
      • 5.5.1 登录功能后端实现
      • 5.5.2 注册功能后端实现
      • 5.5.3 注销功能
    • 5.6 前端的实现
      • 5.6.1 登录前端实现
      • 5.6.2 注册前端实现
    • 5.7 添加拦截器
  • 6. 大厅界面
    • 6.1 交互接口设计
    • 6.2 用户加载前后交互接口
    • 6.3 前端和后端实现用户信息加载
      • 6.3.1 后端的实现
      • 6.3.2 前端的实现
    • 6.4 实现匹配功能的前端代码
    • 6.5 实现匹配功能的后端代码
      • 6.5.1 创建在线状态
      • 6.5.2 创建房间对象
      • 6.5.3 创建房间管理器
      • 6.5.4 创建匹配队列
      • 6.5.5 写完 MatchController
    • 6.6 大厅界面总结
  • 7. 房间界面
    • 7.1 交互接口设计
    • 7.2 实现房间界面前端代码
      • 7.2.1 设置棋盘界面, 以及显示框.
      • 7.2.2 对应的js文件
      • 7.2.3 初始化 websocket
      • 7.2.4 落子时,发送落子请求
      • 7.2.5 落子时, 发送落子响应
    • 7.3 实现房间界面后端代码
      • 7.3.1 注册GameController
      • 7.3.2 创建GameController
      • 7.3.3 创建对应的响应类和请求类
      • 7.3.4 完成用户房间在线状态管理
      • 7.3.5 添加 MyBatis 用来更新玩家积分
      • 7.3.6 完成处理连接方法
      • 7.3.7 完成处理连接断开的方法和连接异常的方法
      • 7.3.8 在房间管理器中添加代码
      • 7.3.9 Room类添加棋盘代码
      • 7.3.10 实现handleTextMessage方法
      • 7.3.11 实现putChess方法
      • 7.3.12 完成用户胜负判断

1. 项目设计

前端 : HTML + CSS + JavaScript + Jquery + AJAX
后端 : Spring MVC + Spring Boot + MyBatis
基于 SpringBoot + MyBatis 的在线五子棋对战

2. 效果图展示

基于 SpringBoot + MyBatis 的在线五子棋对战
基于 SpringBoot + MyBatis 的在线五子棋对战
基于 SpringBoot + MyBatis 的在线五子棋对战
基于 SpringBoot + MyBatis 的在线五子棋对战
基于 SpringBoot + MyBatis 的在线五子棋对战
基于 SpringBoot + MyBatis 的在线五子棋对战

3. 创建项目以及配置文件

3.1 创建项目

基于 SpringBoot + MyBatis 的在线五子棋对战
基于 SpringBoot + MyBatis 的在线五子棋对战
基于 SpringBoot + MyBatis 的在线五子棋对战
基于 SpringBoot + MyBatis 的在线五子棋对战

3.2 配置文件

3.2.1 在 application.properties 中添加配置文件

spring.datasource.url=jdbc:mysql://localhost:3306/onlineGobang?characterEncoding=utf8&useSSL=true
spring.datasource.username=root
spring.datasource.password=0000
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

mybatis.mapper-locations=classpath:mapper/**Mapper.xml

3.2.2 在 resources 目录下创建mapper

mapper下添加 目录 **.xml 并添加代码

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.onlinemusicserver.mapper."对应的Mapper"">

</mapper>

4. 数据库设计与实现

基于 SpringBoot + MyBatis 的在线五子棋对战
这里使用数据库存储每一个用户的信息, 初始的时候, 天梯分和场次都是默认的.

create database if not exists onlineGobang;

use onlineGobang;

drop table if exists user;
create table user(
    userId int primary key auto_increment,
    username varchar(20) unique,
    password varchar(255) not null,
    score int,
    totalCount int,
    winCount int
);

5. 登录注册模块

5.1 设计登录注册交互接口

登录功能

请求
POST /user/login HTTP/1.1

{username: "",password: ""}

响应
{
	status: 1/-1,
	message: "",
	data: ""
}

注销功能

请求
GET /user/logout HTTP/1.1

响应
HTTP/1.1 200

注册功能

请求
POST /user/register HTTP/1.1

{username: "",password: ""}

响应
{
	status: 1/-1,
	message: "",
	data: ""
}

5.2 设置登录注册功能返回的响应类

通过这个类, 方便前端接收内容

@Data
public class ResponseBodyMessage<T> {
    private int status;
    private String message;
    private T data;

    public ResponseBodyMessage(int status, String message, T data) {
        this.status = status;
        this.message = message;
        this.data = data;
    }
}

5.3 使用 BCrypt 对密码进行加密

在 pom.xml中添加依赖

<!-- security依赖包 (加密)-->
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-web</artifactId>
		</dependency>
		<dependency>
			<groupId>org.springframework.security</groupId>
			<artifactId>spring-security-config</artifactId>
		</dependency>

在启动类中添加注解

@SpringBootApplication(exclude = {org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class})

在 cofig 包下, 创建一个类 AppConfig.

@Configuration
public class AppConfig implements WebMvcConfigurer {

    @Bean
    public BCryptPasswordEncoder getBCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

5.4 完成 MyBatis 操作

在 model 包中, 创建 User 实体类

@Data
public class User {
    private int userId;
    private String username;
    private String password;
    private int score;
    private int totalCount;
    private int winCount;
}

在 mapper 包中, 创建 UserMapper 接口
这个接口中 主要是完成

  1. 注册, 插入一个用户
  2. 登录的时候, 通过名字查询当前用户是否存在.
@Mapper
public interface UserMapper {
    // 注册一个用户, 初始的天梯积分默认为1000, 场次默认为0
    int insert(User user);

    // 通过username查询当前用户是否存在
    User selectByName(String username);
}

在 resources 目录下, 创建一个目录 mapper, 在目录下创建 UserMapper.xml
在 UserMapper.xml 中写对应UserMapper接口中对应的操作

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.gobang.mapper.UserMapper">
    <insert id="insert">
        insert into user values(null,#{username},#{password},1000,0,0)
    </insert>

    <select id="selectByName" resultType="com.example.gobang.model.User">
        select * from user where username = #{username}
    </select>
</mapper>

创建 service 包, 在包下创建 UserService 类, 这个类调用 Mapper接口中的方法


@Service
public class UserService {
    @Autowired
    private UserMapper userMapper;

    public int insert(User user){
        return userMapper.insert(user);
    }

    public User selectByName(String username){
        return userMapper.selectByName(username);
    }
}

5.5 后端的实现

创建 controller包, 在包下创建一个 UserController 类
这个类是实现登录模块的功能的

  1. 这里需要注入 UserService, 调用数据库中的方法
  2. 还需要注入 BCryptPasswordEncoder, 对密码进行加密和比较

5.5.1 登录功能后端实现

注意这里的登录.

  1. 首先去数据库根据用户名查询是否存在当前用户.
  2. 如果不存在, 登录失败.
  3. 如果存在, 用输入的密码, 和数据库中的密码进行比较, 看是否相等. (注: 数据中的密码是加密的)
  4. 如果不相等, 登录失败.
  5. 如果相等, 创建 session, 并登录成功.
 @RequestMapping("/login")
    public ResponseBodyMessage<User> login(@RequestBody User user, HttpServletRequest request) {
        User truUser = userService.selectByName(user.getUsername());
        if (truUser == null) {
            System.out.println("登录失败!");
            return new ResponseBodyMessage<>(-1,"用户名密码错误!",user);
        }else {
            boolean flg = bCryptPasswordEncoder.matches(user.getPassword(),truUser.getPassword());
            if (!flg) {
                return new ResponseBodyMessage<>(-1,"用户名密码错误!",user);
            }
            System.out.println("登录成功!");
            HttpSession session = request.getSession(true);
            session.setAttribute(Constant.USER_SESSION_KEY,truUser);
            return new ResponseBodyMessage<>(1,"登录成功!",truUser);
        }
    }

5.5.2 注册功能后端实现

  1. 首先查看是否该用户是否存在
  2. 存在, 就注册失败
  3. 不存在, 就进行注册, 首先对当前密码进行加密.
  4. 加密之后对这个用户添加到数据库中.
    @RequestMapping("/register")
    public ResponseBodyMessage<User> register(@RequestBody User user) {
        User truUser = userService.selectByName(user.getUsername());
        if (truUser != null) {
            return new ResponseBodyMessage<>(-1,"当前用户名已经存在!",user);
        } else{
            String password = bCryptPasswordEncoder.encode(user.getPassword());
            user.setPassword(password);
            userService.insert(user);
            return new ResponseBodyMessage<>(1,"注册成功!",user);
        }
    }

5.5.3 注销功能

直接删除对应session 为 Constant.USER_SESSION_KEY, 然后跳转到login.html

@RequestMapping("/logout")
    public void userLogout(HttpServletRequest request, HttpServletResponse response) throws IOException, IOException {
        HttpSession session = request.getSession(false);
        // 拦截器的拦截, 所以不可能出现session为空的情况
        session.removeAttribute(Constant.USER_SESSION_KEY);
        response.sendRedirect("login.html");
    }

注意: 这里的Constant.USER_SESSION_KEY 是存储的 session 字符串, 由于该 字符串是不变的, 所以存入 Constant 类中.

5.6 前端的实现

5.6.1 登录前端实现

基于 SpringBoot + MyBatis 的在线五子棋对战

let loginButton = document.querySelector('#loginButton');
		loginButton.onclick = function() {
			let username = document.querySelector('#loginUsername');
			let password = document.querySelector('#loginPassword');
			if (username.value.trim() == ""){
                alert('请先输入用户名!');
                username.focus();
                return;
            }
            if (password.value.trim() == ""){
                alert('请先输入密码!');
                password.focus();
                return;
            }
            $.ajax({
                url: "user/login",
                method: "POST",
                data: JSON.stringify({username: username.value.trim(), password: password.value.trim()}),
                contentType: "application/json;charset=utf-8",
                success: function(data, status) {
                    if(data.status == 1) {
                        location.assign("index.html");
                    }else{
                        alert(data.message);
                        username.value="";
                        password.value="";
                        username.focus();
                    }
                }
			})
		}

5.6.2 注册前端实现

基于 SpringBoot + MyBatis 的在线五子棋对战


		let Reg = document.querySelector('#Reg');
		Reg.onclick = function() {
			let username = document.querySelector('#RegUsername');
			let password1 = document.querySelector('#RegPassword1');
			let password2 = document.querySelector('#RegPassword2');
			if(!$('#checkbox').is(':checked')) {
				alert("请勾选条款");
				return;
			}
			if(username.value.trim() == ""){
                alert("请先输入用户名!");
                username.focus();
                return;
            }
            if(password1.value.trim() == ""){
                alert('请先输入密码!');
                password1.focus();
                return;
            }
            if(password2.value.trim() == ""){
                alert('请再次输入密码!');
                password2.focus();
                return;
            }
			if(username.value.trim().length > 20) {
				alert("用户名长度过长");
				username.value="";
				username.focus();
				return;
			}
			if(password1.value.trim() != password2.value.trim()) {
                alert('两次输入的密码不同!');
                passwrod1.value="";
                password2.value="";
                return;
            }
			if(password1.value.trim().length > 255) {
				alert("当前密码长度过长!");
				password1.value="";
				password2.value="";
				password1.focus();
				return;
			}
			$.ajax({
                url: "user/register",
                method: "POST",
                data: JSON.stringify({username: username.value.trim(), password: password1.value.trim()}),
                contentType: "application/json;charset=utf-8",
                success: function(data,status){
                    if(data.status == 1) {
						alert(data.message);
						location.assign("login.html");
					}else{
						alert(data.message);
						username.value="";
                        password1.value="";
                        password2.value="";
                        username.focus();
					}
                }
            })
		}

5.7 添加拦截器

LoginIntercepter

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        HttpSession session = request.getSession(false);
        if (session != null && session.getAttribute(Constant.USER_SESSION_KEY) != null){
            return true;
        }
        response.sendRedirect("/login.html");
        return false;
    }
}

AppConfig

@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer,WebMvcConfigurer{
	@Override
    public void addInterceptors(InterceptorRegistry registry) {
        LoginInterceptor loginInterceptor = new LoginInterceptor();
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns("/**/login.html")
                .excludePathPatterns("/**/css/**.css")
                .excludePathPatterns("/**/images/**")
                .excludePathPatterns("/**/fonts/**")
                .excludePathPatterns("/**/js/**.js")
                .excludePathPatterns("/**/scss/**")
                .excludePathPatterns("/**/user/login")
                .excludePathPatterns("/**/user/register")
                .excludePathPatterns("/**/user/logout");
    }
}

6. 大厅界面

6.1 交互接口设计

这里客户端1, 点击匹配发送消息给服务器, 客户端2, 也点击匹配发送消息给服务器, 当服务器收到两个人的请求之后, 就需要服务器主动向客户端发送消息, 这里就需要用到 websocket
基于 SpringBoot + MyBatis 的在线五子棋对战

URL: ws://127.0.0.1:8080/findMatch

匹配请求

{
	message: ' startMatch ' / ' stopMatch'
}

匹配响应1 这个响应是点击匹配之后, 立刻返回的响应

{
	status: '1' / '-1'  
	message: ' startMatch ' / ' stopMatch '
}

匹配响应2 这个响应是匹配成功之后的响应

{
	status: '1' / '-1'
	message: 'matchSuccess'
}

6.2 用户加载前后交互接口

请求
GET /user/userInfo HTTP/1.1

响应
{
	status: 1/-1 (1 为成功, -1 为失败),
	message: "对应信息",
	data: "内容",  (用户信息)
}

6.3 前端和后端实现用户信息加载

6.3.1 后端的实现

根据当前存储的session对象, 来查找对应的用户

    @RequestMapping("/userInfo")
    public ResponseBodyMessage<User> getUserInfo(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        User user = (User) session.getAttribute(Constant.USER_SESSION_KEY);
        if (user == null) {
            return new ResponseBodyMessage<>(-1,"当前用户不存在",null);
        }else{
            return new ResponseBodyMessage<>(1,"查找成功!", newUser);
        }
    }

6.3.2 前端的实现

基于 SpringBoot + MyBatis 的在线五子棋对战

      load();

      function load() {
        $.ajax({
          url: "user/userInfo",
          method: "GET",
          success:function(data) {
            if(data.status == 1) {
              let h2 = document.querySelector('#myname');
              h2.innerHTML = "你好! " + data.data.username;
              let game = document.querySelector('#gameMes');
              game.innerHTML = "天梯分数: " + data.data.score + " | " + "场数: " + data.data.totalCount + " | " + "获胜场数: "+ data.data.winCount;

            }else{
              alert(data.message);
              location.assign("login.html");
            }
          }
        })
      }

6.4 实现匹配功能的前端代码

        let websocketUrl = 'ws://'+ location.host +'/findMatch';
        let websocket = new WebSocket(websocketUrl);
        
        // 连接成功的时候调用的方法
        websocket.onopen = function() {
          console.log("onopen");
        }
        
        // 连接关闭的时候调用的方法
        websocket.onclose = function() {
          console.log("onclose");
        }
        
        // 连接异常的时候调用的方法
        websocket.onerror = function() {
          console.log("onerrot");
        }

        // 监听整个窗口关闭的事件, 当窗口关闭, 主动的去关闭websocket连接
        window.onbeforeunload = function() {
          websocket.close();
        }

        // 连接成功收到的响应
        websocket.onmessage = function(e) {
          // 先将Json格式 e 化为 响应对象
          let resp = JSON.parse(e.data);
          // 获取到 匹配按钮
          let play = document.querySelector('#beginPlay');
          // 等于-1是错误的起来, 打印错误的信息, 并跳转到登录页面
          if (resp.status == -1) {
            alert(resp.message);
            location.assign("login.html");
            return;
          }
          // 这里就都是正常的响应, 那么就判断是开始匹配, 还是结束匹配
          if (resp.message == 'startMatch') {
            //开始匹配
            console.log("开始匹配");
            play.innerHTML = '匹配中...(点击停止)';
          }else if(resp.message == 'stopMatch') {
            //结束匹配
            console.log("结束匹配");
            play.innerHTML = '开始匹配';
          }else if(resp.message == 'matchSuccess') {
            //匹配成功
            console.log("匹配成功");
            location.assign('room.html');
          }else{
            // 按理不会触发这个else
            alert(resp.message);
            console.log("收到非法响应");
          }
        }
        
        // 获取到匹配按钮
        let play = document.querySelector('#beginPlay');
        // 匹配按钮点击事件
        play.onclick = function() {
          // 判断当前 readyState 是否是OPEN状态的
          if (websocket.readyState == websocket.OPEN) {
            // 当前 readyState 处于OPEN 状态, 说明链接是好的
            if (play.innerHTML == '开始匹配') {
              // 发送开始匹配的请求
              websocket.send(JSON.stringify(
                {
                  message: 'startMatch',
                }
              ))
            }else if(play.innerHTML == '匹配中...(点击停止)'){
              // 发送停止匹配的请求
              websocket.send(JSON.stringify(
                {
                  message: 'stopMatch',
                }
              ))
            }
          }else{
            // 这里就是链接异常的情况
            alert('当前您的链接已经断开, 请重新登录');
            location.assign("login.html");
          }
        }

6.5 实现匹配功能的后端代码

这里是触发url的响应地址

@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer{

    @Autowired
    private MatchController matchController;
 
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(matchController,"/findMatch")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}

6.5.1 创建在线状态

当用户登录的时候, 就让用户状态添加到哈希表中
由于这里是 多线程的状态下, 很多用户访问同一个哈希表就会出现线程安全的问题, 所以这里就使用 ConcurrentHashMap, 确保了线程安全问题.

  1. 这里存储的, key是用户的Id, value是对应的WebSocketSession的信息.
  2. 提供三个方法
    • 进入房间的时候, 将用户的状态存入哈希表中
    • 退出房间的时候, 将用户的状态从哈希表中删除
    • 获取当前用户的 WebSocketSession 信息
@Component
public class OnlineUserManager {
    // 这个哈希表是表示当前用户在游戏大厅的在线状态
    private ConcurrentHashMap<Integer, WebSocketSession> gameState = new ConcurrentHashMap<>();
    public void enterGameIndex(int userId, WebSocketSession webSocketSession) {
        gameState.put(userId,webSocketSession);
    }

    public void exitGameHall(int userId) {
        gameState.remove(userId);
    }

    public WebSocketSession getState(int userId) {
        return gameState.get(userId);
    }
}

6.5.2 创建房间对象

房间对象, 每一房间中, 会有RoomId, 和2个用户信息.
所以这里需要有一个完全不可重复的RoomId, 这里就使用Java中的 UUID来解决


// 游戏房间
@Data
public class Room {
    private String roomId;
    private User user1;
    private User user2;
 	public Room() {
        this.roomId = UUID.randomUUID().toString();
	}
}

6.5.3 创建房间管理器

按理 也是使用哈希表存储, 也有线程安全问题, 所以也使用ConcurrentHashMap
提供3个方法

  1. 添加用户进入到房间
  2. 删除房间中的用户
  3. 提供房间Id得到房间对象

// 房间管理器
@Component
public class RoomManager {
    private ConcurrentHashMap<String,Room> rooms = new ConcurrentHashMap<>();
	
	public void insert(Room room) {
        rooms.put(room.getRoomId(),room);
    }
	
	public void remove(String roomId) {
        rooms.remove(roomId);
    }
	
    public Room findRoomByRoomId(String roomId) {
        return rooms.get(roomId);
    }

6.5.4 创建匹配队列

匹配队列, 首先按照分数将用户分为三个等级.

  1. <2000 , 属于简单用户
  2. >= 2000 && < 3000 , 属于普通用户
  3. >=3000 , 属于高级用户
    // 创建匹配队列 按等级划分
    // 1. < 2000
    private Queue<User> simpleQueue = new LinkedList<>();
    // 2. >= 2000 && < 3000
    private Queue<User> normalQueue = new LinkedList<>();
    // 3. >= 3000
    private Queue<User> highQueue = new LinkedList<>();

这里就通过队列来分为为三个等级, 来完成匹配和退出

  1. 点击匹配的时候, 按照用户当前的等级, 将用户入队
  2. 取消匹配的时候, 按照用户当前的等级, 将用户从队列中删除
  3. 创建三个线程, 一直循环的去对应等级队列中进行获取用户, 如果当前队列中的用户有2个以上的时候, 就进行匹配.
     

这里也有线程安全的问题, 这里同一个队列中, 用户并发的入队, 和删除用户操作, 就会产生线程安全的问题. 如果是不同的队列, 就不涉及线程安全的问题
解决办法: 对于同一个队列中的操作进行加锁.

问题2: 这里的三个线程, 是循环的去等待, 如果当前队列中迟迟没有人进来, 而线程还是循环的执行下去, 这样的资源消耗就非常的大.
所以在进行判断当前用户是否有2个以上的时候, 如果当前用户小于2个, 就将当前的队列进行wait(), 直到再次有用户加入进来的时候,就解锁, 再去判断当前用户是否有2个以上的用户.


// 匹配器, 这个类是用来完成匹配功能的
@Component
public class Matcher {
    // 创建匹配队列 按等级划分
    // 1. < 2000
    private Queue<User> simpleQueue = new LinkedList<>();
    // 2. >= 2000 && < 3000
    private Queue<User> normalQueue = new LinkedList<>();
    // 3. >= 3000
    private Queue<User> highQueue = new LinkedList<>();

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private RoomManager roomManager;

    /**
     * 将当前玩家添加到匹配队列中
     * @param user
     */
    public void insert(User user) {
        // 按等级加入队列中
        if (user.getScore() < 2000) {
            synchronized (simpleQueue) {
                simpleQueue.offer(user);
                // 只要有用户进入了, 就进行唤醒
                simpleQueue.notify();
            }
        }else if (user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (normalQueue) {
                normalQueue.offer(user);
                normalQueue.notify();
            }
        }else {
            synchronized (highQueue) {
                highQueue.offer(user);
                highQueue.notify();
            }
        }
    }

    /**
     * 将当前玩家匹配队列中删除
     * @param user
     */
    public void remove(User user) {
        // 按照当前等级去对应匹配队列中删除
        if (user.getScore() < 2000) {
            synchronized (simpleQueue){
                simpleQueue.remove(user);
            }
        }else if (user.getScore() >= 2000 && user.getScore() < 3000) {
            synchronized (normalQueue) {
                normalQueue.remove(user);
            }
        }else {
            synchronized (highQueue) {
                highQueue.remove(user);
            }
        }
    }

    /**
     * 这里使用3个线程去一直的进行查看是否有2个以上的人, 如果有进行匹配
     */
    public Matcher() {
        // 创建三个线程, 操作三个匹配队列
        Thread t1 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    handlerMatch(simpleQueue);
                }
            }
        };
        t1.start();
        Thread t2 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    handlerMatch(normalQueue);
                }
            }
        };
        t2.start();
        Thread t3 = new Thread() {
            @Override
            public void run() {
                while (true) {
                    handlerMatch(highQueue);
                }
            }
        };
        t3.start();
    }

    private void handlerMatch(Queue<User> matchQueue) {
        synchronized (matchQueue) {
            try{
                // 1. 先查看当前队列中的元素个数, 是否满足两个
                // 这里使用while, 以防为0的时候, 被唤醒,然后没有再次判断导致进入下面操作.
                while (matchQueue.size() < 2) {
                    // 用户小于2个的时候, 就进行等待, 以免浪费资源
                    matchQueue.wait();
                }
                // 2. 尝试从队列中取出两个玩家
                User player1 = matchQueue.poll();
                User player2 = matchQueue.poll();
                // 打印日志
                System.out.println("匹配到的两个玩家: " + player1.getUsername()+ " , " + player2.getUsername());
                // 3. 获取到玩家的 websocket 的会话.
                WebSocketSession session1 = onlineUserManager.getState(player1.getUserId());
                WebSocketSession session2 = onlineUserManager.getState(player2.getUserId());
                // 再次判断是否为空
                if (session1 == null && session2 != null) {
                    matchQueue.offer(player2);
                    return;
                }
                if (session1 != null && session2 == null) {
                    matchQueue.offer(player1);
                    return;
                }
                if (session1 == null && session2 == null) {
                    return;
                }
                if (session1 == session2) {
                    matchQueue.offer(player1);
                    return;
                }
                // 4. 把两个玩家放入一个游戏房间中
                Room room = new Room();
                roomManager.insert(room,player1.getUserId(),player2.getUserId());

                // 5. 给玩家反馈信息, 通知匹配到了对手
                MatchResponse response1 = new MatchResponse();
                response1.setStatus(1);
                response1.setMessage("matchSuccess");
                String json1 = objectMapper.writeValueAsString(response1);
                session1.sendMessage(new TextMessage(json1));

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

6.5.5 写完 MatchController

websocket 有4个方法.

  1. 连接成功的时候调用的方法, 这里需要去判断多开的问题, 由于用户同时登录一个账号的时候, 就会出现多开, 解决办法就是查询当前用户的在线状态, 如果当前用户在线, 就退出当前登录. 如果没有多开就设置登陆状态
  2. 异常关闭的情况, 获取用户的信息, 然后设置在线状态为不在线, 然后删除匹配队列中的用户
  3. 退出的时候调用的方法, 获取用户的信息, 然后设置在线状态为不在线, 然后删除匹配队列中的用户
  4. 处理收到请求的方法, 通过前端发来的请求, 判断是否是开始匹配还是停止匹配.
@Component
public class MatchController extends TextWebSocketHandler {
    private ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    private OnlineUserManager onlineUserManager;

    @Autowired
    private Matcher matcher;

	// 连接成功的时候就会调用该方法
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 玩家上线
        // 1. 获取用户信息
        User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
        // 2. 判断当前用户是否已经登录
        if (onlineUserManager.getState(user.getUserId()) != null ) {
            // 当前用户已经登录
            MatchResponse message = new MatchResponse();
            message.setMessage("当前用户已经登录!");
            message.setStatus(-1);
            session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(message)));
            session.close();
            return;
        }
        // 3. 设置在线状态
        onlineUserManager.enterGameIndex(user.getUserId(),session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 处理开始匹配 和 停止匹配
        User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
        String payload = message.getPayload();
        MatchRequest matchRequest = objectMapper.readValue(payload, MatchRequest.class);
        MatchResponse matchResponse = new MatchResponse();
        if (matchRequest.getMessage().equals("startMatch")) {
            // 进入匹配队列
            // 创建匹配队列, 加入用户
            matcher.insert(user);
            // 返回响应给前端
            matchResponse.setStatus(1);
            matchResponse.setMessage("startMatch");
        }else if(matchRequest.getMessage().equals("stopMatch")) {
            // 退出匹配队列
            // 创建匹配队列, 将用户移除
            matcher.remove(user);
            matchResponse.setMessage("stopMatch");
            matchResponse.setStatus(1);
        }else{
            matchResponse.setStatus(-1);
            matchRequest.setMessage("非法匹配");
            // 非法情况
        }
        session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(matchResponse)));
    }

    // 异常情况
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 玩家下线
        // 1. 获取用户信息
        User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
        WebSocketSession webSocketSession = onlineUserManager.getState(user.getUserId());
        if(webSocketSession == session) {
            // 2. 设置在线状态
            onlineUserManager.exitGameHall(user.getUserId());
        }
        matcher.remove(user);
    }

    // 关闭情况
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 玩家下线
        // 1. 获取用户信息
        User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
        WebSocketSession webSocketSession = onlineUserManager.getState(user.getUserId());
        if(webSocketSession == session) {
            // 2. 设置在线状态
            onlineUserManager.exitGameHall(user.getUserId());
        }
        matcher.remove(user);
    }
}

6.6 大厅界面总结

  1. 这里要注意多线程环境下, 多个用户同时使用同一个哈希表的时候, 进行添加和删除的时候, 会有线程安全的问题, 那么这里就需要使用 ConcurrentHashMap
  2. 在多线程环境下, 按照等级分的队列, 在多线程环境下, 并发的进行入队的时候, 删除的队列中用户的时候, 也会有线程安全问题, 这里针对同一个队列就可以进行加锁.
  3. 由于创建3个线程循环的进入队列中查看是否满足2个用户, 如果当前的环境下, 用户特别少, 一直去循环的进入, 会造成CPU占用率特别高, 所以这里就使用wai()等待, 在有用户进入匹配队列的时候,再去唤醒notify().
  4. 防止多开, 多个地方登录同一个账号就会出现很多问题, 这里在进行连接的时候判断, 如果用户已经在线, 就不让该地方用户登录.
  5. 要想让 房间是第一无二, 就需要使用 UUID, 那么roomId也要使用 字符串的格式.

基于 SpringBoot + MyBatis 的在线五子棋对战

7. 房间界面

7.1 交互接口设计

连接URL

ws://127.0.0.1:8080/game

当双方玩家都已经连接好了 发送响应

{
	message: 'gameReady' 
	status: '1 / -1'  (1是正常响应, -1 是错误响应) 
	roomId: ' ' 
	thisUserId: ' ' (自己用户Id)
	thatUserId: ' ' (对方用户Id)
	whiteUser: ' ' (先手方)
}

落子的时候的请求

{
	message: ' putChess ' 
	userId: ' '  (落子的用户Id)
	row: ' ' (落子的第几行)
	col: ' ' (落子的第几列)
}

落子的时候的响应

{
	message: 'putChess;
	userId: ' '
	row: ' '
	col: ' '
	winner: ' ' (获胜者, 和用户Id一致, 如果没有获胜, 就是0)
}

7.2 实现房间界面前端代码

7.2.1 设置棋盘界面, 以及显示框.

这里的 canvas 是用来绘制棋盘的,

room.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>游戏房间</title>
    <link href="css/game_room.css" rel="stylesheet" type="text/css" media="all" />
</head>
<body>
        <div class="container">
            <div class="one">
                <!-- 棋盘区域, 需要基于 canvas 进行实现 -->
                <canvas id="chess" width="450px" height="450px">
                </canvas>
                <!-- 显示区域 -->
                <div id="screen"> 等待玩家连接中... </div>
            </div>
        </div>
        <script src="js/script.js"></script>
</body>
</html>

game_room.css

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}
html, body {
    height: 100%;

    background-image: url(../images/bg.jpg);
    background-repeat: no-repeat;
    background-position: center;
    background-size: cover;
}
.container {
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
}

#screen {
    width: 450px;
    height: 50px;
    margin-top: 10px;
    background-color: #fff;
    font-size: 22px;
    line-height: 50px;
    text-align: center;
}
.backButton {
    width: 450px;
    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;
}

.backButton:active {
    background-color: gray;
}

7.2.2 对应的js文件

  1. 这里的 setScreenText 这个方法是用来将显示框中的内容, 根据当前是谁下棋来改变内容.
  2. 这里的 initGame 这个方法是用来初始画棋盘的, 棋盘大小为 15 * 15
  3. 内部的 oneStep 是当点击下子之后, 会绘制对应颜色的棋子.
  4. 注意这里的棋盘数组, 为0是没有落子, 为1是落子了.
  5. 这里的gameInfo, 内部内容是全局的.用来接收传过来的响应
let gameInfo = {
    roomId: null,
    thisUserId: null,
    thatUserId: null,
    isWhite: true,
}

//
// 设定界面显示相关操作
//

function setScreenText(me) {
    let screen = document.querySelector('#screen');
    if (me) {
        screen.innerHTML = "轮到你落子了!";
    } else {
        screen.innerHTML = "轮到对方落子了!";
    }
}

//
// 初始化 websocket
//
// TODO

//
// 初始化一局游戏
//
function initGame() {
    // 是我下还是对方下. 根据服务器分配的先后手情况决定
    let me = gameInfo.isWhite;
    // 游戏是否结束
    let over = false;
    let chessBoard = [];
    //初始化chessBord数组(表示棋盘的数组)
    for (let i = 0; i < 15; i++) {
        chessBoard[i] = [];
        for (let j = 0; j < 15; j++) {
            chessBoard[i][j] = 0;
        }
    }
    let chess = document.querySelector('#chess');
    let context = chess.getContext('2d');
    context.strokeStyle = "#BFBFBF";
    // 背景图片
    let logo = new Image();
    logo.src = "image/sky.jpeg";
    logo.onload = function () {
        context.drawImage(logo, 0, 0, 450, 450);
        initChessBoard();
    }

    // 绘制棋盘网格
    function initChessBoard() {
        for (let i = 0; i < 15; i++) {
            context.moveTo(15 + i * 30, 15);
            context.lineTo(15 + i * 30, 430);
            context.stroke();
            context.moveTo(15, 15 + i * 30);
            context.lineTo(435, 15 + i * 30);
            context.stroke();
        }
    }

    // 绘制一个棋子, me 为 true
    function oneStep(i, j, isWhite) {
        context.beginPath();
        context.arc(15 + i * 30, 15 + j * 30, 13, 0, 2 * Math.PI);
        context.closePath();
        var gradient = context.createRadialGradient(15 + i * 30 + 2, 15 + j * 30 - 2, 13, 15 + i * 30 + 2, 15 + j * 30 - 2, 0);
        if (!isWhite) {
            gradient.addColorStop(0, "#0A0A0A");
            gradient.addColorStop(1, "#636766");
        } else {
            gradient.addColorStop(0, "#D1D1D1");
            gradient.addColorStop(1, "#F9F9F9");
        }
        context.fillStyle = gradient;
        context.fill();
    }

    chess.onclick = function (e) {
        if (over) {
            return;
        }
        if (!me) {
            return;
        }
        let x = e.offsetX;
        let y = e.offsetY;
        // 注意, 横坐标是列, 纵坐标是行
        let col = Math.floor(x / 30);
        let row = Math.floor(y / 30);
        if (chessBoard[row][col] == 0) {
            // TODO 发送坐标给服务器, 服务器要返回结果

            oneStep(col, row, gameInfo.isWhite);
            chessBoard[row][col] = 1;
        }
    }

    // TODO 实现发送落子请求逻辑, 和处理落子响应逻辑. 
}

initGame();

7.2.3 初始化 websocket

  1. 在服务器传过来请求的时候, 两个用户都已经准备好了, 首先判断是否是正确的请求.
  2. 在请求是正确的时候, 将传过来的信息存入到gameInfo中, 注意这里的isWhite 是判断是否是先手方.
  3. 注意只有2个人都建立连接了, 才初始画棋盘, 所以在这里初始化棋盘为好.
  4. 棋盘绘制好之后, 在显示框中, 显示对应的信息, 调用对应的 setScreenText 方法

let websocketUrl = 'ws://'+ location.host +'/game';
let websocket = new WebSocket(websocketUrl);

websocket.onopen = function() {
    console.log("房间链接成功!");
}
websocket.onclose = function() {
    console.log("房间断开链接");
}
websocket.onerror = function() {
    console.log("房间出现异常");
}
window.onbeforeunload = function() {
    websocket.close();
}
websocket.onmessage = function(e) {
    console.log(e.data);
    let resp = JSON.parse(e.data);

    if(resp.message != 'gameReady') {
        console.log("响应类型错误");
        location.assign("index.html");
        return;
    }
    if(resp.status == -1) {
        alert("游戏链接失败!");
        location.assign("index.html");
        return;
    }
    
    gameInfo.roomId == resp.roomId;
    gameInfo.thisUserId = resp.thisUserId;
    gameInfo.thatUserId = resp.thatUserId;
    gameInfo.isWhite = resp.whiteUser == resp.thisUserId;

    // 初始化棋盘
    initGame();

    // 设置显示内容
    setScreenText(gameInfo.isWhite);
}

7.2.4 落子时,发送落子请求

在初始化棋盘之后, 在点击的时候, 发送落子请求
注意发送的对应的格式

基于 SpringBoot + MyBatis 的在线五子棋对战

function send(row,col) {
        let req = {
            message: 'putChess',
            userId: gameInfo.thisUserId,
            row: row,
            col: col
        };

        websocket.send(JSON.stringify(req));
    }

7.2.5 落子时, 发送落子响应

  1. 注意这里的响应是在落子之后, 所以要写在initGame() 中
  2. 在接收的时候, 首先将JSON格式响应转成可以接收的格式
  3. 判断响应是否正常, 排除响应错误的情况
  4. 判断当前是自己落子还是对方落子, 然后根据落子绘制棋子
  5. 落子之后, 交换落子的权利, 然后将显示的内容改变.
  6. 再次去判断是否游戏结束. 结束的时候,在显示框显示获胜信息, 并添加一个返回大厅的按钮, 以免直接返回了(用户看不到失败的信息.
websocket.onmessage = function(e) {
        console.log(e.data);
        let resp = JSON.parse(e.data);
        if (resp.message != 'putChess') {
            console.log("响应类型错误!");
            location.assign("index.html")
            return;
        }

        if (resp.userId == gameInfo.thisUserId) {
            // 自己落子
            oneStep(resp.col, resp.row, gameInfo.isWhite);
            chessBoard[resp.row][resp.col] = 1;
        } else if (resp.userId == gameInfo.thatUserId) {
            // 别人落子
            oneStep(resp.col, resp.row, !gameInfo.isWhite);
            chessBoard[resp.row][resp.col] = 1;
        }else{
            // 落子异常
            console.log("userId 异常");
            return;
        }

        // 交换落子
        me = !me;
        setScreenText(me);

        // 判断游戏是否结束
        let screenDiv = document.querySelector('#screen');
        if (resp.winner != 0) {
            console.log(resp.winner+" " + gameInfo.thisUserId+" " + gameInfo.thatUserId);
            if (resp.winner == gameInfo.thisUserId) {
                screenDiv.innerHTML = "恭喜你, 获胜了!";
            }else if(resp.winner == gameInfo.thatUserId) {
                screenDiv.innerHTML = "游戏结束, 失败了!";
            }else {
                console.log("winner 错误");
                alert("当前 winner字段错误 winner = "+ resp.winner);
            }
            // location.assign('index.html');
            // 增加一个按钮, 返回游戏大厅
            let backBtn = document.createElement('button');
            backBtn.innerHTML = "返回游戏大厅";
            backBtn.className = "backButton";
            let one = document.querySelector('.one');
            backBtn.onclick = function() {
                location.assign("index.html");
            }
            one.appendChild(backBtn);
        }

7.3 实现房间界面后端代码

7.3.1 注册GameController

@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer,WebMvcConfigurer{

    @Autowired
    private GameController gameController;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(gameController,"/game")
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }

7.3.2 创建GameController

  1. afterConnectionEstablished 这个方法是在建立连接时候的方法.
  2. handleTextMessage 这个方法是接收发送的响应
  3. handleTransportError 这个方法是出现异常的时候执行的
  4. afterConnectionClosed 这个方法是关闭websocket的时候执行的
@Component
public class GameController extends TextWebSocketHandler {
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
    }

    @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 {
    }
}

7.3.3 创建对应的响应类和请求类

双方进入房间准备就绪的响应

// 客户端链接成功后, 返回的响应
@Data
public class GameReadyResponse {
    private String message;
    private int status;
    private String roomId;
    private int thisUserId;
    private int thatUserId;
    private int whiteUser;
}

落子请求

// 落子的请求
@Data
public class GameRequest {
    private String message;
    private int userId;
    private int row;
    private int col;
}

落子响应

//落子响应
@Data
public class GameResponse {
    private String message;
    private int userId;
    private int row;
    private int col;
    private int winner;
}

7.3.4 完成用户房间在线状态管理

在之前的 OnlineUserManager 中添加代码

  1. enterGameRoom, 进入房间添加到哈希表中(上线)
  2. exitGameRoom, 退出房间从哈希表中删除(下线)
  3. getRoomState, 获取当前用户的websocketsession信息
// 这个哈希表是表示当前用户在游戏房间的在线状态
    private ConcurrentHashMap<Integer, WebSocketSession> roomState = new ConcurrentHashMap<>();

    public void enterGameRoom(int userId, WebSocketSession webSocketSession){
        roomState.put(userId,webSocketSession);
    }
    public void exitGameRoom(int userId) {
        roomState.remove(userId);
    }
    public WebSocketSession getRoomState(int userId) {
        return roomState.get(userId);
    }

7.3.5 添加 MyBatis 用来更新玩家积分

UserMapper

    // 总场数 + 1, 获胜场数+1, 天梯分数 + 50
    void userWin(int userId);

    // 总场数 + 1, 天梯分数 -50
    void userLose(int userId);

UserMapper.xml

    <update id="userWin">
        update user set totalCount = totalCount+1 , winCount = winCount+1, score = score + 50 where userId = #{userId}
    </update>
    <update id="userLose">
        update user set totalCount = totalCount+1, score = score - 50 where userId = #{userId}
    </update>

UserService

// 总场数 + 1, 获胜场数+1, 天梯分数 + 50
    public void userWin(int userId){
        userMapper.userWin(userId);
    }

    // 总场数 + 1, 天梯分数 -50
    public void userLose(int userId) {
        userMapper.userLose(userId);
    }

7.3.6 完成处理连接方法

  1. 首先获取用户的信息
  2. 判断当前是否已经进入房间了, 防止未匹配成功
  3. 判断是否多开, 这里要查询房间在线情况, 和大厅在线情况.
  4. 然后让用户房间的在线状态处于在线.
  5. 首先判断用户1是否上线, 上线就添加到当前房间来, 用户2再上线的时候也添加房间来, 这里可以设置谁是先手方, 根据自己设定的规则.我这里是随机取0~9的数字, 如果是偶数用户1就是先手, 如果是奇数用户2就是先手
  6. 当用户都进入房间的时候, 通知玩家准备就绪了
  7. 注意这里的线程安全问题. 多个用户进入同一个方法,就有可能出现线程安全问题, 由于是同一个房间的用户进行, 只需要对房间对象加锁就可以了.
 @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        GameReadyResponse readyResponse = new GameReadyResponse();

        // 获取用户信息
        User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
        // 判断当前是否已经进入房间
        Room room = roomManager.findRoomByUserId(user.getUserId());
        if (room == null) {
            readyResponse.setStatus(-1);
            readyResponse.setMessage("用户尚未匹配到!");
            session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(readyResponse)));
            return;
        }
        // 判断当前是否多开
        if (onlineUserManager.getRoomState(user.getUserId()) != null || onlineUserManager.getState(user.getUserId()) != null) {
            readyResponse.setMessage("当前用户已经登录!");
            readyResponse.setStatus(-1);
            session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(readyResponse)));
            return;
        }

        // 上线
        onlineUserManager.enterGameRoom(user.getUserId(), session);

        synchronized (room) {
            if (room.getUser1() == null) {
                room.setUser1(user);
                System.out.println("玩家1 " + user.getUsername() + " 已经准备好了");
                return;
            }

            if (room.getUser2() == null) {
                room.setUser2(user);
                System.out.println("玩家2 " + user.getUsername() + " 已经准备好了");

                Random random = new Random();
                int num = random.nextInt(10);
                if (num % 2 == 0) {
                    room.setWhiteUser(room.getUser1().getUserId());
                } else{
                    room.setWhiteUser(room.getUser2().getUserId());
                }
                // 通知玩家1
                noticeGameReady(room,room.getUser1(),room.getUser2());
                // 通知玩家2
                noticeGameReady(room,room.getUser2(),room.getUser1());
                return;
            }
        }

        readyResponse.setStatus(-1);
        readyResponse.setMessage("房间已经满了");
        session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(readyResponse)));
    }
private void noticeGameReady(Room room, User user1, User user2) throws IOException {
        GameReadyResponse resp = new GameReadyResponse();
        resp.setStatus(1);
        resp.setMessage("gameReady");
        resp.setRoomId(room.getRoomId());
        resp.setThisUserId(user1.getUserId());
        resp.setThatUserId(user2.getUserId());
        resp.setWhiteUser(room.getWhiteUser());

        WebSocketSession webSocketSession = onlineUserManager.getRoomState(user1.getUserId());
        webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
    }

7.3.7 完成处理连接断开的方法和连接异常的方法

  1. 首先获取用户的信息
  2. 然后设置用户房间状态为下线
  3. 注意这里掉线了, 就需要判断对方赢了.
    • 判断对方是否掉线, 如果对方也掉线了, 就无需通知谁赢了
    • 如果对方没有掉线, 就通知对方赢了
    • 获胜之后, 要对玩家的信息, 场次, 胜场进行更新. 然后关闭房间

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        // 异常下线
        // 下线
        User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
        WebSocketSession exitSession = onlineUserManager.getRoomState(user.getUserId());
        if(exitSession == session) {
            onlineUserManager.exitGameRoom(user.getUserId());
        }
        System.out.println("当前用户: " + user.getUsername()+" 异常下线了");

        noticeThatUserWin(user);
    }
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        // 下线
        User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
        WebSocketSession exitSession = onlineUserManager.getRoomState(user.getUserId());
        if(exitSession == session) {
            onlineUserManager.exitGameRoom(user.getUserId());
        }
        System.out.println("当前用户: " + user.getUsername()+" 离开房间");

        noticeThatUserWin(user);
    }

    private void noticeThatUserWin(User user) throws IOException {
        Room room = roomManager.findRoomByUserId(user.getUserId());
        if (room == null) {
            System.out.println("房间已经关闭");
            return;
        }
        // 找到对手
        User thatUser = (user == room.getUser1()) ? room.getUser2() : room.getUser1();
        // 找到对手的状态
        WebSocketSession session = onlineUserManager.getRoomState(thatUser.getUserId());
        if (session == null) {
            // 都掉线了
            System.out.println("都掉线了, 无需通知");
            return;
        }
        // 这里通知对手获胜
        GameResponse gameResponse = new GameResponse();
        gameResponse.setMessage("putChess");
        gameResponse.setUserId(thatUser.getUserId());
        gameResponse.setWinner(thatUser.getUserId());
        session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(gameResponse)));

        // 更新玩家分数信息
        int winId = thatUser.getUserId();
        int loseId = user.getUserId();
        userService.userWin(winId);
        userService.userLose(loseId);
        // 释放房间对象
        roomManager.remove(room.getRoomId(),room.getUser1().getUserId(),room.getUser2().getUserId());
    }

7.3.8 在房间管理器中添加代码

  1. 添加哈希表, 管理用户对应的房间号
  2. key为用户的Id, value为用户的对应房间号
    private ConcurrentHashMap<Integer,String> Ids = new ConcurrentHashMap<>();

    public void insert(Room room,int userId1, int userId2) {
        Ids.put(userId1,room.getRoomId());
        Ids.put(userId2,room.getRoomId());
    }

    public void remove(String roomId,int userId1, int userId2) {
        Ids.remove(userId1);
        Ids.remove(userId2);
    }

	public Room findRoomByUserId(int userId) {
        String roomId = Ids.get(userId);
        if (roomId == null) {
            return null;
        }
        return rooms.get(roomId);
    }

7.3.9 Room类添加棋盘代码

  1. 这里的 Constant.ROW 和 Constant.COL 都是不变的常量. 放到 Constant类中. 这里初始化的棋盘数组也是15 * 15的
  2. 这里Room要注入Spring对象, 不能使用@Autowired @Resource注解. 需要使用context

修改启动类

public class GobangApplication {

	public static ConfigurableApplicationContext context;

	public static void main(String[] args) {
		context = SpringApplication.run(GobangApplication.class, args);
	}

}
// 游戏房间
@Data
public class Room {
    private String roomId;
    private User user1;
    private User user2;
    private int whiteUser;

    private OnlineUserManager onlineUserManager;

    private RoomManager roomManager;

    private UserService userService;

    public Room() {
        this.roomId = UUID.randomUUID().toString();

        onlineUserManager = GobangApplication.context.getBean(OnlineUserManager.class);

        roomManager = GobangApplication.context.getBean(RoomManager.class);

        userService = GobangApplication.context.getBean(UserService.class);
    }

    // 为0就是为落子, 为1就是用户1落子, 为2就是用户2落子
    private int[][] board= new int[Constant.ROW][Constant.COL];

    private ObjectMapper objectMapper = new ObjectMapper();

}

7.3.10 实现handleTextMessage方法

落子请求

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        // 获取用户对象
        User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);
        // 根据 玩家 Id 获取房间对象
        Room room = roomManager.findRoomByUserId(user.getUserId());
        // 通过room对象处理这次请求
        room.putChess(message.getPayload());
    }

7.3.11 实现putChess方法

  1. 注意这里的落子, 与前端不同, 这里的棋盘数组, 为0就是没落子, 为1就是用户1落得子, 为2就是用户2落得子
  2. 每次落子都要进行胜负判断, 使用checkWinner方法来实现
  3. 给房间中的用户返回响应
  4. 注意这里的玩家掉线的情况
  5. 如果胜负已分, 更新玩家获胜的信息, 并销毁房间
    // 这个方法是用来处理一次落子的操作
    public void putChess(String reqJson) throws IOException {
        // 1. 记录当前落子的位子
        GameRequest request = objectMapper.readValue(reqJson,GameRequest.class);
        GameResponse response = new GameResponse();
        // 1.1 判断当前落子是谁
        int chess = request.getUserId() == user1.getUserId() ? 1 : 2;
        int row = request.getRow();
        int col = request.getCol();
        if (board[row][col] != 0) {
            System.out.println("当前位置: ("+row+" ," + col+" )" +"已经有子了");
            return;
        }
        board[row][col] = chess;

        // 2. 进行胜负判定
        int winner = checkWinner(row,col,chess);
        // 3. 给房间中所有的客户端返回响应
        response.setMessage("putChess");
        response.setRow(row);
        response.setCol(col);
        response.setWinner(winner);
        response.setUserId(request.getUserId());

        WebSocketSession session1 = onlineUserManager.getRoomState(user1.getUserId());
        WebSocketSession session2 = onlineUserManager.getRoomState(user2.getUserId());
        // 这里对下线进行判断
        if (session1 == null) {
            // 玩家1下线
            response.setWinner(user2.getUserId());
            System.out.println("玩家1掉线");
        }
        if (session2 == null) {
            // 玩家2下线, 就认为玩家1获胜
            System.out.println("玩家2掉线");
        }
        String respJson = objectMapper.writeValueAsString(response);
        if (session1 != null) {
            session1.sendMessage(new TextMessage(respJson));
        }
        if (session2 != null) {
            session2.sendMessage(new TextMessage(respJson));
        }
        // 4. 如果当前获胜, 销毁房间
        if (response.getWinner() != 0) {
            System.out.println("游戏结束, 房间即将销毁");
            // 更新获胜方的信息
            int winId = response.getWinner();
            int LoseId = response.getWinner() == user1.getUserId() ? user2.getUserId() : user1.getUserId();
            userService.userLose(LoseId);
            userService.userWin(winId);
            // 销毁房间
            roomManager.remove(roomId,user1.getUserId(),user2.getUserId());
        }
    }

7.3.12 完成用户胜负判断

这里要判断四种情况

  1. 一行有五个子连珠
  2. 一列有五个子连珠
  3. 从左到右的斜着的五子连珠
  4. 从右到左的斜着的五子连珠

完成 checkWinner 方法

    // 谁获胜就返回谁的Id, 如果还没有获胜者, 就返回0
    private int checkWinner(int row, int col, int chess) {
        //  判断当前是谁获胜
        // 1. 一行五子连珠
        for (int i = col -4 ;i >= 0 && i <= col && i <= Constant.COL-5; i++) {
            if (board[row][i] == chess
            && board[row][i+1] == chess
            && board[row][i+2] == chess
            && board[row][i+3] == chess
            && board[row][i+4] == chess) {
                return chess == 1 ? user1.getUserId() : user2.getUserId();
            }
        }
        // 2. 一列五子连珠
        for (int i = row - 4; i >= 0 && i <= row && i <= Constant.ROW-5; i++) {
            if (board[i][col] == chess
            && board[i+1][col] == chess
            && board[i+2][col] == chess
            && board[i+3][col] == chess
            && board[i+4][col] == chess) {
                return chess == 1 ? user1.getUserId() : user2.getUserId();
            }
        }
        // 3. 斜着五子连珠 -> 左上到右下
        for (int i = row - 4, j = col - 4; i <= row && j <= col;j++,i++){
            try {
                if (board[i][j] == chess
                        && board[i+1][j+1] == chess
                        && board[i+2][j+2] == chess
                        && board[i+3][j+3] == chess
                        && board[i+4][j+4] == chess) {
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            }catch (ArrayIndexOutOfBoundsException e) {
                continue;
            }
        }
        // 4. 斜着五子连珠 -> 右上到左下
        for (int i = row+4,j=col-4; i>=row && j <= col; i--,j++) {
            try {
                if (board[i][j] == chess
                        && board[i-1][j+1] == chess
                        && board[i-2][j+2] == chess
                        && board[i-3][j+3] == chess
                        && board[i-4][j+4] == chess) {
                    return chess == 1 ? user1.getUserId() : user2.getUserId();
                }
            }catch (ArrayIndexOutOfBoundsException e) {
                continue;
            }
        }
        return 0;
    }

版权声明:程序员胖胖胖虎阿 发表于 2022年8月31日 上午1:16。
转载请注明:基于 SpringBoot + MyBatis 的在线五子棋对战 | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...