所用技术栈:
后端:Spring、Spring Boot、Spring MVC
前端:HTML、CSS、JS、AJAX
数据库:MySQL、MyBatis
WebSocket
WebSocket
是从 HTML5
开始支持的一种网页端和服务端保持长连接的 消息推送机制.
WebSocket
是实现消息推送的主要机制。像五子棋这样的程序, 或者聊天这样的程序, 都是非常依赖 “消息推送” 的. 如果只是使用原生的 HTTP 协议, 要想实现消息推送一般需要通过 “轮询” 的方式.轮询的成本比较高, 而且也不能及时的获取到消息的响应.而 WebSocket 则是更接近于 TCP 这种级别的通信方式. 一旦连接建立完成, 客户端或者服务器都可以主动的向对方发送数据.
WebSocket
协议本质上是一个基于 TCP
的协议。为了建立一个 WebSocket
连接,客户端浏览器首先要向服务器发起一个 HTTP 请求,这个请求和通常的 HTTP 请求不同,包含了一些附加头信息,通过这个附加头信息完成握手过程.
FIN
: 为 1 表示要断开 websocket 连接.RSV1/RSV2/RSV3
: 保留位, 一般为 0.opcode
: 操作代码. 决定了如何理解后面的数据载荷,opcode描述了当前这个websocket 报文是啥类型。0x0: 表示这是个延续帧. 当 opcode 为 0, 表示本次数据传输采用了数据分片, 当前收到的帧为其中一个分片.
0x1: 表示这是文本帧.
0x2: 表示这是二进制帧.
0x3-0x7: 保留, 暂未使用.
0x8: 表示连接断开.
0x9: 表示 ping 帧.
0xa: 表示 pong 帧.
0xb-0xf: 保留, 暂未使用.
mask
: 表示是否要对数据载荷进行掩码操作。从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作。Payload length
:数据载荷的长度,单位是字节。为7位,或7+16位,或1+64位。Masking-key
:0或4字节(32位)所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask为1,且携带了4字节的Masking-key。如果Mask为0,则没有Masking-keypayload data
: 报文携带的载荷数据.Spring 内置了 websocket
. 可以直接进行使用.
用户模块:用户模块主要负责用户的注册, 登录, 分数记录功能.
使用 MySQL 数据库存储数据.
客户端提供一个登录页面+注册页面.
服务器端基于 Spring + MyBatis 来实现数据库的增删改查.
匹配模块:用户登录成功, 则进入游戏大厅页面.
游戏大厅中, 能够显示用户的名字, 天梯分数, 比赛场数和获胜场数.
同时显示一个 “匹配按钮”.
点击匹配按钮则用户进入匹配队列, 并且界面上显示为 “取消匹配” .
再次点击则把用户从匹配队列中删除.
如果匹配成功, 则跳转进入到游戏房间页面.
页面加载时和服务器建立 websocket
连接. 双方通过 websocket
来传输 “开始匹配”, “取消匹配”, “匹配成功” 这样的信息.
对战模块:玩家匹配成功, 则进入游戏房间页面.
每两个玩家在同一个游戏房间中.
在游戏房间页面中, 能够显示五子棋棋盘. 玩家点击棋盘上的位置实现落子功能.
并且五子连珠则触发胜负判定, 显示 “你赢了” “你输了”.
页面加载时和服务器建立 websocket 连接. 双方通过 websocket 来传输 “准备就绪”, “落子位置”, “胜负” 这样的信息.
准备就绪: 两个玩家均连上游戏房间的 websocket 时, 则认为双方准备就绪.
落子位置: 有一方玩家落子时, 会通过 websocket 给服务器发送落子的用户信息和落子位置, 同时服务器再将这样的信息返回给房间内的双方客户端. 然后客户端根据服务器的响应来绘制棋子位置.
胜负: 服务器判定这一局游戏的胜负关系. 如果某一方玩家落子, 产生了五子连珠, 则判定胜负并返回胜负信息. 或者如果某一方玩家掉线(比如关闭页面), 也会判定对方获胜.
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0modelVersion>
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.7.2version>
<relativePath/>
parent>
<groupId>com.examplegroupId>
<artifactId>demoartifactId>
<version>0.0.1-SNAPSHOTversion>
<name>demoname>
<description>Demo project for Spring Bootdescription>
<properties>
<java.version>1.8java.version>
properties>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-websocketartifactId>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>2.2.2version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
<scope>runtimescope>
<optional>trueoptional>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<scope>runtimescope>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<optional>trueoptional>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
exclude>
excludes>
configuration>
plugin>
plugins>
build>
project>
创建user表,表示用户信息和身份信息。初始化其天梯分数都为1000.
create database if not exists java_gobang;
use java_gobang;
drop table if exists user;
create table user(
userId int primary key auto_increment,
username varchar(50) unique,
password varchar(50),
score int, -- 天梯分数
totalCount int, -- 比赛总场次
winCount int -- 获胜场次
);
insert into user values(null, '张三', '123', 1000, 0, 0);
insert into user values(null, '李四', '123', 1000, 0, 0);
insert into user values(null, '王五', '123', 1000, 0, 0);
insert into user values(null, '赵六', '123', 1000, 0, 0);
编辑 application.yml
如下:
# 配置数据库的连接字符串
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/java_gobang?characterEncoding=utf8
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
# 设置 Mybatis 的 xml 保存路径
mybatis:
mapper-locations: classpath:mapper/**Mapper.xml
configuration: # 配置打印 MyBatis 执行的 SQL
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# 配置打印 MyBatis 执行的 SQL
logging:
level:
com:
example:
demo: debug
创建实体类model.User
:
@Data
public class User {
private int userId;
private String username;
private String password;
private int score;
private int totalCount;
private int winCount;
}
创建 model.UserMapper
接口:
@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);
}
在resource下面创建一个mapper文件,并在该文件下创建一个UserMapper.xml
:
DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.demo.model.UserMapper">
<insert id="insert">
insert into user values(null, #{username}, #{password}, 1000, 0, 0);
insert>
<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>
<select id="selectByName" resultType="com.example.demo.model.User">
select * from user where username = #{username};
select>
mapper>
需要明确用户模块的前后端交互接口. 这里主要涉及到三个部分:
登录接口
请求:
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
}
创建 api.UserAPI
类
主要实现三个方法:
login
: 用来实现登录逻辑.
register
: 用来实现注册逻辑.
getUserInfo
: 用来实现登录成功后显示用户分数的信息.
package com.example.demo.api;
import com.example.demo.model.User;
import com.example.demo.model.UserMapper;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
/**
* Created With IntelliJ IDEA
* Description:
* Users: yyyyy
* Date: 2022-08-17
* Time: 8:41
*/
@RestController
public class UserAPI {
@Resource
private UserMapper userMapper;
/**
* 登录
* @param username
* @param password
* @param request
* @return
*/
@RequestMapping("/login")
@ResponseBody
public Object login(@RequestParam String username, @RequestParam String password,
HttpServletRequest request){
User user = userMapper.selectByName(username);
if (user == null || !user.getPassword().equals(password)){
System.out.println("登录失败");
return new User();
}
HttpSession session = request.getSession(true);
session.setAttribute("user",user);
return user;
}
/**
* 注册功能
* @param username
* @param password
* @return
*/
@RequestMapping("/register")
@ResponseBody
public Object register(@RequestParam String username, @RequestParam String password){
try {
//设置用户名不能相同
User user = new User();
user.setUsername(username);
user.setPassword(password);
userMapper.insert(user);
return user;
}catch (org.springframework.dao.DuplicateKeyException e){
//如果注册失败,返回一个空对象
User user = new User();
return user;
}
}
@RequestMapping("/userInfo")
@ResponseBody
public Object getUserInfo(HttpServletRequest request){
try {
//处理获取到了session为空的情况
HttpSession session = request.getSession(false);
User user = (User) session.getAttribute("user");
//拿到user对象去数据库中找
User newUser = userMapper.selectByName(user.getUsername());
return newUser;
}catch (NullPointerException e){
return new User();
}
}
}
创建一个login.html
,在 login.html
中编写 js 代码
通过 jQuery 中的 AJAX 和服务器进行交互.
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">
五子棋对战
div>
<div class="login-container">
<div class="login-dialog">
<h3>登录h3>
<div class="row">
<span>用户名span>
<input type="text" id="username">
div>
<div class="row">
<span>密码span>
<input type="password" id="password">
div>
<div class="row">
<button id="submit">提交button>
div>
div>
div>
<script src="./js/jquery.min.js">script>
<script>
let usernameInput = document.querySelector('#username');
let passwordInput = document.querySelector('#password');
let submitButton = document.querySelector('#submit');
submitButton.onclick = function () {
$.ajax({
type: 'post',
url: '/login',
data:{
username: usernameInput.value,
password:passwordInput.value,
},
success: function (body) {
//请求执行成功之后的回调函数
if (body && body.userId > 0){
alert("登录成功!");
//重定向到游戏大厅页面
location.assign('/game_hall.html');
}else {
alert("登录失败!");
$("#username").val("");
$("#password").val("");
}
},
error: function () {
//请求执行失败之后的函数,登录失败之后,用户名和密码置为空
alert("登录失败!");
$("#username").val("");
$("#password").val("");
}
});
}
script>
body>
html>
创建 css/common.css
:
/*公共样式*/
*{
margin: 0;
padding: 0;
box-sizing: border-box;
}
.container{
width: 100%;
height: calc(100% - 50px);
display: flex;
align-items: center;
justify-content: center;
}
html,body{
/*相对已父元素高度设置为100%*/
height: 100%;
background-image: url("../images/wu.jpeg");
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
/*导航栏*/
.nav{
height: 50px;
background-color: rgb(50,50,50);
color: white;
line-height: 50px;
padding-left: 20px;
}
创建 css/login.css
:
.login-container{
height: calc(100% - 50px);
display: flex;
justify-content: center;
align-items: center;
}
.login-dialog{
width: 400px;
height: 400px;
/*透明度调整 0.3*/
background-color: rgba(255,255,255,0.3);
border-radius: 10px;
}
/*标题*/
.login-dialog h3{
text-align: center;
padding: 50px 50px;
}
.login-dialog .row{
width: 100%;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.login-dialog .row span{
width: 80px;
/*字体变粗*/
font-weight: 700;
}
#username,#password{
width: 200px;
height: 40px;
font-size: 20px;
line-height: 40px;
padding-left: 10px;
border: none;
outline: none;
border-radius: 10px;
}
#submit{
width: 300px;
height: 50px;
background-color: cornflowerblue;
color: white;
border: none;
outline: none;
border-radius: 10px;
margin-top: 40px;
font-size: 15px;
}
/*实现按钮点击动画*/
#submit:active{
background-color: gray;
color: black;
}
创建 register.html
,并b填写Ajax代码:
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">
五子棋对战注册界面
div>
<div class="login-container">
<div class="login-dialog">
<h3>注册h3>
<div class="row">
<span>用户名span>
<input type="text" id="username">
div>
<div class="row">
<span>密码span>
<input type="password" id="password">
div>
<div class="row">
<button id="submit">提交button>
div>
div>
div>
<script src="./js/jquery.min.js">script>
<script>
let usernameInput = document.querySelector('#username');
let passwordInput = document.querySelector('#password');
let submitButton = document.querySelector('#submit');
submitButton.onclick = function(){
$.ajax({
type: 'post',
url: '/register',
data:{
username: usernameInput.value,
password:passwordInput.value,
},
success: function (body) {
//如果注册成功,就会返回一个新注册好的对象
if (body && body.username){
alert("注册成功!");
location.assign("/login.html")
}else {
alert("注册失败!");
}
},
error: function () {
alert("注册失败!");
}
});
}
script>
body>
html>
首先定义前后端交互接口:
连接:
ws://127.0.0.1:8080/findMatch
请求:
{
message: 'startMatch' / 'stopMatch',
}
匹配响应1:这个响应是客户端给服务器发送匹配请求之后,服务器立即返回的匹配响应。
{
ok: true, // 是否成功. 比如用户 id 不存在, 则返回 false
reason: '', // 错误原因
message: 'startMatch' / 'stopMatch'
}
匹配响应2:这个是真正匹配到对手之后,服务器主动推送回来的消息。
{
ok: true, // 是否成功. 比如用户 id 不存在, 则返回 false
reason: '', // 错误原因
message: 'matchSuccess',
}
匹配到的对手不需要在响应中体现,仍然都放在服务器这边来保存。
页面这端拿到匹配响应之后, 就跳转到游戏房间.
如果返回的响应 ok 为 false, 则弹框的方式显示错误原因, 并跳转到登录页面.
实现游戏大厅页面基本结构,创建 game_hall.html
, 主要包含:
#screen
用于显示玩家的分数信息button#match-button
作为匹配按钮然后编写js代码,获取用户信息,然后实现匹配逻辑:
game_room.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/game_hall.css">
head>
<body>
<div class="nav">五子棋对战div>
<div class="container">
<div>
<div id="screen">div>
<div id="match-button">开始匹配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 = '玩家: ' + body.username + " 分数: " + body.score
+ "
比赛场次: " + body.totalCount + " 获胜场数: " + body.winCount
},
error: function() {
alert("获取用户信息失败!");
}
});
// 此处进行初始化 websocket, 并且实现前端的匹配逻辑.
// 此处的路径必须写作 /findMatch, 千万不要写作 /findMatch/
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 方法.
//主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function() {
websocket.close();
}
// 处理服务器返回的响应
websocket.onmessage = function(e) {
// 处理服务器返回的响应数据. 这个响应就是针对 "开始匹配" / "结束匹配" 来对应的
// 解析得到的响应对象. 返回的数据是一个 JSON 字符串, 解析成 js 对象
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 = '开始匹配';
} else if (resp.message == 'matchSuccess') {
// 已经匹配到对手了.
console.log("匹配到对手! 进入游戏房间!");
// location.assign("/game_room.html");
location.replace("/game_room.html");
} 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 == '开始匹配') {
console.log("开始匹配");
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>
同时创建一个css/game_hall.css
来设置其界面背景及按钮样式:
#screen{
width: 400px;
height: 200px;
font-size: 20px;
background-color: gray;
background-color: rgba(67,149,244,0.6);
color: white;
border-radius: 10px;
text-align: center;
line-height: 100px;
}
#match-button{
width: 400px;
height: 50px;
font-size: 20px;
color: white;
background-color: cornflowerblue;
border: none;
outline: none;
border-radius: 10px;
text-align: center;
line-height: 50px;
margin-top: 10px;
}
#match-button:active{
background-color: gray;
color: black;
}
MatchAPI
类创建 api.MatchAPI
, 继承自 TextWebSocketHandler
作为处理 websocket
请求的入口类.重载下图中的4个方法,并准备好一个 ObjectMapper
, 后续用来处理 JSON
数据。
@Component
public class MatchAPI extends TextWebSocketHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private OnlineUserManager onlineUserManager;
@Component
public class MatchAPI extends TextWebSocketHandler {
}
@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 {
}
}
在config.WebSocketConfig
类中把 MatchAPI
注册进去。在 addHandler
之后, 再加上一个 .addInterceptors(newHttpSessionHandshakeInterceptor())
代码, 这样可以把之前登录过程中往HttpSession
中存放的数据(主要是 User 对象), 放到 WebSocket
的 session
中. 方便后面的代码中获取到当前用户信息.
@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")
.addInterceptors(new HttpSessionHandshakeInterceptor());
webSocketHandlerRegistry.addHandler(gameAPI, "/game")
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
}
创建 game.OnlineUserManager
类, 用于管理当前用户的在线状态. 本质上是 哈希表 的结构. key 为用户 id, value 为用户的 WebSocketSession
.
借助这个类, 一方面可以判定用户是否是在线, 同时也可以进行方便的获取到 Session 从而给客户端回话.
OnlineUserManager
中.OnlineUserManager
中删除.@Component
public class OnlineUserManger {
//HashMap用来存储当前用户在游戏大厅的在线状态,
//对HashMap做进一步修改为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);
}
//通过用户id得到用户信息
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);
}
//通过用户id得到用户信息
public WebSocketSession getFromGameRoom(int userId){
return gameRoom.get(userId);
}
}
此时给 MatchAPI
注入 OnlineUserManager
。
创建 game.MatchRequest
类:
//表示一个websocket的匹配请求
@Data
public class MatchRequest {
private String message = "";
}
创建 game.MatchResponse
类:
//表示一个websocket的匹配响应
@Data
public class MatchResponse {
private boolean ok;
private String reason;
private String message = "";
}
实现 MatchAPI
中的afterConnectionEstablished
方法.
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
//玩家上线,就加入到onlineUserManger中
//1.先获取到当前用户的身份信息(谁在游戏大厅中,建立的连接)
//此处之所以能够getAttributes,就是因为在websocket的时候
//加上的.addHandler((WebSocketHandler) new HttpSessionHandshakeInterceptor());
//这个逻辑就把HttpSession中的Attributes都给拿到WebSocketSession中了
//在Http登录逻辑中,往HttpSession中存储了User数据 HttpSession.setAttributes("user",user);
//此时就可以在WebSocketSession中把之前HttpSession存储的User对象拿到了
//此处拿到的user是有可能为空的,如果用户就没有通过Http来登录,直接通过/game_hall.html
//这个URL来访问游戏大厅页面,此时就会出现user为null的情况
try {
//拿到用户信息
User user = (User) session.getAttributes().get("user");
//2.判断用户是否登录,如果已经登录,就不应该进行后续逻辑
WebSocketSession tmpSession = onlineUserManger.getFromGameHall(user.getUserId());
if (onlineUserManger.getFromGameHall(user.getUserId()) != null
|| onlineUserManger.getFromGameRoom(user.getUserId()) != null){
//当前用户已经登录了,这里你就不能重复登录
MatchResponse response = new MatchResponse();
response.setOk(true);
response.setReason("当前禁止一个账号重复登录!");
response.setMessage("repeatConnection");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
// session.close();
return;
}
//3.拿到了身份信息之后,就可以把玩家设置成在线状态
onlineUserManger.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)));
}
}
实现MatchAPI
中的 handleTextMessage
方法
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
//实现处理开始匹配请求和停止匹配请求
User user = (User) session.getAttributes().get("user");
//获取到客户端给服务器发送的数据
String payload = message.getPayload();//得到数据载荷对象
//当前的数据载荷是一个JSON格式的字符串,将其转换为Java对象
MatchRequest request = objectMapper.readValue(payload,MatchRequest.class);
MatchResponse response = new MatchResponse();
if (request.getMessage().equals("startMatch")){
//进入匹配队列
//TODO 先创建一个类表示匹配队列,把当前用户给加进去
matcher.add(user);
//把玩家放入匹配队列之后,就可以返回一个响应给客户端了
response.setOk(true);
response.setMessage("startMatch");
}else if (request.getMessage().equals("stopMatch")){
//退出匹配队列
//TODO 先创建一个类表示匹配队列,把当前用户从队列中移除
matcher.remove(user);
response.setOk(true);
response.setMessage("stopMatch");
}else {
//非法情况
response.setOk(false);
response.setMessage("非法的匹配请求!");
}
String jsonString = objectMapper.writeValueAsString(response);
session.sendMessage(new TextMessage(jsonString));
}
创建 game.Matcher
类.
在 Matcher 的构造方法中, 创建一个线程, 使用该线程扫描每个队列, 把每个队列的头两个元素取出来, 匹配到一组中.同时给上面的插入队列元素, 删除队列元素也加上锁.插入成功后要通知唤醒上面的等待逻辑.
// 这个类表示 "匹配器", 通过这个类负责完成整个匹配功能
@Component
public class Matcher {
// 创建三个匹配队列
private Queue<User> normalQueue = new LinkedList<>();
private Queue<User> highQueue = new LinkedList<>();
private Queue<User> veryHighQueue = new LinkedList<>();
@Autowired
private OnlineUserManger onlineUserManager;
@Autowired
private RoomManger roomManager;
private ObjectMapper objectMapper = new ObjectMapper();
// 操作匹配队列的方法.
// 把玩家放到匹配队列中
public void add(User user) {
if (user.getScore() < 2000) {
synchronized (normalQueue) {
normalQueue.offer(user);
normalQueue.notify();
}
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(normalQueue);
}
}
};
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();
}
实现Matcher
中的 handlerMatch
方法
handlerMatch
在单独的线程中调用. 因此要考虑到访问队列的线程安全问题. 需要加上锁.用while循环检查匹配队列中如果出现两个用户是,就将这两个用户从队列中取出,然后创建一个房间,使用房间管理器将这两个用户放在一个房间里,然后分别将两个玩家的信息进行反馈,通知玩家已经匹配到对手了。
private void handlerMatch(Queue<User> matchQueue) {
synchronized (matchQueue) {
try {
// 1. 检测队列中元素个数是否达到 2
// 队列的初始情况可能是 空
// 如果往队列中添加一个元素, 这个时候, 仍然是不能进行后续匹配操作的.
// 因此在这里使用 while 循环检查是更合理的~~
while (matchQueue.size() < 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) {
// 如果玩家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
类
OnlineUserManager
, 以备后面和客户端进行交互.此处不能采取注入的方式,通过入口类中的context来手动获取。public class Room {
//使用字符串类型来表示,方便生成唯一值
private String roomId;
private User user1;
private User user2;
private static final int MAX_ROW = 15;
private static final int MAX_COL = 15;
//创建ObjectMapper用来转换JSON
private ObjectMapper objectMapper = new ObjectMapper();
private OnlineUserManger onlineUserManger;
// 引入 RoomManager, 用于房间销毁
private RoomManger roomManager;
private UserMapper userMapper;
//那个玩家是先手(先手方的玩家id)
private int whiteUser;
public Room(){
//构造room的时候生成一个唯一的字符串来表示房间id
//使用UUID来作为房间id
roomId = UUID.randomUUID().toString();
//通过入口类中的context来手动获取前面的onlineUserManger和RoomManager
onlineUserManger = DemoApplication.context.getBean(OnlineUserManger.class);
roomManager = DemoApplication.context.getBean(RoomManger.class);
userMapper = DemoApplication.context.getBean(UserMapper.class);
}
此时在启动类DemoApplication 中加入context:
@SpringBootApplication
@MapperScan("com.example.demo.model")//指明扫描的Mapper路径
public class DemoApplication {
public static ConfigurableApplicationContext context;
public static void main(String[] args) {
context = SpringApplication.run(DemoApplication.class, args);
}
}
Room 对象会存在很多. 每两个对弈的玩家, 都对应一个 Room 对象.需要一个管理器对象来管理所有的 Room.
创建 game.RoomManager
@Component
public class RoomManger {
private ConcurrentHashMap<String,Room> rooms = new ConcurrentHashMap<>();
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);
}
public Room getRoomByUserId(int userId){
String roomId = userIdToRoomId.get(userId);
if (roomId == null){
//
return null;
}
return rooms.get(roomId);
}
}
写完房间管理器之后将RoomManger
注入到Matcher
中,并完善Matcher.handlerMatch
方法:实现创建房间,并将房间信息及用户信息通过房间管理器加入到房间中。
实现MatchAPI中的 afterConnectionClosed
方法:
主要的工作就是把玩家从 onlineUserManager 中退出.
退出的时候要注意判定, 如果玩家正在匹配中,而websocket连接断开了,就应该移除匹配,如果玩家当前在匹配队列中, 则直接从匹配队列里移除.
@Override
//断开连接
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
//玩家下线,从onlineUserManger中删除
try {
User user = (User) session.getAttributes().get("user");
WebSocketSession tmpSession = onlineUserManger.getFromGameHall(user.getUserId());
if (tmpSession == session){
onlineUserManger.exitGameHall(user.getUserId());
}
//如果玩家正在匹配中,而websocket连接断开了,就应该移除匹配
matcher.remove(user);
}catch (NullPointerException e){
System.out.println("[matchAPI.afterConnectionClosed] 当前用户还未登录");
}
}
实现 handleTransportError
. 逻辑同上.
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
//玩家上线,就从onlineUserManger中移除
try {
User user = (User) session.getAttributes().get("user");
WebSocketSession tmpSession = onlineUserManger.getFromGameHall(user.getUserId());
if (tmpSession == session){
onlineUserManger.exitGameHall(user.getUserId());
}
//如果玩家正在匹配中,而websocket连接断开了,就应该移除匹配
matcher.remove(user);
}catch (NullPointerException e){
System.out.println("[matchAPI.handleTransportError] 当前用户还未登录");
}
}
此时运行程序,即可验证匹配功能是否正常。
连接:
ws://127.0.0.1:8080/game
连接响应:当两个玩家都连接好了, 则给双方都返回一个数据表示就绪
{
message: 'gameReady', // 游戏就绪
ok: true, // 是否成功.
reason: '', // 错误原因
roomId: 'abcd', // 房间号. 用来辅助调试.
thisUserId: 1, // 玩家自己的 id
thatUserId: 2, // 对手的 id
whiteUser: 1, // 先手方的 id
}
落子请求:
{
message: 'putChess',
userId: 1,
row: 0,
col: 0
}
落子响应:
{
message: 'putChess',
userId: 1,
row: 0,
col: 0,
winner: 0
}
实现页面基本结构,创建 game_room.html
, 表示对战页面.
此处引入了 canvas 标签.这个是 HTML5 引入的 “画布”. 后续的棋盘和棋子的绘制, 就依赖这个画布功能.
#screen
用于显示当前的状态. 例如 “等待玩家连接中…”, “轮到你落子”, “轮到对方落子” 等.
DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>游戏房间title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/game_room.css">
head>
<body>
<div class="nav">五子棋对战div>
<div class="container">
<div>
<canvas id="chess" width="450px" height="450px">
canvas>
<div id="screen"> 等待玩家连接中... div>
div>
div>
<script src="js/script.js">script>
body>
html>
创建 css/game_room.css
:
#screen{
width: 450px;
height: 50px;
margin-top: 10px;
background-color: darkslateblue;
font-size: 22px;
line-height: 50px;
text-align: center;
}
创建 js/script.js
使用一个二维数组来表示棋盘. 虽然胜负是通过服务器判定的, 但是客户端的棋盘可以避免 “一个位置重复落子” 这样的情况
oneStep
函数起到的效果是在一个指定的位置上绘制一个棋子. 可以区分出绘制白字还是黑子. 参数是横坐标和纵坐标, 分别对应列和行.
用 onclick
来处理用户点击事件. 当用户点击的时候通过这个函数来控制绘制棋子.
me
变量用来表示当前是否轮到我落子. over
变量用来表示游戏结束.
这个代码中会用到一个背景图(sky.jpg), 放到 image 目录中即可.
在script中加入 websocket 的连接代码, 实现前后端交互.
创建 websocket 对象, 并注册 onopen/onclose/onerror
函数. 其中在 onerror 中做一个跳转到游戏大厅的逻辑. 当网络异常断开, 则回到大厅.
实现 onmessage 方法. onmessage 先处理游戏就绪响应.
修改 onclick 函数, 在落子操作时加入发送请求的逻辑.
注释掉原有的 onStep 和 修改 chessBoard 的操作, 放到接收落子响应时处理.
实现 send , 通过 websocket 发送落子请求.
在 initGame 中, 修改 websocket 的 onmessage.在 initGame 之前, 处理的是游戏就绪响应, 在收到游戏响应之后, 就改为接收落子响应了.在处理落子响应中要处理胜负手.
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
//
// 此处写的路径要写作 /game, 不要写作 /game/
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.ok) {
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;
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 = "#BFBFBF";
// 背景图片
let logo = new Image();
logo.src = "images/sky.jpeg";
logo.onload = function () {
context.drawImage(logo, 0, 0, 450, 450);
initChessBoard();
}
// 绘制棋盘网格
function initChessBoard() {
for (let i = 0; i < 15; i++) {
context.moveTo(15 + i * 30, 15);
context.lineTo(15 + i * 30, 430);
context.stroke();
context.moveTo(15, 15 + i * 30);
context.lineTo(435, 15 + i * 30);
context.stroke();
}
}
// 绘制一个棋子, me 为 true
function oneStep(i, j, isWhite) {
context.beginPath();
context.arc(15 + i * 30, 15 + j * 30, 13, 0, 2 * Math.PI);
context.closePath();
var gradient = context.createRadialGradient(15 + i * 30 + 2, 15 + j * 30 - 2, 13, 15 + i * 30 + 2, 15 + j * 30 - 2, 0);
if (!isWhite) {
gradient.addColorStop(0, "#0A0A0A");
gradient.addColorStop(1, "#636766");
} else {
gradient.addColorStop(0, "#D1D1D1");
gradient.addColorStop(1, "#F9F9F9");
}
context.fillStyle = gradient;
context.fill();
}
chess.onclick = function (e) {
if (over) {
return;
}
if (!me) {
return;
}
let x = e.offsetX;
let y = e.offsetY;
// 注意, 横坐标是列, 纵坐标是行
let col = Math.floor(x / 30);
let row = Math.floor(y / 30);
if (chessBoard[row][col] == 0) {
// 发送坐标给服务器, 服务器要返回结果
send(row, col);
// 留到浏览器收到落子响应的时候再处理(收到响应再来画棋子)
// oneStep(col, row, gameInfo.isWhite);
// chessBoard[row][col] = 1;
}
}
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("响应类型错误!");
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);
}
// 回到游戏大厅
// location.assign('/game_hall.html');
// 增加一个按钮, 让玩家点击之后, 再回到游戏大厅~
let backBtn = document.createElement('button');
backBtn.innerHTML = '回到大厅';
backBtn.onclick = function() {
location.replace('/game_hall.html');
}
let fatherDiv = document.querySelector('.container>div');
fatherDiv.appendChild(backBtn);
}
}
}
创建 api.GameAPI
, 处理 websocket
请求.
这里准备好一个 ObjectMapper
,同时注入一个 RoomManager
和OnlineUserMananger
还有UserMapper
。
@Component
public class GameAPI extends TextWebSocketHandler {
private ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private RoomManger roomManger;
@Autowired
private OnlineUserManger onlineUserManger;
@Resource
private UserMapper userMapper;
@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 {
}
}
此时要修改上面的 WebSocketConfig
, 将 GameAPI
进行注册.
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 通过 .addInterceptors(new HttpSessionHandshakeInterceptor() 这个操作来把 HttpSession 里的属性放到 WebSocket 的 session 中
// 参考: https://docs.spring.io/spring-framework/docs/5.0.7.RELEASE/spring-framework-reference/web.html#websocket-server-handshake
// 然后就可以在 WebSocket 代码中 WebSocketSession 里拿到 HttpSession 中的 attribute.
registry.addHandler(matchAPI, "/findMatch")
.addInterceptors(new HttpSessionHandshakeInterceptor());
registry.addHandler(gameAPI, "/game")
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
这部分内容要和约定的前后端交互接口匹配.
创建 game.GameReadyResponse
类:
@Data
public class GameReadyResponse {
private String message;
private boolean ok;
private String reason;
private String roomId;
private int thisUserId;
private int thatUserId;
private int whiteUser;
}
创建 game.GameRequest
类:
@Data
public class GameRequest {
private String message;
private int userId;
private int row;
private int col;
}
创建 game.GameResponse
类:
@Data
public class GameResponse {
private String message;
private int userId;
private int row;
private int col;
private int winner;
}
实现 GameAPI
的 afterConnectionEstablished
方法.
首先需要检测用户的登录状态. 从 Session 中拿到当前用户信息.
然后要判定当前玩家是否是在房间中.
接下来进行多开判定.如果玩家已经在游戏中, 则不能再次连接.
把两个玩家放到对应的房间对象中. 当两个玩家都建立了连接, 房间就放满了.这个时候通知两个玩家双方都准备就绪.
如果有第三个玩家尝试也想加入房间, 则给出一个提示, 房间已经满了.
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
GameReadyResponse resp = new GameReadyResponse();
// 1. 先获取到用户的身份信息. (从 HttpSession 里拿到当前用户的对象)
User user = (User) session.getAttributes().get("user");
if (user == null) {
resp.setOk(false);
resp.setReason("用户尚未登录!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
// 2. 判定当前用户是否已经进入房间. (拿着房间管理器进行查询)
Room room = roomManger.getRoomByUserId(user.getUserId());
if (room == null) {
// 如果为 null, 当前没有找到对应的房间. 该玩家还没有匹配到.
resp.setOk(false);
resp.setReason("用户尚未匹配到!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
// 3. 判定当前是不是多开 (该用户是不是已经在其他地方进入游戏了)
// 前面准备了一个 OnlineUserManager
if (onlineUserManger.getFromGameHall(user.getUserId()) != null
|| onlineUserManger.getFromGameRoom(user.getUserId()) != null) {
// 如果一个账号, 一边是在游戏大厅, 一边是在游戏房间, 也视为多开~~
resp.setOk(false);
resp.setReason("禁止多开游戏页面");
resp.setMessage("repeatConnection");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
return;
}
// 4. 设置当前玩家上线!
onlineUserManger.enterGameRoom(user.getUserId(), session);
// 5. 把两个玩家加入到游戏房间中.
// 前面的创建房间/匹配过程, 是在 game_hall.html 页面中完成的.
// 因此前面匹配到对手之后, 需要经过页面跳转, 来到 game_room.html 才算正式进入游戏房间(才算玩家准备就绪)
// 当前这个逻辑是在 game_room.html 页面加载的时候进行的.
// 执行到当前逻辑, 说明玩家已经页面跳转成功了!!
// 页面跳转(很有可能出现 "失败" 的情况的)
synchronized (room) {
if (room.getUser1() == null) {
// 第一个玩家还尚未加入房间.
// 就把当前连上 websocket 的玩家作为 user1, 加入到房间中.
room.setUser1(user);
// 把先连入房间的玩家作为先手方.
room.setWhiteUser(user.getUserId());
System.out.println("玩家 " + user.getUsername() + " 已经准备就绪! 作为玩家1");
return;
}
if (room.getUser2() == null) {
// 如果进入到这个逻辑, 说明玩家1 已经加入房间, 现在要给当前玩家作为玩家2了
room.setUser2(user);
System.out.println("玩家 " + user.getUsername() + " 已经准备就绪! 作为玩家2");
// 当两个玩家都加入成功之后, 就要让服务器, 给这两个玩家都返回 websocket 的响应数据.
// 通知这两个玩家说, 游戏双方都已经准备好了.
// 通知玩家1
noticeGameReady(room, room.getUser1(), room.getUser2());
// 通知玩家2
noticeGameReady(room, room.getUser2(), room.getUser1());
return;
}
}
// 6. 此处如果又有玩家尝试连接同一个房间, 就提示报错.
// 这种情况理论上是不存在的, 为了让程序更加的健壮, 还是做一个判定和提示.
resp.setOk(false);
resp.setReason("当前房间已满, 您不能加入房间");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}
private void noticeGameReady(Room room, User thisUser, User thatUser) throws IOException {
GameReadyResponse resp = new GameReadyResponse();
resp.setMessage("gameReady");
resp.setOk(true);
resp.setReason("");
resp.setRoomId(room.getRoomId());
resp.setThisUserId(thisUser.getUserId());
resp.setThatUserId(thatUser.getUserId());
resp.setWhiteUser(room.getWhiteUser());
// 把当前的响应数据传回给玩家.
WebSocketSession webSocketSession = onlineUserManger.getFromGameRoom(thisUser.getUserId());
webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(resp)));
}
下线的时候要注意针对多开情况的判定.
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
User user = (User) session.getAttributes().get("user");
if (user == null) {
// 此处就简单处理, 在断开连接的时候就不给客户端返回响应了.
return;
}
WebSocketSession exitSession = onlineUserManger.getFromGameRoom(user.getUserId());
if (session == exitSession) {
// 加上这个判定, 目的是为了避免在多开的情况下, 第二个用户退出连接动作, 导致第一个用户的会话被删除.
onlineUserManger.exitGameRoom(user.getUserId());
}
System.out.println("当前用户 " + user.getUsername() + " 游戏房间连接异常!");
// 通知对手获胜了
noticeThatUserWin(user);
}
给 Room
类里加上 RoomManager
实例 和 UserMapper
实例
Room 类内部要在游戏结束的时候销毁房间, 需要用到 RoomManager
Room 类内部要修改玩家的分数, 需要用到 UserMapper
实现 handleTextMessage
:
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
//1.先从session里面拿到当前用户的身份信息
User user = (User) session.getAttributes().get("user");
if (user == null){
System.out.println("[handleTextMessage:]当前玩家尚未登录!");
return;
}
//2.根据玩家id获取到房间对象
Room room = roomManger.getRoomByUserId(user.getUserId());
//3.通过room对象来处理具体的请求
room.putChess(message.getPayload());
}
实现 room 中的 putChess
方法.
先把请求解析成请求对象.
根据请求对象中的信息, 往棋盘上落子.
落子完毕之后, 为了方便调试, 可以打印出棋盘的当前状况.
检查游戏是否结束.
构造落子响应, 写回给每个玩家.
写回的时候如果发现某个玩家掉线, 则判定另一方为获胜.
如果游戏胜负已分, 则修改玩家的分数, 并销毁房间.
/**
* 处理一次落子操作
* 1.记录当前落子的位置
* 2.进行胜负判定
* 3.给客户端返回
* @param reqJson
*/
public void putChess(String reqJson) throws IOException {
GameRequest request = objectMapper.readValue(reqJson,GameRequest.class);
GameResponse response = new GameResponse();
//当前这个子是玩家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;
//打印棋盘信息
printBoard();
// 3. 进行胜负判定
int winner = checkWinner(row, col, chess);
//3.给房间中的所有客户端都返回响应
response.setMessage("putChess");
response.setUserId(request.getUserId());
response.setCol(col);
response.setRow(row);
response.setWinner(winner);
//要想给用户发送websocket数据,就需要获取到这个用户的websocketSession
WebSocketSession session1 = onlineUserManger.getFromGameRoom(user1.getUserId());
WebSocketSession session2 = onlineUserManger.getFromGameRoom(user2.getUserId());
//万一玩家已经下线了
if (session1 == null){
//玩家1已经下线,直接认为玩家2获胜
response.setWinner(user2.getUserId());
System.out.println("玩家1掉线");
}
if (session2 == null){
//玩家2已经下线,直接认为玩家1获胜
response.setWinner(user1.getUserId());
System.out.println("玩家2掉线");
}
//把响应构造成JSON字符串,通过session进行传输
String respJson = objectMapper.writeValueAsString(response);
if (session1 != null){
session1.sendMessage(new TextMessage(respJson));
}
if (session2 != null){
session2.sendMessage(new TextMessage(respJson));
}
//如果当前胜负已分,此时这个房间就失去存在的意义了,此时就可以销毁房间
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 r = 0; r < MAX_ROW; r++) {
for (int c = 0; c < MAX_COL; c++) {
// 针对一行之内的若干列, 不要打印换行
System.out.print(board[r][c] + " ");
}
// 每次遍历完一行之后, 再打印换行.
System.out.println();
}
System.out.println("=====================================================================");
}
实现胜负判定
如果游戏分出胜负, 则返回玩家的 id. 如果未分出胜负,则返回 0.
棋盘中值为 1 表示是玩家 1 的落子, 值为 2 表示是玩家 2 的落子.
检查胜负的时候, 以当前落子位置为中心, 检查所有相关的行,列, 对角线即可. 不必遍历整个棋盘.
// 使用这个方法来判定当前落子是否分出胜负.
// 约定如果玩家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++) {
// 针对其中的一种情况, 来判定这五个子是不是连在一起了~
// 不光是这五个子得连着, 而且还得和玩家落的子是一样~~ (才算是获胜)
try {
if (board[row][c] == chess
&& board[row][c + 1] == chess
&& board[row][c + 2] == chess
&& board[row][c + 3] == chess
&& board[row][c + 4] == chess) {
// 构成了五子连珠! 胜负已分!
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
// 如果出现数组下标越界的情况, 就在这里直接忽略这个异常.
continue;
}
}
// 2. 检查所有列
for (int r = row - 4; r <= row; r++) {
try {
if (board[r][col] == chess
&& board[r + 1][col] == chess
&& board[r + 2][col] == chess
&& board[r + 3][col] == chess
&& board[r + 4][col] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 3. 检查左对角线
for (int r = row - 4, c = col - 4; r <= row && c <= col; r++, c++) {
try {
if (board[r][c] == chess
&& board[r + 1][c + 1] == chess
&& board[r + 2][c + 2] == chess
&& board[r + 3][c + 3] == chess
&& board[r + 4][c + 4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 4. 检查右对角线
for (int r = row - 4, c = col + 4; r <= row && c >= col; r++, c--) {
try {
if (board[r][c] == chess
&& board[r + 1][c - 1] == chess
&& board[r + 2][c - 2] == chess
&& board[r + 3][c - 3] == chess
&& board[r + 4][c - 4] == chess) {
return chess == 1 ? user1.getUserId() : user2.getUserId();
}
} catch (ArrayIndexOutOfBoundsException e) {
continue;
}
}
// 胜负未分, 就直接返回 0 了.
return 0;
}
在 GameAPI 中
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
User user = (User) session.getAttributes().get("user");
if (user == null) {
// 此处就简单处理, 在断开连接的时候就不给客户端返回响应了.
return;
}
WebSocketSession exitSession = onlineUserManger.getFromGameRoom(user.getUserId());
if (session == exitSession) {
// 加上这个判定, 目的是为了避免在多开的情况下, 第二个用户退出连接动作, 导致第一个用户的会话被删除.
onlineUserManger.exitGameRoom(user.getUserId());
}
System.out.println("当前用户 " + user.getUsername() + " 游戏房间连接异常!");
// 通知对手获胜了
noticeThatUserWin(user);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
User user = (User) session.getAttributes().get("user");
if (user == null) {
// 此处就简单处理, 在断开连接的时候就不给客户端返回响应了.
return;
}
WebSocketSession exitSession = onlineUserManger.getFromGameRoom(user.getUserId());
if (session == exitSession) {
// 加上这个判定, 目的是为了避免在多开的情况下, 第二个用户退出连接动作, 导致第一个用户的会话被删除.
onlineUserManger.exitGameRoom(user.getUserId());
}
System.out.println("当前用户 " + user.getUsername() + " 离开游戏房间!");
// 通知对手获胜了
noticeThatUserWin(user);
}
private void noticeThatUserWin(User user) throws IOException {
// 1. 根据当前玩家, 找到玩家所在的房间
Room room = roomManger.getRoomByUserId(user.getUserId());
if (room == null) {
// 这个情况意味着房间已经被释放了, 也就没有 "对手" 了
System.out.println("当前房间已经释放, 无需通知对手!");
return;
}
// 2. 根据房间找到对手
User thatUser = (user == room.getUser1()) ? room.getUser2() : room.getUser1();
// 3. 找到对手的在线状态
WebSocketSession webSocketSession = onlineUserManger.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. 释放房间对象
roomManger.remove(room.getRoomId(), room.getUser1().getUserId(), room.getUser2().getUserId());
}