目录
一、项目所要实现的功能模块
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.Driver
mybatis.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 {
@Bean
public 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 {
@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 false;
}
return true;
}
}
2、在 AppConfig 类中添加下面的代码
@Configuration
@EnableWebSocket
public class AppConfig implements WebSocketConfigurer,WebMvcConfigurer {
/**
* 添加拦截器
* @param registry
*/
@Override
public 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; // 用户id
private 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 user
where 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 {
@Resource
private UserMapper userMapper;
// 使用 BCrypt 对密码进行加密
@Resource
private BCryptPasswordEncoder bCryptPasswordEncoder;
@PostMapping("/login")
@ResponseBody
public 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")
@ResponseBody
public 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")
@ResponseBody
public 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 会话。
提供三个方法:
- 玩家进入游戏大厅的时候, 将用户的状态存入哈希表中
- 玩家退出游戏大厅的时候, 将用户的状态从哈希表中删除
- 在游戏大厅获取当前用户的信息
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 来作为房间 id
roomId = 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个方法:
- 添加玩家进入到游戏房间中。
- 删除游戏房间中的玩家。
- 根据游戏房间的Id,获取对应的游戏房间。
- 通过用户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 映射关系不存在,直接返回 null
return null;
}
return rooms.get(rooId);
}
}
7、创建匹配队列
- 在 com.example.online_gobang.game 包中创建 Matcher 类。
- 从待匹配的玩家中选中分数相近的玩家进行匹配。
将所有玩家按照天梯积分划分成三类(基础分:1000):
- 业余水平:score < 2000
- 普通水平:score >= 2000 && score < 3000
- 大师水平:score >= 3000
// 1. 业余水平:score < 2000 private Queue<User> amateurQueue = new LinkedList<>(); // 2. 普通水平:score >= 2000 && score < 3000 private Queue<User> normalQueue = new LinkedList<>(); // 3. 大师水平:score >= 3000 private 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 < 2000
private Queue<User> amateurQueue = new LinkedList<>();
// 2. 普通水平:score >= 2000 && score < 3000
private Queue<User> normalQueue = new LinkedList<>();
// 3. 大师水平:score >= 3000
private Queue<User> masterQueue = new LinkedList<>();
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private 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() {
@Override
public void run() {
// 扫描 amateurQueue
while (true) {
handlerMatch(amateurQueue);
}
}
};
t1.start();
Thread t2 = new Thread() {
@Override
public void run() {
// 扫描 normalQueue
while (true) {
handlerMatch(normalQueue);
}
}
};
t2.start();
Thread t3 = new Thread() {
@Override
public void run() {
// 扫描 masterQueue
while (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();
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private Matcher matcher;
@Override
public 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()+"进入游戏大厅");
}
@Override
protected 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)));
}
@Override
public 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);
}
@Override
public 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 {
@Autowired
private MatchAPI matchAPI;
@Override
public 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 = "玩家: " + data.data.username + "<br>分数: " + data.data.score
+ "<br>比赛场次: " + data.data.totalCount + "<br>获胜场数: " + 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: '' // 玩家所处的房间 Id
thisUserId: 1 // 自己的用户Id
thatUserId: 2 // 对手的用户Id
whiteUser: 1 // 执白子的玩家先手 (1:先手; 2:后手)
}
(3)玩家落子请求
请求:
{
message: 'putChess'
userId: 1 // 落子的用户id
row: 0 // 落子的行
col: 0 // 落子的列
}
(4)玩家落子响应
响应:
{
message: 'putChess'
userId: 1 // 落子的用户id
row: 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 {
@Autowired
private GameAPI gameAPI;
@Override
public 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; // 房间id
private int thisUserId; // 自己的用户id
private int thatUserId; // 对手的用户id
private 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; // 玩家id
private 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<>();
提供三个方法
- 玩家进入游戏房间的时候, 将用户的状态存入哈希表中
- 玩家退出游戏房间的时候, 将用户的状态从哈希表中删除
- 在游戏房间获取当前用户的信息
- 在 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 + 50
where userId = #{userId};
</update>
<update id="userLose">
update user set totalCount = totalCount + 1,score = score - 50
where 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 来作为房间 id
roomId = UUID.randomUUID().toString();
// 通过入口类中记录的 context 来手动获取到前面的 RoomManager 和 OnlineUserManager
onlineUserManager = 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
// 如果胜负未分, 就返回 0
private 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; // 先手方的玩家id
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();
// 引入 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 来作为房间 id
roomId = UUID.randomUUID().toString();
// 通过入口类中记录的 context 来手动获取到前面的 RoomManager 和 OnlineUserManager
onlineUserManager = 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 还是 2
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. 打印出当前的棋盘信息, 方便来观察局势. 也方便后面验证胜负关系的判定.
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 数据, 就需要获取到这个用户的 WebSocketSession
WebSocketSession session1 = onlineUserManager.getFromGameRoom(user1.getUserId());
WebSocketSession session2 = onlineUserManager.getFromGameRoom(user2.getUserId());
// 万一当前查到的会话为空(玩家已经下线了) 特殊处理一下
if (session1 == null) {
// 玩家1 已经下线了. 直接认为玩家2 获胜!
response.setWinner(user2.getUserId());
System.out.println("玩家1 掉线!");
}
if (session2 == null) {
// 玩家2 已经下线. 直接认为玩家1 获胜!
response.setWinner(user1.getUserId());
System.out.println("玩家2 掉线!");
}
// 把响应构造成 JSON 字符串, 通过 session 进行传输.
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
// 如果胜负未分, 就返回 0
private 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 获胜者是玩家2
return 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;
}
}
// 胜负未分返回0
return 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 {
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private RoomManager roomManager;
@Autowired
private ObjectMapper objectMapper;
@Resource
private UserMapper userMapper;
@Override
public 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 的响应数据.
// 通知玩家1
noticeGameReady(room,room.getUser1(),room.getUser2());
// 通知玩家2
noticeGameReady(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)));
}
@Override
protected 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());
}
@Override
public 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);
}
@Override
public 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 为 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) {
// 发送坐标给服务器, 服务器要返回结果
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);
}
}
}