目录
- 主要功能介绍
- 项目创建
- 认识WebSocket
-
- 原理解析
- WebSocket报文格式
- 引入pom.xml依赖
- 数据库设计
-
- 配置 MyBatis
- 用户模块
-
- 服务器开发
- 客户端开发
- 登录界面的设计
- 注册界面设计
- 匹配模块的实现
-
- 客户端开发
- 服务器开发
- 创建并注册 `MatchAPI` 类
- 实现用户管理器
- 创建匹配的请求响应对象
- 处理连接成功
- 处理开始匹配/取消匹配请求
- 实现匹配器(1)
- 创建房间类
- 实现房间管理器
- 处理连接关闭
- 处理连接异常
- 实现对战模块
-
- 定义前后端交互接口
- 客户端开发
- 实现棋盘/棋子绘制
- 服务器开发
- 创建并注册 GameAPI 类
- 创建落子请求/响应对象
- 处理连接成功
- 玩家下线的处理
- 处理落子请求
- 实现对弈功能(1)
- 实现对弈功能(2)
- 实现对弈功能(3)
- 处理玩家中途退出
主要功能介绍
- 用户模块:
- 匹配模块
- 对战模式
所用技术栈:
后端:Spring、Spring Boot、Spring MVC
前端:HTML、CSS、JS、AJAX
数据库:MySQL、MyBatis
WebSocket
项目创建
认识WebSocket
WebSocket
是从 HTML5
开始支持的一种网页端和服务端保持长连接的 消息推送机制.
WebSocket
是实现消息推送的主要机制。像五子棋这样的程序, 或者聊天这样的程序, 都是非常依赖 “消息推送” 的. 如果只是使用原生的 HTTP 协议, 要想实现消息推送一般需要通过 “轮询” 的方式.轮询的成本比较高, 而且也不能及时的获取到消息的响应.而 WebSocket 则是更接近于 TCP 这种级别的通信方式. 一旦连接建立完成, 客户端或者服务器都可以主动的向对方发送数据.
原理解析
WebSocket
协议本质上是一个基于 TCP
的协议。为了建立一个 WebSocket
连接,客户端浏览器首先要向服务器发起一个 HTTP 请求,这个请求和通常的 HTTP 请求不同,包含了一些附加头信息,通过这个附加头信息完成握手过程.
WebSocket报文格式
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 给服务器发送落子的用户信息和落子位置, 同时服务器再将这样的信息返回给房间内的双方客户端. 然后客户端根据服务器的响应来绘制棋子位置.
胜负: 服务器判定这一局游戏的胜负关系. 如果某一方玩家落子, 产生了五子连珠, 则判定胜负并返回胜负信息. 或者如果某一方玩家掉线(比如关闭页面), 也会判定对方获胜.
引入pom.xml依赖
<?xml version="1.0" encoding="UTF-8"?>
<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.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.7.2</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>com.example</groupId><artifactId>demo</artifactId><version>0.0.1-SNAPSHOT</version><name>demo</name><description>Demo project for Spring Boot</description><properties><java.version>1.8</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency><dependency><groupId>org.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>2.2.2</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-devtools</artifactId><scope>runtime</scope><optional>true</optional></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><scope>runtime</scope></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><excludes><exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></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);
配置 MyBatis
编辑 application.yml
如下:
# 配置数据库的连接字符串
spring:datasource:url: jdbc:mysql://127.0.0.1:3306/java_gobang?characterEncoding=utf8username: rootpassword: 123456driver-class-name: com.mysql.cj.jdbc.Driver
# 设置 Mybatis 的 xml 保存路径
mybatis:mapper-locations: classpath:mapper/**Mapper.xmlconfiguration: # 配置打印 MyBatis 执行的 SQLlog-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,天梯分数+30void userWin(int userId);//失败方 总比赛场数+1,获胜场数不变,天梯分数-30void userLose(int userId);
}
在resource下面创建一个mapper文件,并在该文件下创建一个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.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 + 30where userId = #{userId}</update><update id="userLose">update user set totalCount = totalCount + 1,score = score - 30where 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 {@Resourceprivate UserMapper userMapper;/*** 登录* @param username* @param password* @param request* @return*/@RequestMapping("/login")@ResponseBodypublic 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")@ResponseBodypublic 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")@ResponseBodypublic 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 不存在, 则返回 falsereason: '', // 错误原因message: 'startMatch' / 'stopMatch'
}
匹配响应2:这个是真正匹配到对手之后,服务器主动推送回来的消息。
{ok: true, // 是否成功. 比如用户 id 不存在, 则返回 falsereason: '', // 错误原因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在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+ "<br> 比赛场次: " + 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();@Autowiredprivate OnlineUserManager onlineUserManager;@Componentpublic class MatchAPI extends TextWebSocketHandler {}@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {}@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {}@Overridepublic 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;@Autowiredprivate MatchAPI matchAPI;@Autowiredprivate GameAPI gameAPI;@Overridepublic 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 从而给客户端回话.
- 当玩家建立好 websocket 连接, 则将键值对加入
OnlineUserManager
中. - 当玩家断开 websocket 连接, 则将键值对从
OnlineUserManager
中删除. - 在玩家连接好的过程中, 随时可以通过 userId 来查询到对应的会话, 以便向客户端返回数据.
由于存在两个页面, 游戏大厅和游戏房间, 使用两个 哈希表 来分别存储两部分的会话:
@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
方法.
- 通过参数中的 session 对象, 拿到之前登录时设置的 User 信息.
- 使用 onlineUserManager 来管理用户的在线状态.
- 先判定用户是否是已经在线, 如果在线则直接返回出错 (禁止同一个账号多开).
- 设置玩家的上线状态.
@Overridepublic 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
方法
- 先从会话中拿到当前玩家的信息.
- 解析客户端发来的请求
- 判定请求的类型, 如果是 startMatch, 则把用户对象加入到匹配队列. 如果是 stopMatch, 则把用户对象从匹配队列中删除.
此处需要实现一个 匹配器 对象, 来处理匹配的实际逻辑.
@Overrideprotected 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));}
实现匹配器(1)
创建 game.Matcher
类.
- 在 Matcher 中创建三个队列 (队列中存储 User 对象), 分别表示不同的段位的玩家. (此处约定 <2000 一档, 2000-3000 一档, >3000 一档)
- 提供 add 方法, 供 MatchAPI 类来调用, 用来把玩家加入匹配队列.
- 提供 remove 方法, 供 MatchAPI 类来调用, 用来把玩家移出匹配队列.
- 同时 Matcher 找那个要记录 OnlineUserManager, 来获取到玩家的 Session.
在 Matcher 的构造方法中, 创建一个线程, 使用该线程扫描每个队列, 把每个队列的头两个元素取出来, 匹配到一组中.同时给上面的插入队列元素, 删除队列元素也加上锁.插入成功后要通知唤醒上面的等待逻辑.
// 这个类表示 "匹配器", 通过这个类负责完成整个匹配功能
@Component
public class Matcher {// 创建三个匹配队列private Queue<User> normalQueue = new LinkedList<>();private Queue<User> highQueue = new LinkedList<>();private Queue<User> veryHighQueue = new LinkedList<>();@Autowiredprivate OnlineUserManger onlineUserManager;@Autowiredprivate 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() {@Overridepublic void run() {// 扫描 normalQueuewhile (true) {handlerMatch(normalQueue);}}};t1.start();Thread t2 = new Thread(){@Overridepublic void run() {while (true) {handlerMatch(highQueue);}}};t2.start();Thread t3 = new Thread() {@Overridepublic void run() {while (true) {handlerMatch(veryHighQueue);}}};t3.start();}
实现Matcher
中的 handlerMatch
方法
- 由于
handlerMatch
在单独的线程中调用. 因此要考虑到访问队列的线程安全问题. 需要加上锁. - 每个队列分别使用队列对象本身作为锁即可.
- 在入口处使用 wait 来等待, 直到队列中达到 2 个元素及其以上, 才唤醒线程消息队列
用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
类
- 一个房间要包含一个房间 ID, 使用 UUID 作为房间的唯一身份标识.
- 房间内要记录对弈的玩家双方信息.
- 记录先手方的 ID
- 记录一个 二维数组 , 作为对弈的棋盘.
- 记录一个
OnlineUserManager
, 以备后面和客户端进行交互.此处不能采取注入的方式,通过入口类中的context来手动获取。 - ObjectMapper 来处理 json。
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用来转换JSONprivate ObjectMapper objectMapper = new ObjectMapper();private OnlineUserManger onlineUserManger;// 引入 RoomManager, 用于房间销毁private RoomManger roomManager;private UserMapper userMapper;//那个玩家是先手(先手方的玩家id)private int whiteUser;public Room(){//构造room的时候生成一个唯一的字符串来表示房间id//使用UUID来作为房间idroomId = UUID.randomUUID().toString();//通过入口类中的context来手动获取前面的onlineUserManger和RoomManageronlineUserManger = 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
- 使用一个 Hash 表, 保存所有的房间对象, key 为 roomId, value 为 Room 对象 再使用一个 Hash 表,保存 userId -> roomId 的映射, 方便根据玩家来查找所在的房间.
- 提供增, 删, 查的 API. (查包含两个版本, 基于房间 ID 的查询和基于用户 ID 的查询).
@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
. 逻辑同上.
@Overridepublic 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, // 玩家自己的 idthatUserId: 2, // 对手的 idwhiteUser: 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 进行实现 --><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 为 truefunction oneStep(i, j, isWhite) {context.beginPath();context.arc(15 + i * 30, 15 + j * 30, 13, 0, 2 * Math.PI);context.closePath();var gradient = context.createRadialGradient(15 + i * 30 + 2, 15 + j * 30 - 2, 13, 15 + i * 30 + 2, 15 + j * 30 - 2, 0);if (!isWhite) {gradient.addColorStop(0, "#0A0A0A");gradient.addColorStop(1, "#636766");} else {gradient.addColorStop(0, "#D1D1D1");gradient.addColorStop(1, "#F9F9F9");}context.fillStyle = gradient;context.fill();}chess.onclick = function (e) {if (over) {return;}if (!me) {return;}let x = e.offsetX;let y = e.offsetY;// 注意, 横坐标是列, 纵坐标是行let col = Math.floor(x / 30);let row = Math.floor(y / 30);if (chessBoard[row][col] == 0) {// 发送坐标给服务器, 服务器要返回结果send(row, col);// 留到浏览器收到落子响应的时候再处理(收到响应再来画棋子)// 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);}}
}
服务器开发
创建并注册 GameAPI 类
创建 api.GameAPI
, 处理 websocket
请求.
这里准备好一个 ObjectMapper
,同时注入一个 RoomManager
和OnlineUserMananger
还有UserMapper
。
@Component
public class GameAPI extends TextWebSocketHandler {private ObjectMapper objectMapper = new ObjectMapper();@Autowiredprivate RoomManger roomManger;@Autowiredprivate OnlineUserManger onlineUserManger;@Resourceprivate UserMapper userMapper;@Overridepublic void afterConnectionEstablished(WebSocketSession session) throws Exception {}@Overrideprotected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {}@Overridepublic void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {}@Overridepublic 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 中拿到当前用户信息.
然后要判定当前玩家是否是在房间中.
接下来进行多开判定.如果玩家已经在游戏中, 则不能再次连接.
把两个玩家放到对应的房间对象中. 当两个玩家都建立了连接, 房间就放满了.这个时候通知两个玩家双方都准备就绪.
如果有第三个玩家尝试也想加入房间, 则给出一个提示, 房间已经满了.
@Overridepublic 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. 判定当前是不是多开 (该用户是不是已经在其他地方进入游戏了)// 前面准备了一个 OnlineUserManagerif (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 的响应数据.// 通知这两个玩家说, 游戏双方都已经准备好了.// 通知玩家1noticeGameReady(room, room.getUser1(), room.getUser2());// 通知玩家2noticeGameReady(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)));}
玩家下线的处理
下线的时候要注意针对多开情况的判定.
@Overridepublic 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
:
@Overrideprotected 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());}
实现对弈功能(1)
实现 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数据,就需要获取到这个用户的websocketSessionWebSocketSession 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());}}
实现对弈功能(2)
实现打印棋盘的逻辑
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("=====================================================================");}
实现对弈功能(3)
实现胜负判定
如果游戏分出胜负, 则返回玩家的 id. 如果未分出胜负,则返回 0.
棋盘中值为 1 表示是玩家 1 的落子, 值为 2 表示是玩家 2 的落子.
检查胜负的时候, 以当前落子位置为中心, 检查所有相关的行,列, 对角线即可. 不必遍历整个棋盘.
// 使用这个方法来判定当前落子是否分出胜负.// 约定如果玩家1 获胜, 就返回玩家1 的 userId// 如果玩家2 获胜, 就返回玩家2 的 userId// 如果胜负未分, 就返回 0private 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 中
@Overridepublic 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);}@Overridepublic 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());}