目录

一、项目所要实现的功能模块

1、用户模块

2、匹配模块

3、对战模块

二、使用技术

三、项目截图

1、登录页面

2、注册页面

3、游戏大厅页面

4、游戏房间页面

四、创建 SpringBoot 项目

1、在 IDEA 中创建一个 SpringBoot 项目

2、设置项目名称

3、选择项目依赖

4、选择项目存放路径,就可以创建出一个 SpringBoot 项目

五、配置文件

六、数据库设计

七、工具包

1、ResponseBodyMessage 类

2、Constant 类

3、密码加密

(1)在 pom.xml 文件中添加依赖

(2)在springboot启动类添加注解

(3)在 config 包中创建 AppConfig 类

八、配置拦截器

1、创建 config 包,在 config 包中创建 LoginInterceptor 类

2、在 AppConfig 类中添加下面的代码

九、实现登录注册模块

1、登录功能的请求和响应设计

2、注册功能的请求和响应

3、创建 User 类

4、创建对应的 Mapper 和 API

(1)创建接口 UserMapper

(2)创建 UserMapper.xml

5、在 com.example.online_gobang.api 包中创建 UerAPI 类

6、注册登录功能测试

(1)注册功能

(2)登录功能

7、前端代码

(1)注册功能

(2)登录功能

十、实现匹配模块

1、匹配功能的请求和响应设计

(1)匹配请求

(2)匹配响应 1

(3)匹配响应 2

2、获取用户信息的请求和响应设计

3、在 UserAPI 类中添加 getUserInfo 方法

4、用户的在线状态

5、创建游戏房间

6、创建房间管理器

7、创建匹配队列

8、websocket 的匹配请求

9、websocket 的匹配响应

10、处理匹配功能的 websocket 请求

11、触发 websocket 约定好的请求与响应路径

12、前端代码

十一、实现对战模块

1、对战功能的请求和响应设计

(1)建立连接请求

(2)建立连接响应

(3)玩家落子请求

(4)玩家落子响应

2、触发 websocket 约定好的请求与响应路径

3、建立连接的响应

4、websocket 的落子请求

5、websocket 的落子响应

6、玩家在游戏房间的在线状态

7、更新玩家的比赛场次、获胜场次、天梯积分

(1)在 UserMapper 接口中添加代码

(2)在 UserMapper.xml 中添加代码

8、在 Room 类中实现棋局判定胜负的逻辑

(1)修改启动类

(2)获取 RoomManager 与 OnlineUserManager

(3)在 Room 类中创建一个二维数组用来表示棋盘

(4)Room 类的完整代码

9、处理对战功能的 websocket 请求

10、前端代码

(1)使用 canvas 绘制棋盘(game_room.html)

(2)绘制棋盘的 JavaScript 文件(script.js)


一、项目所要实现的功能模块

1、用户模块

  • 用户的注册和登录。
  • 管理用户的天梯积分、比赛场数、获胜场数等信息。

2、匹配模块

  • 游戏大厅页面
  • 依据用户的天梯积分,实现匹配机制。

3、对战模块

  • 游戏房间页面
  • 把两个匹配到的玩家放到一个游戏房间中,双方进行对战比赛。

二、使用技术

  • 前端:HTML、CSS、JavaScript、jQuery、Ajax
  • 后端:Java、SpringBoot、WebSocket
  • 数据库:MySQL、MyBatis

三、项目截图

1、登录页面

2、注册页面

3、游戏大厅页面

4、游戏房间页面

四、创建 SpringBoot 项目

1、在 IDEA 中创建一个 SpringBoot 项目

2、设置项目名称

3、选择项目依赖

4、选择项目存放路径,就可以创建出一个 SpringBoot 项目

五、配置文件

  • 在 application.properties 配置如下信息
#配置数据库
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/online_gobang?characterEncoding=utf8&serverTimezone=UTC
spring.datasource.username=数据库用户名
spring.datasource.password=数据库密码
spring.datasource.driver-class-name=com.mysql.jdbc.Drivermybatis.mapper-locations=classpath:mybatis/**Mapper.xml

六、数据库设计

create database if not exists online_gobang;use online_gobang;-- 用户表
drop table if exists user;
create table user(userId int primary key auto_increment comment '用户id',username varchar(50) unique comment '用户名',password varchar(255) not null comment '用户密码',score int comment '天梯积分',totalCount int comment '比赛总场数',winCount int comment '获胜场数'
);

七、工具包

  • 在 package com.example.online_gobang 目录下创建一个 tools 包,在这个包中存放整个项目要使用的工具类。

1、ResponseBodyMessage 类

  • 设计统一的响应体工具类,因为做任何操作时都需要响应,所以封装一个通用的响应工具类,这个工具类设计成一个泛型类。
package com.example.online_gobang.tools;import lombok.Data;@Data
public class ResponseBodyMessage<T> {private int status; // 状态码private String message; // 返回的信息(出错原因等)private T data; // 返回给前端的数据(因为返回的数据类型不确定,可能是 String,boolea,int ...,因此使用泛型)public ResponseBodyMessage(int status, String message, T data) {this.status = status;this.message = message;this.data = data;}
}

2、Constant 类

  • 这个类用来存储不变的常量。 例如:设置 session 对象中的 key 值,key 是一个不变的字符串。
  • 如果在其他地方获取对应的 session 就可以通过这个类中的字符串进行获取。
package com.example.online_gobang.tools;public class Constant {public static final String USER_SESSION_KEY = "user"; // 设置 session 中的 key 值
}

3、密码加密

使用 Bcrypt 对用户密码进行加密

  • Bcrypt 是一款加密工具,可以比较方便地实现数据的加密工作。也可以简单理解为它内部自己实现了随机加盐处理 。
  • 使用MD5加密,每次加密后的密文其实都是一样的,这样就方便了MD5通过大数据的方式进行破解。
  • Bcrypt生成的密文是60位的,而MD5的是32位的,因此 Bcrypt 破解难度更大。

(1)在 pom.xml 文件中添加依赖

  • 添加到 <dependencies> </dependencies> 标签内
<!-- 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>

(2)在springboot启动类添加注解

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

(3)在 config 包中创建 AppConfig 类

package com.example.online_gobang.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;/*** Created with IntelliJ IDEA.* Description:* User: 74646* Date: 2022-11-14* Time: 16:35*/
@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer,WebMvcConfigurer {@Beanpublic BCryptPasswordEncoder getBCryptPasswordEncoder(){return  new BCryptPasswordEncoder();}
}

八、配置拦截器

  • 未登录的情况下拦截其他页面,登录成功后才可以访问其他界面

1、创建 config 包,在 config 包中创建 LoginInterceptor 类

package com.example.online_gobang.config;import com.example.online_gobang.tools.Constant;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;public class LoginInterceptor implements HandlerInterceptor {@Overridepublic 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 false;}return true;}
}

2、在 AppConfig 类中添加下面的代码

@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer,WebMvcConfigurer {/*** 添加拦截器* @param registry*/@Overridepublic void addInterceptors(InterceptorRegistry registry) {// 登录之后才可以访问其他页面LoginInterceptor loginInterceptor = new LoginInterceptor();registry.addInterceptor(loginInterceptor).// 拦截所有的addPathPatterns("/**")//排除所有的JS.excludePathPatterns("/js/**.js")//排除images下所有的元素.excludePathPatterns("/images/**").excludePathPatterns("/css/**.css").excludePathPatterns("/fronts/**").excludePathPatterns("/player/**").excludePathPatterns("/login.html").excludePathPatterns("/register.html")//排除登录和注册接口.excludePathPatterns("/login").excludePathPatterns("/register").excludePathPatterns("/logout");}
}

九、实现登录注册模块

1、登录功能的请求和响应设计

请求:{post, // 使用 post 请求/login // 请求路径data:{ username, password } // 传入的数据}响应:{"status": 200,"message": "登录成功","data": {"id": xxxxx,"username": xxxxxx,"score": 1000,"totalCount": 0,"winCount": 0}}响应设计字段解释:{状态码为 200 表示成功,-200表示失败状态描述信息,描述此次请求成功或者失败的原因返回的数据,请求成功后,服务器返回给前端的数据}

2、注册功能的请求和响应

请求:{post, // 使用 post 请求/register // 请求路径data:{ username, password } // 传入的数据}响应:{"status": 200,"message": "注册成功","data": {"id": xxxxx,"username": xxxxxx,"score": 1000,"totalCount": 0,"winCount": 0}}响应设计字段解释:{状态码为 200 表示成功,-200表示失败状态描述信息,描述此次请求成功或者失败的原因返回的数据,请求成功后,服务器返回给前端的数据}

3、创建 User 类

  • 在 com.example.online_gobang.model 包中创建 User 类

package com.example.online_gobang.model;import lombok.Data;@Data
public class User {private int userId; // 用户idprivate String username; // 用户名private String password; // 密码private int score; // 天梯积分private int totalCount; // 比赛总场数private int winCount; // 获胜场数
}

4、创建对应的 Mapper 和 API

(1)创建接口 UserMapper,实现用户信息的插入与查询

  • 在 com.example.online_gobang.mapper 包中创建 UserMapper 接口
package com.example.online_gobang.mapper;import com.example.online_gobang.model.User;
import org.apache.ibatis.annotations.Mapper;@Mapper
public interface UserMapper {/***  数据库中插入用户信息,用于注册功能* @param user*/void insert(User user);/***  根据用户名,查询用户的详细信息,用户登录功能* @param username* @return*/User selectByName(String username);
}

(2)创建 UserMapper.xml

  • 在 resource 目录下,创建 mybatis 文件夹,创建 UserMapper.xml,在 UserMapper.xml 文件中写SQL语句。
<?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.online_gobang.mapper.UserMapper"><insert id="insert">insert into user values(null, #{username}, #{password}, 1000, 0, 0);</insert><select id="selectByName" resultType="com.example.online_gobang.model.User">select *from userwhere username = #{username};</select>
</mapper>

5、在 com.example.online_gobang.api 包中创建 UerAPI 类

  • 在 UserAPI 类中实现用户注册、用户登录功能
package com.example.online_gobang.api;import com.example.online_gobang.mapper.UserMapper;
import com.example.online_gobang.model.User;
import com.example.online_gobang.tools.Constant;
import com.example.online_gobang.tools.ResponseBodyMessage;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;@RestController
public class UserAPI {@Resourceprivate UserMapper userMapper;// 使用 BCrypt 对密码进行加密@Resourceprivate BCryptPasswordEncoder bCryptPasswordEncoder;@PostMapping("/login")@ResponseBodypublic ResponseBodyMessage<User> login(@RequestParam String username,@RequestParam String password,HttpServletRequest request){/***  根据 username 到数据库中进行查询*  如果能找到匹配的用户,并且密码也一致,就认为登录成功*/User user = userMapper.selectByName(username);System.out.println("登录的用户:"+ username);if(user != null){System.out.println("登录成功");// 判断当前用户输入的密码(password) 与 数据库中查询到的密码(加密的密码,getPassword())是否匹配boolean flag = bCryptPasswordEncoder.matches(password,user.getPassword());if(!flag){// 密码不匹配,登录失败return new ResponseBodyMessage<>(-200,"用户名或密码错误",user);}// 如果登录成功就将信息写入到 session 中(在 session 中存储了一个用户信息对象,此后可以随时从 session 中将这个对象取出来进行一些操作)request.getSession().setAttribute(Constant.USER_SESSION_KEY,user);// 状态码为200,表示登录成功,并返回用户信息return   new ResponseBodyMessage<>(200,"登录成功",user);}else {// 登录失败,返回一个空的对象System.out.println("登录失败");return new ResponseBodyMessage<>(-200,"用户名或密码错误",user);}}@RequestMapping("/register")@ResponseBodypublic ResponseBodyMessage<Boolean> register(@RequestParam String username, @RequestParam String password){User user1 = userMapper.selectByName(username);if(user1 != null) {return new ResponseBodyMessage<>(-200,"当前用户已经存在",false);}else {User user = new User();String newPassword = bCryptPasswordEncoder.encode(password);user.setUsername(username);user.setPassword(newPassword);boolean flag = userMapper.insert(user);if(flag == true){return new ResponseBodyMessage<>(200,"注册成功",true);}else{return new ResponseBodyMessage<>(-200,"注册失败",false);}}}
}

6、注册登录功能测试

  • 使用 postman 进行测试。

(1)注册功能

(2)登录功能

7、前端代码

(1)注册功能

    <script>$(function(){$("#register").click(function(){var username = $("#username").val();var password = $("#password").val();$.ajax({url: "/register",type: "POST",data:{"username":username,"password":password},dataType:"json",success: function(data){console.log(data);if(data.status == 200) {// alert("注册成功");location.assign("login.html");}else{alert("注册失败");$("#username").val("");$("#password").val("");$("#repassword").val("");}}})})});let register = document.querySelector('#register');console.log(register);register.onclick = function() {let username = document.querySelector('#username');let password = document.querySelector('#password');let repassword = document.querySelector('#repassword');if(username.value.trim() == ""){alert("请输入账号!");username.focus();return;}if(password.value.trim() == ""){alert('请输入密码!');password.focus();return;}if(repassword.value.trim() == ""){alert('请再次输入密码!');repassword.focus();return;}if(username.value.trim().length > 15) {alert("账号长度不可超过15个字符,请重新输入");username.value="";username.focus();return;}if(password.value.trim() != repassword.value.trim()) {alert('两次输入的密码不同,请重试输入!');passwrod.value="";repassword.value="";return;}if(password.value.trim().length > 255) {alert("当前密码长度过长!");password.value="";repassword.value="";password.focus();return;}}</script>

(2)登录功能

    <script>$(function(){$("#submit").click(function(){// 点击登录按钮,获取用户名和密码var username = $("#username").val();var password = $("#password").val();// 判断用户名和密码是否为空(使用 trim 方法,防止输入空格)if(username.trim() == "" || password.trim() == ""){alert("账号或密码不能为空");return;}// 如果用户名和密码不为空,使用 Ajax 传入请求$.ajax({type:"POST",url:"/login",data:{"username":username,"password":password},// 服务器返回的数据类型dataType:"json",// 请求成功,服务器返回数据success:function(data){console.log(data);// 如果状态码为 200,表示登录成功if(data.status == 200){alert("登录成功");// 跳转到游戏大厅页面location.assign('/game_hall.html');}else{alert("登录失败,账号或密码错误");// 登录失败,将用户名或密码置空$("#username").val("");$("#password").val("");}}});});});$(function () {$("#register").click(function () {window.location.href="register.html";});});</script>

十、实现匹配模块

  • 让多个用户在游戏大厅进行匹配,系统将天梯积分相近的两个玩家匹配到同一个房间中进行对战。

1、匹配功能的请求和响应设计

  • 玩家发送匹配请求,这个事情是确定的(点击了匹配按钮,就会发送匹配请求) ,但是服务器什么时候告知玩家匹配结果是不确定的。
  • 匹配功能需要依赖消息推送机制(websocket),当服务器匹配成功之后就会主动告诉当前进行匹配的所有玩家“你匹配成功了”。

消息推送机制:

  • 消息推送就是服务器主动将数据发送给客户端(服务器主动发送请求),而 HTTP 协议必须是客户端主动将数据发送给服务器(客户端主动发送请求)。
  • 匹配功能需要服务器主动给客户端发送数据,因此需要使用 websocket,接下来所设计的前后端交互接口都是基于 websocket 这样的交互方式进行的。

(1)匹配请求

  • 客户端通过 websocket 给服务器发送一个 json 格式的文本数据
请求:
{ws://127.0.0.1:8081/findMatch // 请求路径data:{ message:'startMatch' // 开始匹配或 'stopMatch' // 结束匹配} // 请求内容
}

(2)匹配响应 1

  • 这个响应是客户端给服务器发送匹配请求之后,服务器立即返回的匹配响应
响应:{ws://127.0.0.1:8081/findMatch // 响应路径data:{ status: 200, // 匹配成功reason: '', // 如果匹配失败,失败原因的信息message: 'startMatch' // 开始匹配或 'stopMatch' // 结束匹配} // 响应内容}响应设计字段解释:{状态码为 200 表示成功,-200表示失败状态描述信息,描述此次请求成功或者失败的原因返回的数据,请求成功后,服务器返回给前端的数据}

(3)匹配响应 2

  • 这个响应是匹配到对手之后,服务器主动给客户端发送的响应。
响应:{ws://127.0.0.1:8081/findMatch // 响应路径data:{ status: 200, // 匹配成功reason: '', // 如果匹配失败,失败原因的信息message: 'matchSuccess'} // 响应内容}响应设计字段解释:{状态码为 200 表示成功,-200表示失败状态描述信息,描述此次请求成功或者失败的原因返回的数据,请求成功后,服务器返回给前端的数据}

2、获取用户信息的请求和响应设计

  • 用户登录成功之后,让客户端随时通过这个接口来访问服务器,获取到自身的信息。
请求:{get, // 使用 get 请求/userInfo // 请求路径}响应:{"status": 200,"message": "相关信息","data": {"id": xxxxx,"username": xxxxxx,"score": 1000,"totalCount": 0,"winCount": 0}}响应设计字段解释:{状态码为 200 表示成功,-200表示失败状态描述信息,描述此次请求成功或者失败的原因返回的数据,请求成功后,服务器返回给前端的数据}

3、在 UserAPI 类中添加 getUserInfo 方法

  • 根据当前存储的 session 对象, 查找对应的用户
    @RequestMapping("/userInfo")@ResponseBodypublic ResponseBodyMessage<User> getUserInfo(HttpServletRequest request){HttpSession session = request.getSession(false);User user = (User)session.getAttribute("user"); // 从会话中获取 User 对象User newUser = userMapper.selectByName(user.getUsername());if(newUser != null){return new ResponseBodyMessage<>(200,"获取成功",user);}else {System.out.println("没有该用户");return new ResponseBodyMessage<>(-200,"获取失败",user);}} 

4、用户的在线状态

  • 创建 game 包,在 game 包中创建 OnlineUserManager 类
  • 维护用户的在线状态,目的是为了能够方便获取到某个用户当前的 websocket 会话,从而可以通过这个会话来给这个客户端发送信息,同时也可以感知到当前玩家的在线/离线状态。

使用哈希表来保存当前用户的在线状态,当用户登录的时候, 就将用户状态添加到哈希表中。

  • 玩家的在线状态是多线程的, 很多用户访问同一个哈希表就会出现线程安全的问题, 所以这里就使用 ConcurrentHashMap, 确保了线程安全问题。
  • 这里存储的, key是用户的Id, value是对应的 websocket 会话。

提供三个方法:

  1. 玩家进入游戏大厅的时候, 将用户的状态存入哈希表中
  2. 玩家退出游戏大厅的时候, 将用户的状态从哈希表中删除
  3. 在游戏大厅获取当前用户的信息
package com.example.online_gobang.game;import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;import java.util.concurrent.ConcurrentHashMap;@Component
public class OnlineUserManager {// 这个哈希表用来表示当前用户在游戏大厅的在线状态private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();// 玩家进入游戏大厅public void enterGameHall(int userId,WebSocketSession webSocketSession){gameHall.put(userId,webSocketSession);}// 玩家退出游戏大厅public void exitGameHall(int userId){gameHall.remove(userId);}public WebSocketSession getFromGameHall(int userId){return gameHall.get(userId);}}

5、创建游戏房间

  • 在 com.example.online_gobang.game 包中创建 Room 类。
  • 将 roomId 设置成字符串类型,使用 UUID 生成唯一的 roomId。

UUID:通用唯一识别码(Universally Unique Identifier)的缩写

  • 表示“唯一的身份标识”。
  • 通过一系列算法能够生成一串字符串(一组十六进制表示的数字)。
  • 每次调用这个算法,得到的结果都是不相同的。
  • Java 中有现成的类可以直接生成 UUID。
package com.example.online_gobang.game;import com.example.online_gobang.model.User;
import lombok.Data;import java.util.UUID;// 这个类表示一个游戏房间
@Data
public class Room {private String roomId;private User user1;private User user2;public Room() {// 构造 Room 的时候生成一个唯一的字符串表示房间 id.// 使用 UUID 来作为房间 idroomId = UUID.randomUUID().toString();}
}

6、创建房间管理器

  • 在 com.example.online_gobang.game 包中创建 RoomManager 类。
  • 一个游戏服务器上会同时存在多个游戏房间,因此需要一个“游戏房间管理器”来管理多个游戏房间。

使用哈希表存储每个游戏房间并对其进行管理。

private ConcurrentHashMap<String,Room> rooms = new ConcurrentHashMap<>();
  • key:roomId(每个游戏房间的id都是唯一的),value:Room。

使用哈希表维护玩家和游戏房间之间的关系。

private ConcurrentHashMap<Integer,String>  usrIdToRoomId = new ConcurrentHashMap<>();
  • 考虑到线程安全问题,因此使用 ConcurrentHashMap。
  • key:userId,value:roomId。

提供4个方法:

  1. 添加玩家进入到游戏房间中。
  2. 删除游戏房间中的玩家。
  3. 根据游戏房间的Id,获取对应的游戏房间。
  4. 通过用户Id,查找该玩家所在的游戏房间。
package com.example.online_gobang.game;import org.springframework.stereotype.Component;import java.util.concurrent.ConcurrentHashMap;// 房间管理器
@Component
public class RoomManager {private ConcurrentHashMap<String,Room> rooms = new ConcurrentHashMap<>();// 通过用户id 与房间id 维护玩家和房间之间的关系private ConcurrentHashMap<Integer,String>  usrIdToRoomId = new ConcurrentHashMap<>();public void add(Room room, int userId1,int userId2){// 添加一个房间到房间管理器的同时,也将两个玩家的 userId 添加到 usrIdToRoomId 中,便于维护玩家和房间之间的关系rooms.put(room.getRoomId(),room);usrIdToRoomId.put(userId1,room.getRoomId());usrIdToRoomId.put(userId2,room.getRoomId());}public void remove(String roomId, int userId1,int userId2){// 移除一个房间的同时也要同时移除两个玩家的信息rooms.remove(roomId);usrIdToRoomId.remove(userId1);usrIdToRoomId.remove(userId2);}public Room getByRoomId(String roomId){return rooms.get(roomId);}// 通过用户id 查找对应的房间public Room getRoomByUserId(int userId){String rooId = usrIdToRoomId.get(userId);if(rooId == null){// rooId == null 表示游戏房间不存在// userId -> roomId 映射关系不存在,直接返回 nullreturn null;}return rooms.get(rooId);}
}

7、创建匹配队列

  • 在 com.example.online_gobang.game 包中创建 Matcher 类。
  • 从待匹配的玩家中选中分数相近的玩家进行匹配。

将所有玩家按照天梯积分划分成三类(基础分:1000):

  • 业余水平:score < 2000
  • 普通水平:score >= 2000 && score < 3000
  • 大师水平:score >= 3000
    // 1. 业余水平:score < 2000private Queue<User> amateurQueue = new LinkedList<>();// 2. 普通水平:score >= 2000 && score < 3000private Queue<User> normalQueue = new LinkedList<>();// 3. 大师水平:score >= 3000private Queue<User> masterQueue = new LinkedList<>();
  • 给这三个等级分配三个不同的队列,根据当前玩家的天梯积分,将玩家的用户信息放到对应的队列中。
  • 设置一个线程去不停的扫描匹配队列,只要队列中的元素(匹配中的玩家)积分相近,就将这一对玩家放到一个游戏房间中。

1、线程安全问题:

  • 如果多个线程针对同一个队列进行并发修改操作(入队、出队)就会产生线程安装问题。如果是针对多个不同队列进行操作就不会产生线程安全问题。

解决办法:对创建的三个队列对象在进行队列操作时分别进行加锁(synchronized)。

2、忙等问题:

  • 如果当前匹配队列中没有玩家或只有一个玩家在进行匹配,线程就会调用 handlerMatch 方法并且直接返回,然后再次调用 handlerMatch 方法 ...... 一直循环。这个过程中 CPU 占用率会非常高。

解决方法:

  • 在判断当前队列中的元素(正在匹配的玩家)是否有2个以上的时候,如果当前队列中的玩家数 < 2,就调用 wait() 进行匹配等待,如果没有玩家进入匹配队列就会一直等。
  • 直到有玩家进入匹配队列,就调用 notify() 唤醒线程,再次判断当前队列中是否有2个以上的玩家。
package com.example.online_gobang.game;import com.example.online_gobang.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;import java.io.IOException;
import java.util.LinkedList;
import java.util.Queue;// 匹配器, 这个类是用来完成匹配功能的
@Component
public class Matcher {// 创建匹配队列 按等级划分// 1. 业余水平:score < 2000private Queue<User> amateurQueue = new LinkedList<>();// 2. 普通水平:score >= 2000 && score < 3000private Queue<User> normalQueue = new LinkedList<>();// 3. 大师水平:score >= 3000private Queue<User> masterQueue = new LinkedList<>();@Autowiredprivate OnlineUserManager onlineUserManager;@Autowiredprivate ObjectMapper objectMapper;@Autowiredprivate RoomManager roomManager;/*** 将当前玩家添加到匹配队列中* @param user*/public void add(User user) {// 按等级加入队列中if (user.getScore() < 2000) {synchronized (amateurQueue) {amateurQueue.offer(user);// 只要有用户进入了, 就进行唤醒amateurQueue.notify();}// 打印日志System.out.println("把玩家"+user.getUsername()+"加入到了amateurQueue 中");}else if (user.getScore() >= 2000 && user.getScore() < 3000) {synchronized (normalQueue) {normalQueue.offer(user);normalQueue.notify();}System.out.println("把玩家"+user.getUsername()+"加入到了normalQueue 中");}else {synchronized (masterQueue) {masterQueue.offer(user);masterQueue.notify();}System.out.println("把玩家"+user.getUsername()+"加入到了masterQueue 中");}}/***  当玩家点击停止匹配* 就把当前玩家匹配队列中删除* @param user*/public void remove(User user) {// 按照当前等级去对应匹配队列中删除if (user.getScore() < 2000) {synchronized (amateurQueue){amateurQueue.remove(user);}System.out.println("把玩家"+user.getUsername()+"移除了masterQueue");}else if (user.getScore() >= 2000 && user.getScore() < 3000) {synchronized (normalQueue) {normalQueue.remove(user);}System.out.println("把玩家"+user.getUsername()+"移除了normalQueue");}else {synchronized (masterQueue) {masterQueue.remove(user);}System.out.println("把玩家"+user.getUsername()+"移除了masterQueue");}}// 使用3个线程去一直的进行查看是否有2个以上的人, 如果有进行匹配public Matcher() {// 创建三个线程, 操作三个匹配队列Thread t1 = new Thread() {@Overridepublic void run() {// 扫描 amateurQueuewhile (true) {handlerMatch(amateurQueue);}}};t1.start();Thread t2 = new Thread() {@Overridepublic void run() {// 扫描 normalQueuewhile (true) {handlerMatch(normalQueue);}}};t2.start();Thread t3 = new Thread() {@Overridepublic void run() {// 扫描 masterQueuewhile (true) {handlerMatch(masterQueue);}}};t3.start();}private void handlerMatch(Queue<User> matchQueue) {// 因为三个队列都调用了 handlerMatch 方法,因此对这个方法里面的操作进行加锁即可。// 针对形参进行加锁(传入不同的实参就可以对不同的队列对象进行加锁)synchronized (matchQueue) {try{// 1. 先查看当前队列中的元素个数, 是否满足两个//    在往队列里添加一个元素后仍然不能进行后续匹配操作,//    因此使用 while 循环检测是否有两个元素添加到队列中更合理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.getFromGameHall(player1.getUserId());WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());// 再次判断是否为空if (session1 == null && session2 != null) {// 如果玩家1 掉线了,就把玩家2 重新放到匹配队列中matchQueue.offer(player2);return;}if (session1 != null && session2 == null) {// 如果玩家2 掉线了,就把玩家1 重新放到匹配队列中matchQueue.offer(player1);return;}if (session1 == null && session2 == null) {return;}if (session1 == session2) {// 如果两个玩家是同一个用户(一个玩家入队了两次,理论上不存在,但还是需要再判定一次)// 就把其中的一个玩家放回到匹配队列matchQueue.offer(player1);return;}// 4. 把两个玩家放入一个游戏房间中Room room = new Room();roomManager.add(room, player1.getUserId(), player2.getUserId());// 5. 给玩家反馈信息, 通知匹配到了对手// 给玩家1返回的响应MatchResponse response1 = new MatchResponse();response1.setStatus(200);response1.setMessage("matchSuccess");String json1 = objectMapper.writeValueAsString(response1);session1.sendMessage(new TextMessage(json1));// 给玩家2返回的响应MatchResponse response2 = new MatchResponse();response2.setMessage("matchSuccess");response2.setStatus(200);String json2 = objectMapper.writeValueAsString(response2);session2.sendMessage(new TextMessage(json2));} catch (IOException | InterruptedException e) {e.printStackTrace();}}}
}

8、websocket 的匹配请求

  • 在 com.example.online_gobang.game 包下创建 MatchRequest 类。
package com.example.online_gobang.game;import lombok.Data;// 表示 websocket 的一个匹配请求
@Data
public class MatchRequest {private String message;
}

9、websocket 的匹配响应

  • 在 com.example.online_gobang.game 包下创建 MatchResponse 类。
package com.example.online_gobang.game;import lombok.Data;// 表示 websocket 的一个匹配响应
@Data
public class MatchResponse {private int status; // 状态码private String reason; // 响应内容(失败的原因)private String message; // 匹配信息
}

10、处理匹配功能的 websocket 请求

  • 在 com.example.online_gobang.api 包中创建 MatchAPI 类
  • afterConnectionEstablished 方法:在游戏大厅建立连接
  • handleTextMessage 方法:在游戏大厅中接收发送的响应
  • handleTransportError 方法:处理玩家异常下线
  • afterConnectionClosed 方法:处理玩家正常下线
package com.example.online_gobang.api;import com.example.online_gobang.game.MatchRequest;
import com.example.online_gobang.game.MatchResponse;
import com.example.online_gobang.game.Matcher;
import com.example.online_gobang.game.OnlineUserManager;
import com.example.online_gobang.model.User;
import com.example.online_gobang.tools.Constant;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;// 通过这个类来处理匹配功能中的 websocket 请求
@Component
public class MatchAPI extends TextWebSocketHandler {private ObjectMapper objectMapper = new ObjectMapper();@Autowiredprivate OnlineUserManager onlineUserManager;@Autowiredprivate Matcher matcher;@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {// 玩家上线,加入到 OnlineUserManager 中// 1. 获取当前的用户信息(谁在游戏大厅创建连接)User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);// 2. 判断当前用户是否已经登录MatchResponse response = new MatchResponse();if (onlineUserManager.getFromGameHall(user.getUserId()) != null ) {// 当前用户已经登录response.setMessage("当前用户已经登录!");response.setStatus(-200);response.setReason("禁止游戏多开");/***  先通过 ObjectMapper 把 MathResponse 对象转成 JSON 字符串*  然后再包装上一层 TextMessage,再进行传输*  TextMessage 就表示一个文本格式的 websocket 数据包*/session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(response)));session.close();return;}// 3. 设置在线状态onlineUserManager.enterGameHall(user.getUserId(),session);System.out.println("玩家"+user.getUsername()+"进入游戏大厅");}@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {// 处理开始匹配 和 停止匹配User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);// 获取到客户端给服务器发送的数据String payload = message.getPayload();// 当前这个数据载荷是一个 JSON 格式的字符串,需要将它转换成 Java 对象( MatchRequest )MatchRequest matchRequest = objectMapper.readValue(payload, MatchRequest.class); // 从客户端获取的数据MatchResponse matchResponse = new MatchResponse(); // 给客户端返回的数据if (matchRequest.getMessage().equals("startMatch")) {// 进入匹配队列, 加入用户matcher.add(user);// 返回响应给前端matchResponse.setStatus(200);matchResponse.setMessage("startMatch");}else if(matchRequest.getMessage().equals("stopMatch")) {// 退出匹配队列, 将用户移除matcher.remove(user);matchResponse.setMessage("stopMatch");matchResponse.setStatus(200);}else{// 非法情况matchResponse.setStatus(-200);matchRequest.setMessage("非法匹配");}session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(matchResponse)));}@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {// 玩家下线,从 OnlineUserManager 中删除// 1. 获取用户信息User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);WebSocketSession webSocketSession = onlineUserManager.getFromGameHall(user.getUserId());if(webSocketSession == session) {// 2. 设置在线状态onlineUserManager.exitGameHall(user.getUserId());}// 如果玩家正在匹配中,而 websocket 连接断开了就应该移除匹配队列matcher.remove(user);}@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {// 玩家下线,从 OnlineUserManager 中删除// 1. 获取用户信息User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);WebSocketSession webSocketSession = onlineUserManager.getFromGameHall(user.getUserId());if(webSocketSession == session) {// 2. 设置在线状态onlineUserManager.exitGameHall(user.getUserId());}// 如果玩家正在匹配中,而 websocket 连接断开了就应该移除匹配队列matcher.remove(user);}
}

11、触发 websocket 约定好的请求与响应路径

  • ws://127.0.0.1:8081/findMatch(触发这个路径与服务器建立连接)
  • 在 AppConfig 类中添加代码 websocket 请求路径的代码
@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer,WebMvcConfigurer {@Autowiredprivate MatchAPI matchAPI;@Overridepublic void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {registry.addHandler(matchAPI,"/findMatch").addInterceptors(new HttpSessionHandshakeInterceptor());}
}

12、前端代码

<script>$.ajax({type:'get',url:'/userInfo',success: function(data) {if(data.status == 200){let screenDiv = document.querySelector('#screen');screenDiv.innerHTML = "玩家:&nbsp" + data.data.username + "<br>分数:&nbsp" + data.data.score+ "<br>比赛场次:&nbsp" + data.data.totalCount + "<br>获胜场数:&nbsp" + data.data.winCount}else{alert(data.message);location.assign("login.html")}}});// 进行初始化 websocket, 并且实现前端的匹配逻辑.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("onerror");}// 监听页面关闭事件. 在页面关闭之前, 手动调用这里的 websocket 的 close 方法.window.onbeforeunload = function() {websocket.close();}// 匹配成功后收到的响应websocket.onmessage = function(e) {// 处理服务器返回的响应数据. let resp = JSON.parse(e.data);// 获取到开始匹配按钮let matchButton = document.querySelector('#match-button');if(resp.status == -200) {console.log("游戏大厅中接收到了非法响应! " + resp.reason);alert(resp.message);location.assign("login.html");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 {alert(resp.message);console.log("收到了非法的响应! message=" + resp.message);} }// 给匹配按钮添加一个点击事件let matchButton = document.querySelector('#match-button');matchButton.onclick = function() {// 在触发 websocket 请求之前, 先确认下 websocket 连接是否正常if (websocket.readyState == websocket.OPEN) {// 如果当前 readyState 处在 OPEN 状态, 说明连接正常// 开始匹配/停止匹配if (matchButton.innerHTML == '开始匹配') {console.log("开始匹配");websocket.send(JSON.stringify({message: 'startMatch',}));} else if (matchButton.innerHTML == '匹配中...(点击停止)') {console.log("停止匹配");websocket.send(JSON.stringify({message: 'stopMatch',}));}} else {// 连接异常alert("当前您的连接已经断开! 请重新登录!");location.assign('/login.html');}}</script>

十一、实现对战模块

1、对战功能的请求和响应设计

(1)建立连接请求

  • 客户端通过 websocket 给服务器发送一个 json 格式的文本数据
请求:{ws://127.0.0.1:8081/game // 请求路径}

(2)建立连接响应

  • 玩家匹配成功后服务器生成一些游戏的初始信息返回给客户端。
响应:{message: 'gameReady' // 消息的类别(游戏准备就绪) status: 200  // 200 是正常响应, -200 是异常响应 reason: ''  // 报错原因roomId: ''  // 玩家所处的房间 IdthisUserId: 1 // 自己的用户IdthatUserId: 2 // 对手的用户IdwhiteUser: 1  // 执白子的玩家先手 (1:先手; 2:后手)}

(3)玩家落子请求

请求:{message: 'putChess'userId: 1 // 落子的用户idrow: 0 // 落子的行col: 0 // 落子的列}

(4)玩家落子响应

响应:{message: 'putChess'userId: 1 // 落子的用户idrow: 0 // 落子的行col: 0 // 落子的列winner:0 // 为0时: 胜负未分; 非0时(获胜者的用户id): 胜负已分}

2、触发 websocket 约定好的请求与响应路径

  • ws://127.0.0.1:8081/game(触发这个路径与服务器建立连接)
  • 在 AppConfig 类中添加触发 websocket 请求路径的代码
@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer,WebMvcConfigurer {@Autowiredprivate GameAPI gameAPI;@Overridepublic void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {registry.addHandler(gameAPI,"/game").addInterceptors(new HttpSessionHandshakeInterceptor());}
}

3、建立连接的响应

  • 在 com.example.online_gobang.game 包下创建 GameReadyResponse 类。
package com.example.online_gobang.game;import lombok.Data;// 客户端连接游戏房间后,服务器返回的响应
@Data
public class GameReadyResponse {private String message; // 消息的类别private int status; // 状态码private String roomId; // 房间idprivate int thisUserId; // 自己的用户idprivate int thatUserId; // 对手的用户idprivate int whiteUser; // 执白子的玩家先手
}

4、websocket 的落子请求

  • 在 com.example.online_gobang.game 包下创建 GameRequest 类。
package com.example.online_gobang.game;import lombok.Data;// 落子请求
@Data
public class GameRequest {private String message;private int userId; // 玩家idprivate int row; // 落子的行private int col; // 落子的列
}

5、websocket 的落子响应

  • 在 com.example.online_gobang.game 包下创建 GameRequest 类。
package com.example.online_gobang.game;import lombok.Data;// 落子响应
@Data
public class GameResponse {private String message;private int userId;private int row;private int col;private int winner; // 判断胜负(未分出胜负为0,分出胜负则为获胜者的id)
}

6、玩家在游戏房间的在线状态

使用哈希表来保存当前玩家在游戏房间的在线状态,当玩家进入游戏房间的时候, 就将玩家的状态添加到哈希表中。

  • key是用户的Id, value是对应的 websocket 会话。
// 这个哈希表用来表示当前用户在游戏房间的在线状态
private ConcurrentHashMap<Integer, WebSocketSession> gameRoom = new ConcurrentHashMap<>();

提供三个方法

  1. 玩家进入游戏房间的时候, 将用户的状态存入哈希表中
  2. 玩家退出游戏房间的时候, 将用户的状态从哈希表中删除
  3. 在游戏房间获取当前用户的信息
  • 在 com.example.online_gobang.game 包中的 OnlineUserManager 类中添加下面的代码
@Component
public class OnlineUserManager {// 这个哈希表用来表示当前用户在游戏房间的在线状态private ConcurrentHashMap<Integer, WebSocketSession> gameRoom = new ConcurrentHashMap<>();// 玩家进入游戏房间public void enterGameRoom(int userId,WebSocketSession webSocketSession){gameRoom.put(userId,webSocketSession);}// 玩家退出游戏房间public void exitGameRoom(int userId){gameRoom.remove(userId);}public WebSocketSession getFromGameRoom(int userId){return gameRoom.get(userId);}
}

7、更新玩家的比赛场次、获胜场次、天梯积分

(1)在 UserMapper 接口中添加代码

@Mapper
public interface UserMapper {/*** 更新的数据:总场数 +1,获胜场数 +1,天梯积分 + 50* @param userId*/void userWin(int userId);/*** 更新的数据:总场数 +1,获胜场数不变,天梯积分 - 50* @param userId*/void userLose(int userId);
}

(2)在 UserMapper.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.online_gobang.mapper.UserMapper"><update id="userWin">update user set totalCount = totalCount + 1,winCount = winCount + 1,score = score + 50where userId = #{userId};</update><update id="userLose">update user set totalCount = totalCount + 1,score = score - 50where userId = #{userId};</update></mapper>

8、在 Room 类中实现棋局判定胜负的逻辑

(1)修改启动类

Room 要注入Spring对象, 不能使用@Autowired @Resource注解. 需要使用context

package com.example.online_gobang;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;public class OnlineGobangApplication {public static ConfigurableApplicationContext context;public static void main(String[] args) {context = SpringApplication.run(OnlineGobangApplication.class, args);}}

(2)获取 RoomManager 与 OnlineUserManager

  • 在 Room 类中通过 Room 类的构造方法获取 RoomManager 与 OnlineUserManager
// 这个类表示一个游戏房间
@Data
public class Room {private static final int ROW = 15;private static final int COL = 15;private OnlineUserManager onlineUserManager;// 引入 roomManager 用于房间销毁private RoomManager roomManager;public Room() {// 构造 Room 的时候生成一个唯一的字符串表示房间 id.// 使用 UUID 来作为房间 idroomId = UUID.randomUUID().toString();// 通过入口类中记录的 context 来手动获取到前面的 RoomManager 和 OnlineUserManageronlineUserManager = OnlineGobangApplication.context.getBean(OnlineUserManager.class);roomManager = OnlineGobangApplication.context.getBean(RoomManager.class);userService = JavaGobangApplication.context.getBean(UserService.class);}
}

(3)在 Room 类中创建一个二维数组用来表示棋盘

  • 用 0 表示没有落棋子,用 1 表示玩家1落的棋子,用 2 表示玩家2落的棋子。
// 这个类表示一个游戏房间
@Data
public class Room {private static final int ROW = 15;private static final int COL = 15;private OnlineUserManager onlineUserManager;// 引入 roomManager 用于房间销毁private RoomManager roomManager;// 用于将 JSON 格式的字符串转换 Java 对象private ObjectMapper objectMapper = new ObjectMapper();// 这个二维数组表示棋盘// 1) 使用 0 表示当前位置未落子. 初始化好的 int 二维数组, 就相当于是 全 0// 2) 使用 1 表示 user1 的落子位置// 3) 使用 2 表示 user2 的落子位置private int[][] board = new int[ROW][COL];// 处理一次落子的操作public void putChess(String payload) throws IOException {// 1. 记录当前落子的位置// 2. 打印出当前的棋盘信息, 方便来观察局势. 也方便后面验证胜负关系的判定.printBoard();// 3. 进行胜负判定int winner = checkWinner(row, col, chess);// 4. 给房间里的所有客户端返回响应response.setMessage("putChess");response.setUserId(request.getUserId());response.setRow(row);response.setCol(col);response.setWinner(winner);// 5. 如果当前胜负已分, 此时这个房间就失去存在的意义了. 就可以直接销毁房间. (把房间从房间管理器中给移除)// 打印棋盘内容private void printBoard() {}// 使用这个方法来判定当前落子是否分出胜负.// 约定如果玩家1 获胜, 就返回玩家1 的 userId// 如果玩家2 获胜, 就返回玩家2 的 userId// 如果胜负未分, 就返回 0private int checkWinner(int row, int col, int chess) {return 0;}
}

(4)Room 类的完整代码

package com.example.online_gobang.game;import com.example.online_gobang.OnlineGobangApplication;
import com.example.online_gobang.mapper.UserMapper;
import com.example.online_gobang.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.Data;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;import java.io.IOException;
import java.util.UUID;// 这个类表示一个游戏房间
@Data
public class Room {private String roomId;private User user1;private User user2;private int whiteUser; // 先手方的玩家idprivate static final int ROW = 15;private static final int COL = 15;private OnlineUserManager onlineUserManager;// 引入 roomManager 用于房间销毁private RoomManager roomManager;// 用于将 JSON 格式的字符串转换 Java 对象private ObjectMapper objectMapper = new ObjectMapper();// 引入 UserMapper,用于更新比赛数据private UserMapper userMapper;// 这个二维数组表示棋盘// 1) 使用 0 表示当前位置未落子. 初始化好的 int 二维数组, 就相当于是 全 0// 2) 使用 1 表示 user1 的落子位置// 3) 使用 2 表示 user2 的落子位置private int[][] board = new int[ROW][COL];public Room() {// 构造 Room 的时候生成一个唯一的字符串表示房间 id.// 使用 UUID 来作为房间 idroomId = UUID.randomUUID().toString();// 通过入口类中记录的 context 来手动获取到前面的 RoomManager 和 OnlineUserManageronlineUserManager = OnlineGobangApplication.context.getBean(OnlineUserManager.class);roomManager = OnlineGobangApplication.context.getBean(RoomManager.class);userMapper = OnlineGobangApplication.context.getBean(UserMapper.class);}// 处理一次落子的操作public void putChess(String payload) throws IOException {// 1. 记录当前落子的位置// 将 json 格式的字符串转换成 Java 对象GameRequest request = objectMapper.readValue(payload,GameRequest.class);GameResponse response = new GameResponse();// 当前这个子是玩家1 落的还是玩家2 落的. 根据这个玩家1 和 玩家2 来决定往数组中是写 1 还是 2int 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. 打印出当前的棋盘信息, 方便来观察局势. 也方便后面验证胜负关系的判定.printBoard();// 3. 进行胜负判定int winner = checkWinner(row, col, chess);// 4. 给房间里的所有客户端返回响应response.setMessage("putChess");response.setUserId(request.getUserId());response.setRow(row);response.setCol(col);response.setWinner(winner);// 要想给用户发送 websocket 数据, 就需要获取到这个用户的 WebSocketSessionWebSocketSession 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 进行传输.if (session1 != null) {session1.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}if (session2 != null) {session2.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));}// 5. 如果当前胜负已分, 此时这个房间就失去存在的意义了. 就可以直接销毁房间. (把房间从房间管理器中给移除)if (response.getWinner() != 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());}}// 打印棋盘内容private void printBoard() {System.out.println("打印棋盘信息, 当前房间: " + roomId);System.out.println("====================================================");for (int i = 0; i < ROW; i++) {for (int j = 0; j < COL; j++) {System.out.print(board[i][j] + " ");}System.out.println();}System.out.println("====================================================");}// 使用这个方法来判定当前落子是否分出胜负.// 如果玩家1 获胜, 就返回玩家1的userId// 如果玩家2 获胜, 就返回玩家2的userId// 如果胜负未分, 就返回 0private int checkWinner(int row, int col, int chess) {//  判断当前是谁获胜// 1. 一行五子连珠for (int c = col -4; c <= col && c <= COL-5; 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) {// 构成五子连珠,chess == 1 获胜者是玩家1;chess == 2 获胜者是玩家2return chess == 1 ? user1.getUserId() : user2.getUserId();}}catch (ArrayIndexOutOfBoundsException e){// 如果出现数组下标越界的情况,就忽略这个异常continue;}}// 2. 一列五子连珠for (int r = row - 4; r <= row && r <= ROW-5; 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; c++, r++){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;}}// 胜负未分返回0return 0;}
}

9、处理对战功能的 websocket 请求

  • 在 com.example.online_gobang.api 包中创建 GameAPI 类。
  • afterConnectionEstablished 方法:游戏房间建立连接.
  • handleTextMessage 方法:在游戏房间中接收发送的响应
  • handleTransportError 方法:玩家异常下线
  • afterConnectionClosed 方法:玩家正常下线
package com.example.online_gobang.api;import com.example.online_gobang.game.*;
import com.example.online_gobang.mapper.UserMapper;
import com.example.online_gobang.model.User;
import com.example.online_gobang.tools.Constant;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;import javax.annotation.Resource;
import java.io.IOException;
import java.util.Random;@Component
public class GameAPI extends TextWebSocketHandler {@Autowiredprivate OnlineUserManager onlineUserManager;@Autowiredprivate RoomManager roomManager;@Autowiredprivate ObjectMapper objectMapper;@Resourceprivate UserMapper userMapper;@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {GameReadyResponse gameReadyResponse = new GameReadyResponse();// 1. 获取玩家信息User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);if(user == null){gameReadyResponse.setStatus(-200);gameReadyResponse.setReason("用户未登录");session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(gameReadyResponse)));return;}// 2. 判断当前玩家是否已经进入房间Room room = roomManager.getRoomByUserId(user.getUserId());if (room == null) {gameReadyResponse.setStatus(-200);gameReadyResponse.setMessage("玩家尚未匹配到!");session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(gameReadyResponse)));return;}// 3. 判断当前玩家是否多开(如果一个账号一边在游戏大厅一边在游戏房间,这种也视为多开)if (onlineUserManager.getFromGameHall(user.getUserId()) != null || onlineUserManager.getFromGameRoom(user.getUserId()) != null) {gameReadyResponse.setReason("禁止玩家多开游戏页面!");gameReadyResponse.setStatus(-200);gameReadyResponse.setMessage("repeatConnection");session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(gameReadyResponse)));return;}// 4. 设置当前玩家上线(进入游戏房间)onlineUserManager.enterGameRoom(user.getUserId(), session);// 5. 把两个玩家加入到游戏房间中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());}// 当两个玩家都加入成功后, 让服务器给这两个玩家都返回 websocket 的响应数据.// 通知玩家1noticeGameReady(room,room.getUser1(),room.getUser2());// 通知玩家2noticeGameReady(room,room.getUser2(),room.getUser1());return;}}// 6. 如果又有其他玩家连接到已经满了的房间,给出一个提示。(这种情况理论上不存在)gameReadyResponse.setStatus(-200);gameReadyResponse.setMessage("当前房间已满");session.sendMessage(new TextMessage(objectMapper.writeValueAsBytes(gameReadyResponse)));}private void noticeGameReady(Room room, User thisUser, User thatUser) throws IOException {GameReadyResponse resp = new GameReadyResponse();resp.setStatus(200);resp.setReason("");resp.setMessage("gameReady");resp.setRoomId(room.getRoomId());resp.setThisUserId(thisUser.getUserId());resp.setThatUserId(thatUser.getUserId());resp.setWhiteUser(room.getWhiteUser());// 把当前的响应数据传回给玩家.WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(thisUser.getUserId());webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));}@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {// 1. 先从 session 中获取当前用户的身份信息User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);if (user == null){System.out.println("[handleTextMessage]当前玩家"+ user.getUsername()+"未登录");return;}// 2. 根据玩家id 获取房间对象Room room =  roomManager.getRoomByUserId(user.getUserId());// 通过room对象处理这次请求room.putChess(message.getPayload());}@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {// 玩家异常下线// 1. 获取玩家信息User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(user.getUserId());if(webSocketSession == session){// 2. 退出游戏房间onlineUserManager.exitGameRoom(user.getUserId());}System.out.println("当前用户: " + user.getUsername()+" 游戏房间连接异常!");// 通知对手获胜了noticeThatUserWin(user);}@Overridepublic void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {// 玩家正常下线// 1. 获取玩家信息User user = (User) session.getAttributes().get(Constant.USER_SESSION_KEY);WebSocketSession webSocketSession = onlineUserManager.getFromGameRoom(user.getUserId());if(webSocketSession == session){// 2. 退出游戏房间onlineUserManager.exitGameRoom(user.getUserId());}System.out.println("当前用户: " + user.getUsername()+" 离开房间");// 通知对手获胜了noticeThatUserWin(user);}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());}
}

10、前端代码

(1)使用 canvas 绘制棋盘(game_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 rel="stylesheet" href="css/common.css"><link rel="stylesheet" href="css/game_room.css">
</head>
<body><div class="container"><div ><!-- 棋盘区域,需要基于 canvas 进行实现  --><canvas id="chess" width="450px" height="450px"></canvas><!-- 提示区 --><div id="screen">等待玩家连接中...</div><div class="buttons"></div></div></div><script src="js/script.js"></script>
</body>
</html>

(2)绘制棋盘的 JavaScript 文件(script.js)

  • setScreenText 方法:用来将显示框中的内容, 根据当前是哪个玩家下的棋来修改内容。
  • initGame 方法:用来初始画棋盘的, 棋盘大小为 15 * 15。
  • oneStep 方法:当点击下子之后, 会绘制对应颜色的棋子。
  • onmessage 方法:用来处理后端传输的响应(使用 isWhite 判断是否是先手方)。
  • send方法: 通过 websocket 发送落子请求。
  • 棋盘数组:0 表示该位置没有落子, 1表示该位置已经落子,避免一个位置重复落子。
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
let websocketUrl = "ws://" + location.host + "/game";
let websocket = new WebSocket(websocketUrl);websocket.onopen = function() {console.log("连接游戏房间成功!");
}websocket.close = function() {console.log("和游戏服务器断开连接!");
}websocket.onerror = function() {console.log("和服务器的连接出现异常!");
}window.onbeforeunload = function() {websocket.close();
}// 处理服务器返回的响应数据
websocket.onmessage = function(event) {console.log("[handlerGameReady] " + event.data);let resp = JSON.parse(event.data);if(resp.message != 'gameReady') {console.log("响应类型错误");location.assign("game_hall.html");return;}if (resp.status == -200) {alert("连接游戏失败! reason: " + resp.reason);// 如果出现连接失败的情况, 回到游戏大厅location.assign("/game_hall.html");return;}// 游戏就绪if (resp.message == 'gameReady') {gameInfo.roomId = resp.roomId;gameInfo.thisUserId = resp.thisUserId;gameInfo.thatUserId = resp.thatUserId;// 判断先手方,如果执白子的Userid是自己,那么就是自己先手gameInfo.isWhite = (resp.whiteUser == resp.thisUserId);// 初始化棋盘initGame();// 设置显示区域的内容setScreenText(gameInfo.isWhite);} else if (resp.message == 'repeatConnection') { // 重复连接alert("检测到游戏多开(游戏大厅和游戏房间多开)! 请使用其他账号登录!");location.assign("/login.html");}
}// 初始化一局游戏
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 = "#000000";// 背景图片let logo = new Image();logo.src = "images/game_room2.png";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, 435);context.stroke();context.moveTo(15, 15 + i * 30);context.lineTo(435, 15 + i * 30);context.stroke();}}// 绘制一个棋子, me 为 truefunction 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) {// 发送坐标给服务器, 服务器要返回结果send(row, col);}}function send(row, col) {let req = {message: 'putChess',userId: gameInfo.thisUserId,row: row,col: col};websocket.send(JSON.stringify(req));}// 之前 websocket.onmessage 主要是用来处理了游戏就绪响应. 在游戏就绪之后, 初始化完毕之后, 也就不再有这个游戏就绪响应了.// 就在这个 initGame 内部, 修改 websocket.onmessage 方法, 让这个方法里面针对落子响应进行处理!websocket.onmessage = function(event) {console.log("[handlerPutChess] " + event.data);let resp = JSON.parse(event.data);if (resp.message != 'putChess') {console.log("响应类型错误!");location.assign("game_hall.html")return;}// 先判定当前这个响应是自己落的子, 还是对方落的子.if (resp.userId == gameInfo.thisUserId) {// 自己落的子oneStep(resp.col, resp.row, gameInfo.isWhite);} else if (resp.userId == gameInfo.thatUserId) {// 对手落的子oneStep(resp.col, resp.row, !gameInfo.isWhite);} else {// 响应错误! userId 是有问题的!console.log('[handlerPutChess] resp userId 错误!');return;}// 给对应的位置设为 1, 方便后续逻辑判定当前位置是否已经有子了.chessBoard[resp.row][resp.col] = 1;// 交换双方的落子轮次me = !me;setScreenText(me);// 判定游戏是否结束let screenDiv = document.querySelector('#screen');if (resp.winner != 0) {if (resp.winner == gameInfo.thisUserId) {// alert('你赢了!');screenDiv.innerHTML = '恭喜你,获胜了!';} else if (resp.winner = gameInfo.thatUserId) {// alert('你输了!');screenDiv.innerHTML = '很遗憾,你输了!';} else {alert("winner 字段错误! " + resp.winner);}// 回到游戏大厅let room = document.querySelector(".buttons");console.log(room)let backButton = document.createElement("button");backButton.innerHTML = '返回游戏大厅';backButton.className = "backButton";backButton.onclick = function() {location.assign('/game_hall.html');}room.appendChild(backButton);}}
}

基于 SpringBoot + MyBatis 的网页版五子棋对战相关推荐

  1. 基于springboot+mybatis+mysql+html实现宠物医院管理系统(包含实训报告)

    基于springboot+mybatis+mysql+html实现宠物医院管理系统(包含实训报告) 一.系统简介 二.系统主要功能界面 2.1登陆 2.2系统设置 2.3宠物管理 2.4预约管理 2. ...

  2. 基于Springboot+mybatis+mysql+html图书管理系统2

    基于Springboot+mybatis+mysql+html图书管理系统2 一.系统介绍 二.功能展示 1.用户登陆 2.用户主页 3.图书查询 4.还书 5.个人信息修改 6.图书管理(管理员) ...

  3. 基于springboot+mybatis+mysql+vue运动会报名管理系统

    基于springboot+mybatis+mysql+vue运动会报名管理系统 一.系统介绍 二.功能展示 1.用户登陆 2.报名详情(运行员) 3.比赛报名(运动员) 4.个人参赛项目(运动员) 5 ...

  4. 基于springboot+mybatis+mysql+vue房屋租赁管理系统

    基于springboot+mybatis+mysql+vue房屋租赁管理系统 一.系统介绍 二.功能展示 1.用户登陆 2.管理员端主要功能 2.房主角色端主要功能 3.租客角色端主要功能 三.其它系 ...

  5. Jeecg-Boot 1.0 版本发布,基于SpringBoot+Mybatis+AntDesign快速开发平台

    基于SpringBoot+Mybatis+AntDesign企业级快速开发平台 引言:      Jeecg-Boot 一款基于代码生成器的J2EE快速开发框架!  采用前后端分离技术: Spring ...

  6. 飞机大战HTML5游戏源码,基于Canvas制作的网页版飞机大战游戏+飞机大战手机端

    简介: 飞机大战HTML5游戏源码是一款基于Canvas制作的网页版飞机大战游戏,画质精美的飞机大战手机端游戏源码 网盘下载地址: http://kekewangLuo.net/W1S2LQcqAT2 ...

  7. 基于Springboot+mybatis+mysql+html教育培训中心教学系统

    基于Springboot+mybatis+mysql+html教育培训中心教学系统 一.系统介绍 二.功能展示 1.用户登陆 2.用户注册 3.个人中心 4.人员信息管理 5.课程管理 6.缴费管理 ...

  8. 基于SpringBoot+MyBatis的餐饮点餐系统

    基于SpringBoot+MyBatis的餐饮点餐系统,适用于毕业设计. package com.example.zxdmeal.controller;import com.alibaba.fastj ...

  9. 基于springboot+mybatis+mysql+html企业人事管理系统

    基于springboot+mybatis+mysql+html企业人事管理系统 一.系统介绍 二.功能展示 1.用户登陆 2.员工奖惩--员工 3.合同管理--员工 4.个人薪酬--员工 5.培训管理 ...

最新文章

  1. 使用FFmpeg生成HLS
  2. nginx代理设置域名跳转/域名重定向
  3. 零基础学Python:作用域详解
  4. linux 管道和重定向
  5. java.lang.LinkageError: JAXB 2.0 API is being loaded from the bootstrap classloader, but this RI(xxx
  6. 23种计模式之Python实现(史上最全最通俗易懂)内容整改中
  7. 【MySQL】MySQL基础理论学习笔记
  8. 索引体积_米家温湿度计体积虽小,耗电不小,如果经常离线,换颗电池吧
  9. VNPY - 事件引擎
  10. matlab2c使用c++实现matlab函数系列教程-triu函数
  11. IDE已破解,不用预热,马上进入「微信小程序」开发
  12. jsSIP-demo(完整源码加注释)
  13. 183. Customers Who Never Order没有下过订单的顾客MySQL
  14. 超级艺术家:DeepStyle for Mac
  15. csgo 放置机器人_一键跑图!极为方便的CSGO跑图工具(附2020年5月28日更新)
  16. 1404 数根(函数专题)
  17. 关于uniapp小程序发布新版本,小程序不及时更新问题记录
  18. prometheus数据结构和压缩原理讲解
  19. JZOJ 4210. 【五校联考1day1】我才不是萝莉控呢
  20. Office WORD如何简繁转换

热门文章

  1. Java实现经典算法
  2. Java 输入月份判断该月份有多少天
  3. java手机cpu测试_CPU性能检测下载
  4. 004photoshop点阵图与矢量图的区别
  5. Linux下实现文件实时同步(rsync命令+rsync作为服务+xinetd托管rsync)
  6. STM32+ESP8266+小程序的智能家居(项目简介)
  7. 运营经验之混乱猴子军团chaos monkey
  8. Freehand做稿和输出,有哪些好的建议吗
  9. Windows下Git使用记录--03 Git GUI与金山快盘组建私人代码仓库进行项目开发
  10. JointCalib-雷达与相机的外参标定