文章目录
- 一、核心功能及技术
- 二、效果演示
- 三、创建项目
- 扩展:WebSocket 框架知识
- 四、需求分析和概要设计
- 五、数据库设计与配置 Mybatis
- 六、实现用户模块功能
- 6.1 数据库代码编写
- 6.2 前后端交互接口
- 6.3 服务器开发
- 6.4 客户端开发
- 七、实现匹配模块功能
- 7.1 前后端交互接口
- 7.2 客户端开发
- 7.3 服务器开发
- 八、实现对战模块功能
一、核心功能及技术
核心功能:
- 注册,登录,退出
- 大厅记录用户相关信息,如天梯分数
- 将分数相差不大的同水平选手进行匹配
- 大厅开始匹配,成功后两名玩家进入同一游戏房间,可随时取消匹配
- 多玩家可同时在线,两两玩家随机对弈
- 实时记录玩家游戏信息,管理游戏场数,分数等
- 无论哪一方玩家对弈时退出或掉线,另一方自动获胜
核心技术:
- Spring/SpringBoot/SpringMVC
- WebSocket
- MySQL
- MyBatis
- HTML/CSS/JS/AJAX(canvas API)
注意: WebSocket 和 canvas API 是实现本项目的两个核心技术,前者我们后续会稍许讲解,后者涉及到前端知识,我们就不多赘述,大家可参考以下链接去了解更多关于 canvas 的知识点!https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API
二、效果演示
-
登录
-
注册
-
游戏大厅
-
游戏房间,及分出胜负后的效果
三、创建项目
创建一个springboot项目,具体步骤与前面的博客记录一样,这里就不再重复赘述,大家自行参考以下博客:
1.springboot项目的基本创建
2.添加mybatis框架支持
注意: 除开以前的老几样框架,本次项目我们还引入了一个新的知识点 WebSocket 如下:
扩展:WebSocket 框架知识
参考另一篇博客 https://editor.csdn.net/md/?articleId=130700875
四、需求分析和概要设计
整个项目分成以下模块:
- 用户模块
- 匹配模块
- 对战模块
用户模块
-
用户模块主要负责用户的注册, 登录,退出,分数记录功能.
-
使用 MySQL 数据库存储用户数据.
-
客户端提供一个登录页面+注册页面.
-
服务器端基于 Spring + MyBatis 来实现数据库的增删改查.
匹配模块
-
注册后,用户登录成功, 则进入游戏大厅页面.
-
游戏大厅中, 能够显示用户的名字, 天梯分数, 比赛场数和获胜场数.
-
同时显示一个 “匹配按钮”.
-
点击匹配按钮则用户进入匹配队列, 并且界面上开始匹配按钮显示为 “取消匹配” .
-
再次点击取消匹配“”则把用户从匹配队列中删除.
-
如果匹配成功, 则跳转进入到游戏房间页面.
-
游戏大厅页面加载时和服务器建立 websocket 连接. 双方通过 websocket 来传输 “开始匹配”, “取消匹配”, “匹配成功” 这样的信息.
对战模块
-
玩家匹配成功, 则进入游戏房间页面.
-
每两个玩家在同一个游戏房间中.
-
在游戏房间页面中, 能够显示五子棋棋盘. 玩家点击棋盘上的位置实现落子功能.
-
并且五子连珠则触发胜负判定, 显示 “你赢了” “你输了”.
-
游戏房间页面加载时和服务器建立 websocket 连接. 双方通过 websocket 来传输 “准备就绪”, “落子位置”, “胜负” 这样的信息.
准备就绪: 两个玩家均连上游戏房间的 websocket 时, 则认为双方准备就绪.
落子位置: 有一方玩家落子时, 会通过 websocket 给服务器发送落子的用户信息和落子位置, 同时服务器再将这样的信息返回给房间内的双方客户端. 然后客户端根据服务器的响应来绘制棋子位置.
胜负: 服务器判定这一局游戏的胜负关系. 如果某一方玩家落子, 产生了五子连珠, 则判定胜负并返回胜负信息. 或者如果某一方玩家掉线(比如关闭页面), 也会判定对方获胜.
五、数据库设计与配置 Mybatis
创建如下:
create database if not exists java_gobang DEFAULT CHARACTER SET utf8;
use java_gobang;
drop table if exists user;
create table user(
userId int primary key auto_increment,
username varchar(50) unique,
password varchar(500),
score int, -- 天梯分数
totalCount int, -- 比赛总场次
winCount int -- 获胜场次
);
注意: 为了避免部署项目时云服务器数据库不支持中文,创建数据库时先提前设置好字符集。并且我们会将密码进行加密,所以密码的长度我们设置大一点
配置 Mybatis:
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/java_gobang?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/**Mapper.xml
logging:
pattern:
console: "[%-5level] - %msg%n"
创建 mapper 目录,保存 .xml 文件,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.java_gobang.mapper.UserMapper">
<mapper>
六、实现用户模块功能
前面已经配置好了Mybatis文件,下面我们具体来实现 !!
注意: 用户模块不会涉及到消息推送,我们还是通过AJAX向后端发送请求处理后端响应即可 !
6.1 数据库代码编写
- 创建实体类
创建 model 目录,添加 User 实体类,添加 @Data 注解,提供get,set方法
@Data
public class User {
private int userId;
private String username;
private String password;
private int score;
private int totalCount;
private int winCount;
}
- 创建 UserMapper 接口
创建 mapper 接口目录,添加 UserMapper 接口类
此处主要提供四个方法:
- selectByName: 根据用户名查找用户信息. 用于实现登录.
- insert: 新增用户. 用户实现注册.
- userWin: 用于给获胜玩家修改分数.
- userLose: 用户给失败玩家修改分数.
package com.example.java_gobang.mapper;
import com.example.java_gobang.model.User;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserMapper {
// 往数据库里插入一个用户. 用于注册功能.
void insert(User user);
// 根据用户名, 来查询用户的详细信息. 用于登录功能
User selectByName(String username);
// 总比赛场数 + 1, 获胜场数 + 1, 天梯分数 + 30
void userWin(int userId);
// 总比赛场数 + 1, 获胜场数 不变, 天梯分数 - 30
void userLose(int userId);
}
- .xml文件实现UserMapper 接口
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.java_gobang.mapper.UserMapper">
<insert id="insert">
insert into user values(null, #{username}, #{password}, 1000, 0, 0);
</insert>
<select id="selectByName" resultType="com.example.java_gobang.model.User">
select * from user where username = #{username};
</select>
<update id="userWin">
update user set totalCount = totalCount + 1, winCount = winCount + 1, score = score + 30
where userId = #{userId}
</update>
<update id="userLose">
update user set totalCount = totalCount + 1, score = score - 30
where userId = #{userId}
</update>
</mapper>
6.2 前后端交互接口
需要明确用户模块的前后端交互接口. 这里主要涉及到三个部分.
- 登录接口
请求:
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=zhangsan&password=123
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: ‘zhangsan’,
score: 1000,
totalCount: 10,
winCount: 5
}
如果登录失败, 返回的是一个 userId 为 0 的对象.
- 注册接口
请求:
POST /register HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=zhangsan&password=123
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: ‘zhangsan’,
score: 1000,
totalCount: 10,
winCount: 5
}
如果注册失败(比如用户名重复), 返回的是一个 userId 为 0 的对象.
- 获取用户信息
请求:
GET /userInfo HTTP/1.1
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username: ‘zhangsan’,
score: 1000,
totalCount: 10,
winCount: 5
}
6.3 服务器开发
创建 api.UserAPI 类
主要实现四个方法:
- login: 用来实现登录逻辑.
- register: 用来实现注册逻辑.
- getUserInfo: 用来实现登录成功进入大厅后显示用户信息.
- logout:用来实现退出游戏功能
代码如下:
@RestController
public class UserAPI {
@Resource
private UserMapper userMapper;
//需要提前添加 BCrypt 依赖
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
//登录更新 加上了密码加密
@PostMapping("/login")
@ResponseBody
public Object login(String username, String password, HttpServletRequest request){
// 查询用户是否在数据库中存在
User user = userMapper.selectByName(username);
// 没有查到
if(user == null) {
System.out.println("登录失败!");
return new User();
}else {
//查到了,但密码不一样
if(!bCryptPasswordEncoder.matches(password,user.getPassword())) {
return new User();
}
// 匹配成功,创建 session
request.getSession().setAttribute("user",user);
return user;
}
}
//注册更新,加上了密码加密
@PostMapping("/register")
@ResponseBody
public Object register(String username,String password){
User user1 = userMapper.selectByName(username);
if(user1 != null){
System.out.println("当前用户已存在");
return new User();
}else{
User user2 = new User();
user2.setUsername(username);
String password1 = bCryptPasswordEncoder.encode(password);
user2.setPassword(password1);
userMapper.insert(user2);
return user2;
}
}
@RequestMapping("/logout")
public void logout(HttpServletRequest request, HttpServletResponse response) throws IOException {
HttpSession session = request.getSession(false);
try{
session.removeAttribute("user");
response.sendRedirect("login.html");
}catch (NullPointerException e){
System.out.println("session.removeAttribute()这里没有设置拦截器,直接访问logout页面退出会空指针异常");
}
}
@GetMapping("/userInfo")
@ResponseBody
public Object getUserInfo(HttpServletRequest req) {
try {
HttpSession httpSession = req.getSession(false);
User user = (User) httpSession.getAttribute("user");//拿到的是登录的用户信息
// 拿着这个 user 对象, 去数据库中找, 找到最新的数据
User newUser = userMapper.selectByName(user.getUsername());
return newUser;
} catch (NullPointerException e) {
return new User();
}
}
}
注意: 上述逻辑实现,以及密码加密解密等操作和在线音乐平台的实现一样,若不懂大家可以去参考音乐博客 !!
6.4 客户端开发
- login.html 登录页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/login.css">
</head>
<body id="body">
<div class="nav">
<img src="image/touxiang.png" alt="">
<span>五子棋对战平台</span>
<!-- 空白元素, 用来占位置 -->
</div>
<div class="login-container">
<div class="login-dialog">
<!-- 登录界面的对话框 -->
<h3>用户登录</h3>
<!-- 这个表示一行 -->
<div class="row">
<span>用户名</span>
<input type="text" id="username" placeholder="请输入用户名">
</div>
<div class="row">
<span>密码</span>
<input type="password" id="password" placeholder="请输入密码">
</div>
<!-- 提交按钮 -->
<div class="submit-row1">
<button id="submit">登录</button>
<input type="button" id="submit2" value="注册" onclick="toregister()">
</div>
</div>
</div>
<script src="./js/jquery.min.js"></script>
<style>
#body {
background-image: url("image/roombeijing.png");
background-size:100% 100%;
background-attachment: fixed;
}
</style>
<script type="text/javascript">
function toregister() {
window.location.href = "register.html";
}
</script>
<script>
let submitButton = document.querySelector("#submit");
submitButton.onclick = function (){
let usernameInput = document.querySelector("#username");
let passwordInput = document.querySelector("#password");
if(usernameInput.value.trim() == ""){
alert("请输入用户名!");
usernameInput.focus();
return;
}
if(passwordInput.value.trim() == ""){
alert('请输入密码!');
passwordInput.focus();
return;
}
//通过 jQuery 中的 AJAX 和服务器进行交互
$.ajax({
type: 'post',
url: '/login',
data: {
username: usernameInput.value,
password: passwordInput.value,
},
success: function (body){
//请求执行成功之后的回调函数
//判定当前是否登录成功
// 登录成功返回 当前的 User对象,失败返回一个空的 User 对象
if(body && body.userId > 0){
// alert("登录成功!");
location.assign('/game_hall.html');
}else{
alert("用户名或密码错误!")
}
},
error: function (){
//请求执行失败之后的回调函数
alert("登录失败!")
}
});
}
</script>
</body>
</html>
- register.html 注册页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>注册页面</title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/login.css">
</head>
<body>
<div class="nav">
<img src="image/touxiang.png" alt="">
<span>五子棋对战平台</span>
</div>
<!-- 版心 -->
<div class="login-container">
<!-- 中间的登陆框 -->
<div class="login-dialog">
<h3>用户注册</h3>
<div class="row">
<span>用户名</span>
<input type="text" id="username" placeholder="请输入用户名">
</div>
<div class="row">
<span>密码</span>
<input type="password" id="password" placeholder="请输入密码">
</div>
<div class="row">
<span>确认密码</span>
<input type="password" id="password2" placeholder="请确认密码">
</div>
<div class="submit-row2">
<button id="submit">注册</button>
<input type="button" id="submit2" value="返回" onclick="toregister()">
</div>
</div>
</div>
<script src="./js/jquery.min.js"></script>
<script type="text/javascript">
function toregister() {
window.location.href = "login.html";
}
</script>
<script>
let submitButton = document.querySelector("#submit");
submitButton.onclick = function () {
let username = document.querySelector("#username");
let password1 = document.querySelector("#password");
let password2 = document.querySelector("#password2");
if (username.value.trim() == "") {
alert("请输入用户名!");
username.focus();
return;
}
if (password1.value.trim() == "") {
alert('请输入密码!');
password1.focus();
return;
}
if (password2.value.trim() == "") {
alert('请再次输入密码!');
password2.focus();
return;
}
if (password1.value.trim() != password2.value.trim()) {
alert('两次输入的密码不同!');
passwrod1.value = "";
password2.value = "";
return;
}
$.ajax({
type: 'post',
url: '/register',
data: {
username: username.value,
password: password1.value,
},
success: function (body) {
//请求执行成功之后的回调函数
if (body && body.username) {
alert("注册成功!");
location.assign('/login.html');
} else {
alert("注册失败!当前用户已经存在!")
}
},
error: function () {
//请求执行失败之后的回调函数
alert("注册失败!")
}
});
}
</script>
</body>
</html>
七、实现匹配模块功能
7.1 前后端交互接口
先通过 WebSocket 将前后端连接起来
前端初始化 websocket ,连接:
ws://127.0.0.1:8080/findMatch
后端创建匹配类 MatchAPI,并创建 WebSocketConfig 类实现WebSocketConfigurer接口,来连接匹配类 MatchAPI 和 前端,实现如下:
package com.example.java_gobang.config;
import com.example.java_gobang.api.GameAPI;
import com.example.java_gobang.api.MatchAPI;
import com.example.java_gobang.api.TestAPI;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private TestAPI testAPI;
@Autowired
private MatchAPI matchAPI;
@Autowired
private GameAPI gameAPI;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry webSocketHandlerRegistry) {
webSocketHandlerRegistry.addHandler(testAPI, "/test");
webSocketHandlerRegistry.addHandler(matchAPI, "/findMatch")
//在注册websocket API的时候,就需要把前面准备的 Httpsession(用户登录会给Httpsession保存用户信息) 搞过来(搞到websocket的session中)
//因为在匹配中,你需要把用户相关的信息发送给服务器,服务器根据此信息进行水平匹配
.addInterceptors(new HttpSessionHandshakeInterceptor());
webSocketHandlerRegistry.addHandler(gameAPI, "/game")
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
}
注意: 第一个参数为后端匹配的类,第二个参数也必须和前端规定的路径保持一致 !!
在 addHandler 之后, 再加上一个 .addInterceptors(new HttpSessionHandshakeInterceptor()) 代码, 这样可以把之前登录过程中往 HttpSession 中存放的数据(主要是 User 对象), 放到 WebSocket 的 session 中. 方便后面的代码中获取到当前用户信息.
实现连接后,我们来看具体的请求和响应 !!
请求:
{
message: ‘startMatch’ / ‘stopMatch’,
}
响应1: (收到请求后立即响应)
{
ok: true, // 是否成功. 比如用户 id 不存在, 则返回 false
reason: ‘’, // 错误原因
message: ‘startMatch’ / ‘stopMatch’
}
响应2: (匹配成功后的响应)
{
ok: true, // 是否成功. 比如用户 id 不存在, 则返回 false
reason: ‘’, // 错误原因
message: ‘matchSuccess’,
}
注意:
- 页面这端拿到匹配响应之后, 就跳转到游戏房间.
- 如果返回的响应 ok 为 false, 则弹框的方式显示错误原因, 并跳转到登录页面
7.2 客户端开发
创建 game_hall.html 游戏大厅页面, 主要包含:
- #screen 用于显示玩家的分数信息
- button#match-button 作为匹配按钮.
game_hall.html 的 js 部分代码功能:
- 点击匹配按钮, 就会进入匹配逻辑. 同时按钮上提示 “匹配中…(点击取消)” 字样.
- 再次点击匹配按钮, 则会取消匹配.
- 当匹配成功后, 服务器会返回匹配成功响应, 页面跳转到 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_hall.css">
</head>
<body>
<div class="nav">
<img src="image/touxiang.png" alt="">
<span>五子棋对战大厅</span>
<div class="spacer"></div>
<a href="logout">退出登录[Logout]</a>
</div>
<!-- 整个页面的容器元素 -->
<div class="container">
<!-- 这个 div 在 container 中是处于垂直水平居中这样的位置的 -->
<div>
<!-- 展示用户信息 -->
<div id="screen"></div>
<!-- 匹配按钮 -->
<div id="match-button">开 始 匹 配 ( Play Game )</div>
</div>
</div>
<script src="js/jquery.min.js"></script>
<script>
$.ajax({
type: 'get',
url: '/userInfo',
success: function(body) {
let screenDiv = document.querySelector('#screen');
screenDiv.innerHTML = '您的信息如下:' + '<br> 姓名: ' + body.username + "," + "天梯分数: " + body.score
+ "<br> 比赛场次: " + body.totalCount + "," + "获胜场数: " + body.winCount
},
error: function() {
alert("获取用户信息失败!");
}
});
// 此处进行初始化 websocket, 并且实现前端的匹配逻辑.
// 此处的路径必须写作 /findMatch, 千万不要写作 /findMatch/
let websocketUrl = 'ws://' + location.host + '/findMatch';//location.host访问 game_hall 同样的IP和端口号,不在此处写死,更加灵活
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) {
// 处理服务器返回的响应数据. 这个响应就是针对 "开始匹配" / "结束匹配" 来对应的
// 解析得到的响应对象. 返回的数据是一个 JSON 字符串, 解析成 js 对象 JSON.stringify反之
//扩展:JSON 转换为 Java对象:objectMapper.readValue 反之:writeValueAsString
let resp = JSON.parse(e.data);
let matchButton = document.querySelector('#match-button');
if (!resp.ok) {
console.log("游戏大厅中接收到了失败响应! " + resp.reason);
return;
}
if (resp.message == 'startMatch') {
// 开始匹配请求发送成功
console.log("进入匹配队列成功!");
matchButton.innerHTML = '匹 配 中 ! ! ( 点 击 停 止 )'
} else if (resp.message == 'stopMatch') {
// 结束匹配请求发送成功
console.log("离开匹配队列成功!");
matchButton.innerHTML = '开 始 匹 配 ( Play Game )';
} else if (resp.message == 'matchSuccess') {
// 已经匹配到对手了.
console.log("匹配到对手! 进入游戏房间!");
// location.assign("/game_room.html");
location.replace("/game_room.html");//避免用户在浏览器使用回退功能造成逻辑出错,我们在此使用 replace,不会回退到上一历史页面
} else if (resp.message == 'repeatConnection') {
alert("同一账号禁止多开! 请使用其他账号登录!");
location.replace("/login.html");
} else {
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 == '开 始 匹 配 ( Play Game )') {
console.log("开 始 匹 配 ( Play Game )");
websocket.send(JSON.stringify({
message: 'startMatch',
}));
} else if (matchButton.innerHTML == '匹 配 中 ! ! ( 点 击 停 止 )') {
console.log("停止匹配");
websocket.send(JSON.stringify({
message: 'stopMatch',
}));
}
} else {
// 这是说明连接当前是异常的状态
alert("当前您的连接已经断开! 请重新登录!");
location.replace('/login.html');
}
}
</script>
</body>
</html>
7.3 服务器开发
创建 api.MatchAPI, 继承自 TextWebSocketHandler 作为处理 websocket 请求的入口类,具体如何实现与前端实现 websocket 连接,前面已经写好了的
// 通过这个类来处理匹配功能中的 websocket 请求
@Component
public class MatchAPI extends TextWebSocketHandler {
//处理JSON格式
private ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private Matcher matcher;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
}
}
下面我们就来考虑如何实现这几个重写方法 !!!先做好其他准备
- 实现用户管理器
创建 game.OnlineUserManager 类, 用于管理当前用户的在线状态. 本质上是 哈希表 的结构. key 为用户 id, value 为用户的 WebSocketSession.
借助这个类, 一方面可以判定用户是否是在线, 同时也可以进行方便的获取到 Session 从而给客户端回话.
- 当玩家建立好 websocket 连接, 则将键值对加入 OnlineUserManager 中.
- 当玩家断开 websocket 连接, 则将键值对从 OnlineUserManager 中删除.
- 在玩家连接好的过程中, 随时可以通过 userId 来查询到对应的会话, 以便向客户端返回数据.
由于存在两个页面, 游戏大厅和游戏房间, 使用两个 哈希表 来分别存储两部分的会话.
代码实现如下:
package com.example.java_gobang.game;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;
//这个类用来管理 用户在大厅和游戏房间里的状态
@Component
public class OnlineUserManager {
// 这个哈希表就用来表示当前用户在游戏大厅在线状态.
// 避免同时有多个用户并发和服务器建立/断开连接,这里采用线程安全的 ConcurrentHashMap
private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();
// 这个哈希表就用来表示当前用户在游戏房间的在线状态.
private ConcurrentHashMap<Integer, WebSocketSession> gameRoom = 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);
}
//进入游戏房间
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);
}
}
- 创建匹配请求/响应对象
创建 game.MatchRequest 类
package com.example.java_gobang.game;
// 这是表示一个 websocket 的匹配请求
public class MatchRequest {
private String message = "";
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
创建 game.MatchResponse 类
// 这是表示一个 websocket 的匹配响应
@Data
public class MatchResponse {
private boolean ok;
private String reason;
private String message;
}
- 处理连接成功
实现 afterConnectionEstablished 方法.
-
通过参数中的 session 对象, 拿到之前登录时设置的 User 信息.
-
先判定用户是否是已经在线, 如果在线则直接返回出错 (禁止同一个账号多开).
-
使用 onlineUserManager 来管理用户的在线状态,设置玩家的上线状态.
代码实现如下:
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 玩家上线进入到游戏大厅, 将用户信息加入到 OnlineUserManager 中
// 1. 先获取到当前用户的身份信息(谁在游戏大厅中, 建立的连接)
// 此处的代码, 之所以能够 getAttributes, 全靠了在注册 Websocket 的时候,
// 加上的 .addInterceptors(new HttpSessionHandshakeInterceptor());
// 这个逻辑就把 HttpSession 中的 Attribute 都给拿到 WebSocketSession 中了
// 在 Http 登录逻辑中, 往 HttpSession 中存了 User 数据: httpSession.setAttribute("user", user);
// 此时就可以在 WebSocketSession 中把之前 HttpSession 里存的 User 对象给拿到了.
// 注意, 此处拿到的 user, 是有可能为空的!!
// 如果之前用户压根就没有通过 HTTP 来进行登录, 直接就通过 /game_hall.html 这个 url 来访问游戏大厅页面
// 此时就会出现 user 为 null 的情况
try {
User user = (User) session.getAttributes().get("user");
// 2. 先判定当前用户是否已经登录过(已经是在线状态), 如果是已经在线, 就不该继续进行后续逻辑.禁止多开(同一账号登录多处)
// 如果使用浏览器多开,会使用户的状态在hash表中同一个key对应两个value,而后一个value会将前一个value覆盖,
// 所以第一个浏览器的连接就会失效,拿不到websocketsession,也就无法给它推送数据了
if (onlineUserManager.getFromGameHall(user.getUserId()) != null
|| onlineUserManager.getFromGameRoom(user.getUserId()) != null) {
// 当前用户已经登录了!!
// 针对这个情况要告知客户端, 你这里重复登录了.
MatchResponse response = new MatchResponse();
response.setOk(true);
response.setReason("您已登录,当前禁止多开!");
response.setMessage("repeatConnection");
// TestMessage 表示一个文本格式的 websocket 数据包
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
// 此处直接关闭有些太激进了, 还是返回一个特殊的 message response.setMessage("repeatConnection"), 供客户端来进行判定, 由客户端负责进行处理
// 并且这里的直接关闭会触发后面的 afterConnectionClosed,通过用户id使先登录的浏览器也会断开websocket连接,不科学,我们只需要禁止重复登录就行
// session.close();
return;
}
// 3. 拿到了身份信息之后, 就可以把玩家设置成在线状态了
onlineUserManager.enterGameHall(user.getUserId(), session);
System.out.println("玩家 " + user.getUsername() + " 进入游戏大厅!");
} catch (NullPointerException e) {
System.out.println("[MatchAPI.afterConnectionEstablished] 当前用户未登录!");
// e.printStackTrace(); 不直接打印异常调用栈了,我们在控制台自定义日志输出
// 出现空指针异常, 说明当前用户的身份信息是空, 用户未登录呢.
// 把当前用户尚未登录这个信息给返回回去~~
MatchResponse response = new MatchResponse();
response.setOk(false);
response.setReason("您尚未登录! 不能进行后续匹配功能!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
}
}
- 处理开始匹配/取消匹配请求
实现 handleTextMessage 方法
- 先从会话中拿到当前玩家的信息.
- 解析客户端发来的请求
- 判定请求的类型, 如果是 startMatch, 则把用户对象加入到匹配队列. 如果是 stopMatch, 则把用户对象从匹配队列中删除.
- 此处需要实现一个 匹配器类Matcher, 来处理匹配的实际逻辑.
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
// 实现处理开始匹配请求和处理停止匹配请求.
User user = (User) session.getAttributes().get("user");
// 获取到客户端给服务器发送的数据
String payload = message.getPayload();
// 当前这个数据载荷是一个 JSON 格式的字符串, 就需要把它转成 Java 对象. MatchRequest
MatchRequest request = objectMapper.readValue(payload, MatchRequest.class);
MatchResponse response = new MatchResponse();
if (request.getMessage().equals("startMatch")) {
// 进入匹配队列
matcher.add(user);
// 把玩家信息放入匹配队列之后, 就可以返回一个响应给客户端了.
response.setOk(true);
response.setMessage("startMatch");
} else if (request.getMessage().equals("stopMatch")) {
// 退出匹配队列
matcher.remove(user);
// 移除之后, 就可以返回一个响应给客户端了.
response.setOk(true);
response.setMessage("stopMatch");
} else {
response.setOk(false);
response.setReason("非法的匹配请求");
}
String jsonString = objectMapper.writeValueAsString(response);
session.sendMessage(new TextMessage(jsonString));
}
- 实现匹配器
创建 game.Matcher 类.并按照如下要求实现
- 在 Matcher 中创建三个队列 (队列中存储 User 对象), 分别表示不同的段位的玩家.
(此处约定 <2000 一档, 2000-3000 一档, >3000 一档) - 提供 add 方法, 供 MatchAPI 类来调用, 用来把玩家加入匹配队列.
- 提供 remove 方法, 供 MatchAPI 类来调用, 用来把玩家移出匹配队列.
- 同时 Matcher 找那个要记录 OnlineUserManager, 来获取到玩家的 Session.
- 在 Matcher 的构造方法中, 创建一个线程, 使用该线程扫描每个队列, 把每个队列的头两个元素取出来, 匹配到一组中.
- 实现 handlerMatch 处理匹配方法
1.由于 handlerMatch 在单独的线程中调用. 因此要考虑到多用户访问队列的线程安全问题. 需要加上锁.
2.每个队列分别使用队列对象本身作为锁即可.
3.在入口处使用 wait 来等待, 直到队列中达到 2 个元素及其以上, 才唤醒线程消费队列.插入成功后要通知唤醒上面的等待逻辑.
4.需要给上面的插入队列元素, 删除队列元素也加上锁
代码实现如下:
// 这个类表示 "匹配器", 通过这个类负责完成整个匹配功能
@Component
public class Matcher {
// 创建三个匹配队列
private Queue<User> normalQueue = new LinkedList<>();
private Queue<User> highQueue = new LinkedList<>();
private Queue<User> veryHighQueue = new LinkedList<>();
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private RoomManager roomManager;
private ObjectMapper objectMapper = new ObjectMapper();
// 操作匹配队列的方法.
// 把玩家放到匹配队列中
//对于不同队列同时进行添加和移除操作是不会产生线程不安全的,而是对同一队列进行,所以要对同一队列对象进行加锁
public void add(User user) {
if (user.getScore() < 2000) {
synchronized (normalQueue) {
normalQueue.offer(user);
normalQueue.notify();//有玩家进入匹配队列时,就唤醒该队列 ,对应handlerMatch方法中的 wait
}
System.out.println("把玩家 " + user.getUsername() + " 加入到了 normalQueue匹配队列中!");
} else if (user.getScore() >= 2000 && user.getScore() < 3000) {
synchronized (highQueue) {
highQueue.offer(user);
highQueue.notify();
}
System.out.println("把玩家 " + user.getUsername() + " 加入到了 highQueue匹配队列中!");
} else {
synchronized (veryHighQueue) {
veryHighQueue.offer(user);
veryHighQueue.notify();
}
System.out.println("把玩家 " + user.getUsername() + " 加入到了 veryHighQueue匹配队列中!");
}
}
// 当玩家点击停止匹配的时候, 就需要把玩家从匹配队列中删除
public void remove(User user) {
if (user.getScore() < 2000) {
synchronized (normalQueue) {
normalQueue.remove(user);
}
System.out.println("把玩家 " + user.getUsername() + " 移除了 normalQueue匹配队列!");
} else if (user.getScore() >= 2000 && user.getScore() < 3000) {
synchronized (highQueue) {
highQueue.remove(user);
}
System.out.println("把玩家 " + user.getUsername() + " 移除了 highQueue匹配队列!");
} else {
synchronized (veryHighQueue) {
veryHighQueue.remove(user);
}
System.out.println("把玩家 " + user.getUsername() + " 移除了 veryHighQueue匹配队列!");
}
}
public Matcher() {
// 创建三个线程, 分别针对这三个匹配队列, 进行操作.
Thread t1 = new Thread() {
@Override
public void run() {
// 扫描 normalQueue
while (true) { //在这里循环速度极快,一进入handlerMatch就快速返回,但是当队列中的个数小于2时,这样快速的扫描就没有什么意义,且CPU占用率很高,即出现了忙等
handlerMatch(normalQueue);
//针对上面的忙等,可以在调用完 handlerMatch 后,进行sleep
//这样做可以,但是不完美,比如玩家已经匹配到对手,却还要等待休眠结束后,才能进行游戏
}
}
};
t1.start();
Thread t2 = new Thread(){
@Override
public void run() {
while (true) {
handlerMatch(highQueue);
}
}
};
t2.start();
Thread t3 = new Thread() {
@Override
public void run() {
while (true) {
handlerMatch(veryHighQueue);
}
}
};
t3.start();
}
private void handlerMatch(Queue<User> matchQueue) {
synchronized (matchQueue) {
try {
// 1. 检测队列中元素个数是否达到 2
// 队列的初始情况可能是 空
// 如果往队列中添加一个元素, 这个时候, 仍然是不能进行后续匹配操作的.
// 因此在这里使用 while 循环检查是更合理的~~ 只有当 size大于2后,才能进行后续操作
while (matchQueue.size() < 2) {
matchQueue.wait();//队列中的数目一直小于2时,即一直还没有玩家加入队列直到数目达到2以上,就进行线程等待
}
// 2. 尝试从队列中取出两个玩家
User player1 = matchQueue.poll();
User player2 = matchQueue.poll();
System.out.println("匹配出两个玩家: " + player1.getUsername() + ", " + player2.getUsername());
// 3. 获取到玩家的 websocket 的会话
// 获取到会话的目的是为了告诉玩家, 你排到了~~
WebSocketSession session1 = onlineUserManager.getFromGameHall(player1.getUserId());
WebSocketSession session2 = onlineUserManager.getFromGameHall(player2.getUserId());
// 理论上来说, 匹配队列中的玩家一定是在线的状态.
// 因为前面的逻辑里进行了处理, 当玩家断开连接的时候就把玩家从匹配队列中移除了.
// 但是此处仍然进行一次判定~~
if (session1 == null) {
// 如果玩家1 现在不在线了, 就把玩家2 重新放回到匹配队列中
matchQueue.offer(player2);
return;
}
if (session2 == null) {
// 如果玩家2 现在下线了, 就把玩家1 重新放回匹配队列中
matchQueue.offer(player1);
return;
}
// 当前能否排到两个玩家是同一个用户的情况嘛? 一个玩家入队列了两次??
// 理论上也不会存在~~
// 1) 如果玩家下线, 就会对玩家移出匹配队列
// 2) 又禁止了玩家多开.
// 但是仍然在这里多进行一次判定, 以免前面的逻辑出现 bug 时带来严重的后果.
if (session1 == session2) {
// 把其中的一个玩家放回匹配队列.
matchQueue.offer(player1);
return;
}
// 4. 把这两个玩家放到一个游戏房间中.
Room room = new Room();
roomManager.add(room, player1.getUserId(), player2.getUserId());
// 5. 给玩家反馈信息: 你匹配到对手了~
// 通过 websocket 返回一个 message 为 'matchSuccess' 这样的响应
// 此处是要给两个玩家都返回 "匹配成功" 这样的信息.
// 因此就需要返回两次
MatchResponse response1 = new MatchResponse();
response1.setOk(true);
response1.setMessage("matchSuccess");
String json1 = objectMapper.writeValueAsString(response1);
session1.sendMessage(new TextMessage(json1));
MatchResponse response2 = new MatchResponse();
response2.setOk(true);
response2.setMessage("matchSuccess");
String json2 = objectMapper.writeValueAsString(response2);
session2.sendMessage(new TextMessage(json2));
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
}
}
}
通过上述匹配器进行玩家匹配,成功匹配出两名玩家后,我们就将玩家加入到一个游戏房间中进行后续具体的游戏操作。所以接下来我们需要完成游戏房间的设置
- 创建房间类
创建 game.Room 类
- 一个房间要包含一个房间 ID, 使用 UUID 作为房间的唯一身份标识.
- 房间内要记录对弈的玩家双方信息.
- 记录先手方的 ID
- 记录一个 二维数组 , 作为对弈的棋盘.
- 记录一个 OnlineUserManager, 以备后面和客户端进行交互.
- 当然, 少不了 ObjectMapper 来处理 json
@Data
public class Room {
private String roomId;
// 玩家1
private User user1;
// 玩家2
private User user2;
// 先手方的用户 id
private int whiteUserId = 0;
// 棋盘, 数字 0 表示未落子位置. 数字 1 表示玩家 1 的落子. 数字 2 表示玩家 2 的落子
private static final int MAX_ROW = 15;
private static final int MAX_COL = 15;
private int[][] chessBoard = new int[MAX_ROW][MAX_COL];
private ObjectMapper objectMapper = new ObjectMapper();
private OnlineUserManager onlineUserManager;
public Room() {
// 构造 Room 的时候生成一个唯一的字符串表示房间 id.
// 使用 UUID 来作为房间 id
roomId = UUID.randomUUID().toString();
// 通过入口类中记录的 context 来手动获取到前面的 RoomManager 和 OnlineUserManager
onlineUserManager = JavaGobangApplication.context.getBean(OnlineUserManager.class);
roomManager = JavaGobangApplication.context.getBean(RoomManager.class);
userMapper = JavaGobangApplication.context.getBean(UserMapper.class);
}
}
具体的游戏操作,我们在后面的对战模块进行补写 !!
- 实现房间管理器
Room 对象会存在很多. 每两个对弈的玩家, 都对应一个 Room 对象.需要一个管理器对象RoomMannager 来管理所有的 Room.
创建 game.RoomManager
- 使用一个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象
- 再使用一个 Hash 表, 保存 userId -> roomId 的映射, 方便根据玩家来查找所在的房间.
- 提供增, 删, 查的 API. (查包含两个版本, 基于房间 ID 的查询和基于用户 ID 的查询).
代码实现如下:
// 房间管理器类.
// 这个类也希望有唯一实例.
@Component
public class RoomManager {
private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
//将玩家id 和 游戏房间id 的映射关系存储起来
private ConcurrentHashMap<Integer, String> userIdToRoomId = new ConcurrentHashMap<>();
public void add(Room room, int userId1, int userId2) {
rooms.put(room.getRoomId(), room);
userIdToRoomId.put(userId1, room.getRoomId());
userIdToRoomId.put(userId2, room.getRoomId());
}
public void remove(String roomId, int userId1, int userId2) {
rooms.remove(roomId);
userIdToRoomId.remove(userId1);
userIdToRoomId.remove(userId2);
}
public Room getRoomByRoomId(String roomId) {
return rooms.get(roomId);
}
//通过玩家id 找到玩家所在的游戏房间
public Room getRoomByUserId(int userId) {
String roomId = userIdToRoomId.get(userId);
if (roomId == null) {
// userId -> roomId 映射关系不存在, 直接返回 null
return null;
}
return rooms.get(roomId);
}
}
- 处理连接关闭
实现 afterConnectionClosed
- 主要的工作就是把玩家从 onlineUserManager 中退出.
- 退出的时候要注意判定, 当前玩家是否是多开的情况(一个userId, 对应到两个 websocket 连接). 如果一个玩家开启了第二个 websocket 连接, 那么这第二个 websocket 连接不会影响到玩家从 OnlineUserManager 中退出.
- 如果玩家当前在匹配队列中, 则直接从匹配队列里移除.
代码实现如下:
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
try {
// 玩家下线, 从 OnlineUserManager 中删除
User user = (User) session.getAttributes().get("user");
WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
if (tmpSession == session) {
onlineUserManager.exitGameHall(user.getUserId());
}
// 如果玩家正在匹配中, 而 websocket 连接断开了, 就应该移除匹配队列
matcher.remove(user);
} catch (NullPointerException e) {
System.out.println("[MatchAPI.afterConnectionClosed] 当前用户未登录!");
// e.printStackTrace();
// 不应该在连接关闭之后, 还尝试发送消息给客户端
// MatchResponse response = new MatchResponse();
// response.setOk(false);
// response.setReason("您尚未登录! 不能进行后续匹配功能!");
// session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
}
}
}
- 理连接异常
实现 handleTransportError. 逻辑同上.
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
try {
// 玩家下线, 从 OnlineUserManager 中删除
User user = (User) session.getAttributes().get("user");
WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
if (tmpSession == session) {
onlineUserManager.exitGameHall(user.getUserId());
}
// 如果玩家正在匹配中, 而 websocket 连接断开了, 就应该移除匹配队列
matcher.remove(user);
} catch (NullPointerException e) {
System.out.println("[MatchAPI.handleTransportError] 当前用户未登录!");
// e.printStackTrace();
// MatchResponse response = new MatchResponse();
// response.setOk(false);
// response.setReason("您尚未登录! 不能进行后续匹配功能!");
// session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
//上述websocket连接都已经断开了,还怎么能发送响应呢 !!!
}
}
八、实现对战模块功能
至此,上述逻辑能成功实现当多个玩家开始匹配游戏,两两玩家能被分配到一个游戏房间中进行游戏,至于如何实现游戏逻辑,我们接着来看 !!!