网页版五子棋对战
1.用户模块
用户的注册和登录
管理用户的天梯分数、比赛场数、获胜场数等信息
2.匹配模块
依据用户的天梯积分,实现匹配机制
3.对战模块
把两个匹配到的玩家放到一个游戏房间中,双方通过网页的形式来进行对战比赛
用到的关键技术点:
Java、Spring/Spring Boot/Spring MVC、HTML/CSS/AJAX、MySQL/MyBatis、WebSocket
我们之前学习过的服务器开发,主要是这样的模型:
客户端主动向服务器发起请求,服务器收到之后,返回一个响应。
如果客户端不主动发起请求,服务器是不能主动联系客户端的
我们是否需要,服务器主动给客户端发消息这样的场景呢?
需要!!“消息推送”
当前已有的知识,主要是HTTP.HTTP自身难以实现这种消息推送效果的
HTTP要想实现类似的效果,就需要基于“轮询”的机制
很明显,像这样的轮询操作,开销是比较大的,成本也是比较高的
如果轮询间隔时间长,玩家1落子之后,玩家2不能及时拿到结果
如果轮询间隔时间短,虽然即使性得到改善,但是玩家2 不得不浪费更多的机器资源(尤其是带宽)
因此,websocket就是一个消息推送机制
websocket报文格式
websocket也是一个应用层的协议,下层是基于TCP的~
opcode描述了当前这个websocket报文是啥类型
表示当前这是一个文本帧还是一个二进制帧
表示当前这是一个ping帧,还是一个pong帧
payload len含义表示的是当前数据报携带的数据载荷的长度。这个字段本身就是一个变长的,一个websocket数据报能承载的载荷长度是非常长的
websocket握手过程(建立连接的过程)
使用网页端,尝试和服务器建立websocket连接
网页端就会先给服务器发起一个HTTP请求 这个HTTP请求中会带有特殊的Header
Connection:Upgrade
Upgrade:Websocket
这两个header其实就是告知服务器,我们要进行协议升级
如果服务器支持websocket,就会返回一个特殊的HTTP响应 这个响应的状态码是101(切换协议)
客户端和服务器之间就开始使用websocket来进行通信了
实现一个简单的websocket代码
编写服务器端(Java)
编写客户端(JS)
通过TestAPI重写了几个类,
光有这几个类还不够 需要把这几个类关联到路径
用户模块
完成注册登录,以及用户分数管理
使用数据库来保存上述用户信息
使用MyBatis来连接并操作数据库
1.修改Spring的配置文件,使数据库可以被连接上(application.yml)
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/java_gobang?characterEncoding=utf8&useSSL=false
username: root
password: zy19991227
driver-class-name: com.mysql.cj.jdbc.Driver
mybatis:
mapper-locations: classpath:mapper/**Mapper.xml
2.创建实体类。用户 User
3.创建Mapper接口
针对数据库进行哪些具体的操作
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yvvSwfi5-1688815713045)(C:\Users\zyx\AppData\Roaming\Typora\typora-user-images\image-20230329102600202.png)]
4.实现MyBatis的相关xml配置文件,来自动实现数据库操作
约定前后端交互接口
登录的请求和相应
请求
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: 0,
winCount: 0
}
如果登录失败,就返回一个无效的user对象。
如果这里的每个属性都是空的,像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: 0,
winCount: 0
}
这个前后端交互的接口,在约定的时候,是有很多种交互方式的
这里约定好了之后,后续的前端或后端代码,都要严格地遵守这个约定来写代码
从服务器获取到当前登录的用户信息的请求和响应
程序运行过程中,用户登陆了之后,让客户端随时通过这个接口,来访问服务器,获取自身的信息
请求
GET/userInfo HTTP/1.1
响应
HTTP/1.1 200 OK
Content-Type: application/json
{
userId: 1,
username:'zhangsan',
score: 1000,
totalCount: 0,
winCount: 0
}
编写服务器代码
package com.example.java_gobang.api;
import com.example.java_gobang.model.User;
import com.example.java_gobang.model.UserMapper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletMapping;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@RestController
public class UserAPI {
@Resource
private UserMapper userMapper;
@PostMapping("/login") //请求使用的是POST
@ResponseBody //将java对象转为json格式的数据
public Object login(String username, String password, HttpServletRequest req){
//关键操作:根据username去数据库中进行查询
//如果能找到匹配的用户,并且密码也一致,就认为登陆成功
User user = userMapper.selectByName(username);
System.out.println("[login] user=" + username);
if(user == null || !user.getPassword().equals(password)){
//登陆失败
System.out.println("登陆失败");
return new User();//无效对象
}
HttpSession httpSession = req.getSession(true);
//参数true的含义:会话存在直接返回,会话不存在就创建一个
//参数false的含义:会话存在直接返回,会话不存在就返回空
httpSession.setAttribute("user",user);
return user;
}
@PostMapping("/register")
@ResponseBody
public Object register(String username,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;
}
}
@GetMapping("/userInfo")
@ResponseBody
public Object getUserInfo(HttpServletRequest req) {
try {
HttpSession httpSession = req.getSession(false);
User user = (User) httpSession.getAttribute("user");
return user;
} catch (NullPointerException e){
return new User();
}
}
}
使用Postman测试登录:
用户名和密码都正确
用户名和密码不正确(返回空值)
使用Postman测试注册:
使用Postman测试用户信息:
编写 登录/注册 功能的前端页面
login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录</title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/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>
</body>
</html>
common.css
/* 公共样式*/
/*去除浏览器原有样式*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,body{
height: 100%;
background-image: url(../image/cat.jpg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.nav {
height: 50px;
background-color: rgba(5, 5, 28,0.7);
color: white;
line-height: 50px;
padding-left: 20px;
}
login.css
.login-container {
height: calc(100% - 50px);
display: flex;
justify-content: center;
align-items: center;
}
.login-dialog {
width: 400px;
height: 400px;
background-color: rgba(255,255,255,0.8);
border-radius: 10px;
}
/*标题*/
.login-dialog h3 {
text-align: center;
padding: 50px 0;
}
/*针对一行操作样式*/
.login-dialog .row {
width: 100%;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.login-dialog .row span {
width: 100px;
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: rgb(0,129,0);
color: whitesmoke;
border: none;
outline: none;
border-radius: 10px;
margin-top: 20px;
}
#submit:active {
background-color: rgb(6,6,6);
}
实现登录的具体过程
使用ajax,使页面和服务器之间进行交互
<script src="./js/jquery.min.js"></script>
<script>
// 通过 ajax 的方式实现登录过程
let submitButton = document.querySelector('#submit');
submitButton.onclick = function() {
// 1. 先获取到用户名和密码
let username = document.querySelector('#username').value;
let password = document.querySelector('#password').value;
$.ajax({
method: 'post',
url: '/login',
data: {
username: username,
password: password
},
success: function(data) {
console.log(JSON.stringify(data));
if (data && data.userId > 0) {
// 登录成功, 跳转到游戏大厅
alert("登录成功!")
location.assign('/game_hall.html');
} else {
alert("登录失败! 用户名密码错误! 或者该账号正在游戏中!");
}
}
});
}
</script>
实现注册的具体过程
使用ajax,使页面和服务器之间进行交互
<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>
匹配模块
让多个用户,在游戏大厅中能够进行匹配,系统会把实力相近的两个玩家凑成一桌,进行对战
约定前后端交互接口
玩家发送匹配请求,这个事情是确定(点击了匹配按钮,就会发送匹配请求)服务器啥时候告知玩家匹配结果(到底排到了谁)
需要等待匹配结束的时候才告知
正因为服务器自己也不知道啥时候能够告知玩家匹配的结果,因此就需要依赖消息推送机制当服务器这里匹配成功之后,就主动的告诉当前排到的玩家‘你排到了
接下来约定的前后端交互接口,也是基于websocket来展开的
websocket可以传输文本数据,也能传输二进制数据
此处就直接设计成让websocket传输json格式的方式即可
匹配请求:
客户端通过websocket给服务器发送一个json格式的文本数据
ws://127.0.0.1:8080/findMatch
{
message:'startMatch' / 'stopMatch', //开始或结束匹配
}
在通过websocket传输请求数据的时候,数据中是不必带有用户身份信息的
当前用户的身份信息,在前面登陆完成后,就已经保存到HttpSession中了
websocket里,也是能拿到之前登录好的HttpSession中的信息的
匹配响应1:
ws://127.0.0.1:8080/findMatch
{
ok:true, //匹配成功
reason:'',//匹配如果失败,失败原因的信息
message: 'startMatch' / 'stopMatch',
}
这个响应是客户端给服务器发送匹配请求之后,服务器立即返回的匹配响应
匹配响应2:
ws://127.0.0.1:8080/findMatch
{
ok:true, //匹配成功
reason:'',//匹配如果失败,失败原因的信息
message: 'matchSuccess',
}
这个响应是真正匹配到对手之后,服务器主动推送回来的消息
匹配到的对手不需要在这个响应中体现,仍然都放到服务器这边来保存即可
匹配页面(游戏大厅页面)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>游戏大厅</title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css/game_hall.css">
</head>
<body>
<div class="nav">五子棋对战</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("获取用户信息失败!");
}
});
</script>
</body>
</html>
.container {
width: 100%;
height: calc(100% - 50px);
display: flex;
align-items: center;
justify-content: center;
}
#screen {
width: 400px;
height: 200px;
font-size: 20px;
background-color: gray;
color: white;
border-radius: 10px;
text-align: center;
line-height: 100px;
}
#match-button {
width: 400px;
height: 50px;
font-size: 20px;
color: white;
background-color: orange;
border: none;
outline: none;
border-radius: 10px;
text-align: center;
line-height: 50px;
margin-top: 20px;
}
#match-button:active {
background-color: bisque;
}
![在这里插入图片描述\
JSON字符串和JS对象的转换
JSON字符串转成JS对象
JSON.parse
JS对象转成JSON字符串
JSON.stringify
JSON字符串和Java对象的转换
JSON字符串转成Java对象
ObjectMapper.readValue
Java对象转成JSON字符串
ObjectMapper.writerValueAsString
在注册websocket API的时候,就需要把前面准备好的HttpSession给搞过来(搞到Websocket的Session中!)
用户登录就会给HttpSession中保存用户的信息
此处需要能够保存和表示用户上线和下线的状态
之所以要维护用户的在线状态,目的就是为了能够在代码中比较方便的获取到某个用户
当前的websocket会话,从而可以通过这个会话来给这个客户端发送消息,同时也可以感知到他的在线/离线状态
使用哈希表来保存当前用户的在线状态
key就是用户id
value就是用户当前使用的websocket会话
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
先通过ObjectMapper
把MatchResponse
对象转成JSON
字符串,然后再包装上一层TextMessage
再进行传输
其中TextMessage
就表示一个文本格式的websocket
数据包
当前是使用HashMap来存储用户的在线状态
如果是多线程访问同一个HashMap就容易出现线程安全问题
如果同时有多个用户和服务器连接/断开连接,此时服务器就是并发的针对HashMap进行修改
private ConcurrentHashMap<Integer, WebSocketSession> gameHall = new ConcurrentHashMap<>();
多开问题
当浏览器1建立websocket请求时,服务器这边会在OnlineUserManager中保存键值对:userId=1,WebSocketSession=session1
当浏览器2建立websocket请求时,服务器这边会在OnlineUserManager中保存键值对:userId=1,WebSocketSession=session2
这两次连接,尝试往哈希表中存储两个键值对,这两个键值对的key是一样的 后来的value会覆盖之前value
上述这种覆盖,就会导致第一个浏览器的连接“名存实亡”已经拿不到对应的WebSocketSession了,也就无法给这个浏览器推送数据了
多开会产生上述问题,我们的程序是否应该允许多开呢?
对于大部分游戏来说,都是不行的!都是禁止多开的,禁止同一个账号在不同的主机上登录!
因此我们呢要做的,不是直接解决会话覆盖的问题,而是从源头上禁止游戏多开!
1)账号登陆成功之后,禁止在其他地方再登录(采用这种方法)
2)账号登陆之后,后续其他位置的登录会把前面的登录给踢掉
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
//玩家上线,加入到OnlineUserManager中
//1.先获取到当前用户的身份信息(谁在游戏大厅中建立连接)
// 此处的代码,之所以能够getAttributes 全靠了在注册Websocket的时候
// 加上的 .addInterceptors(new HttpSessionHandshakeInterceptor());
// 这个逻辑 就把 HttpSession 中的 Attribute 都给拿到 webSocketSession 中了
// 在Http登录逻辑中,往HttpSession中存了User数据 httpSession.setAttribute("user",user);
// 此时就可以在webSocketSession中把之前 HttpSession中存的User对象给拿到了
// 注意。此处的user是有可能为空的!!
// 如果之前用户压根就没有通过HTTP来进行登录,直接就通过/game_hall.html这个url来访问游戏大厅页面
// 此时就会出现user为null的情况
try {
User user = (User) session.getAttributes().get("user");
//2.先判定当前用户是否已经登陆过(已经是在线状态),如果是已经在线,就不该继续进行后续逻辑
WebSocketSession tmpSession = onlineUserManager.getFromGameHall(user.getUserId());
if(tmpSession != null) {
//当前用户已经登陆了
//针对这个情况要告知客户端,你这里重复登陆了
MatchResponse response = new MatchResponse();
response.setOk(false);
response.setReason("当前禁止多开!");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
session.close();
return;
}
//3.拿到了身份信息之后,就可以把玩家设置成上线状态了
onlineUserManager.enterGameHall(user.getUserId(), session);
System.out.println("玩家"+ user.getUsername() +"进入游戏大厅");
}catch (NullPointerException e){
e.printStackTrace();
//出现空指针异常,说明当前用户的身份信息为空
//把当前用户尚未登陆这个信息返回
MatchResponse response = new MatchResponse();
response.setOk(false);
response.setReason("您尚未登录!不能进行后续匹配");
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(response)));
}
}
在连接建立逻辑这里,做出了判定;如果玩家已经登陆过,就不能再登录,同时关闭websocket
连接
websocket
连接关闭的过程中,也会触发afterConnectionClosed
在这个方法里,会有一个exitGameHall
匹配模块的目标
从带匹配的玩家中,选出分数尽量相近的的玩家
把所有玩家按照分数,分为三类:
Normal score<2000
High score>=2000 &&score<3000
VeryHigh score>=3000
给这三个等级,分配三个不同的队列
根据当前玩家的分数,来把这个玩家的用户信息,放到对应的队列里
接下来在搞一个专门的线程,去不停的扫描这个匹配队列
只要说队列里的元素(匹配中的玩家)凑成了一对,把这一对玩家取出来,放到一个游戏房间中
线程安全问题
入队列:
取元素:
删除元素:
使用到多线程的代码时,一定要时刻注意“线程安全”问题
使用sunchronized
进行加锁
需要指定一个锁对象,到底针对谁进行加锁?只有多个线程在尝试针对同一个锁对象进行加锁的时候,才会有互斥效果
此处我们进行加锁的时候,如果多个线程访问的是不同的队列,不涉及线程安全问题。必须得是多个线程操作同一个队列,才需要加锁
因此在加锁的时候选取的锁对象,就是normalQueue,highQueue,veryHighQueue这三个队列对象本身
如果当前匹配队列中,就只有一个元素,或者没有元素,会出现什么效果?
在这个代码中,就会出现handlerMatch
一进入方法就会快速返回,然后再次进入方法 循环速度飞快 但是却没有实质的意义。这个过程中CPU占用率会非常高(忙等)
在调用完handlerMatch
之后,加上sleep(500)
这个方案确实可以,但是当有玩家匹配到之后,可能要500ms之后才能正在得到匹配的返回结果
通过sleep
难以两全齐美,要么让玩家多等,要么让CPU多转
因此我们使用wait/notify
当真正有玩家进入匹配队列之后,就调用notify
来唤醒线程
设计游戏房间管理
一个游戏服务器上,又同时存在了多个游戏房间~
需要一个“游戏房间管理器”管理多个游戏房间~
键值对,给每个room也生成一个唯一的roomId~
以键值对(哈希表)在room manager中进行管理
UUID
表示“世界上唯一的身份标识”
通过一系列的算法,能够生成一串字符串(一组十六进制表示的数字)
两次调用这个算法,生成的这个字符串都是不相同的
任意次调用,每次得到的结果都不相同
UUID
内部具体如何实现的(算法实现细节)不去深究 Java中有现成的类
关于RoomManager
希望能够根据房间id找到房间对象,也希望能够根据玩家id,找到玩家所属的房间
通过调试目前代码 发现问题:
问题1:
当前发现玩家点击匹配之后,匹配按钮的文本不发生改变
分析之前写过的代码,点击按钮的时候,仅仅是给服务器发送了websocket请求,告诉服务器我要开始匹配了~
服务器会立即返回一个响应,“进入匹配队列成功”,然后页面再修改按钮的文本
出现问题的原因:
服务器这边在处理匹配请求时,按理说,要立即返回一个**websocket
**响应
实际上在服务器代码这里构造了响应对象,但是忘记sendMessage
给发回去了
解决方法:
在MatchAPI
中加入如下两行代码
String jsonString = objectMapper.writeValueAsString(response);
session.sendMessage(new TextMessage(jsonString));
验证匹配功能的时候,模拟多个用户登录的情况,最好使用多个浏览器,避免同一个浏览器中的cookie/session信息干扰
问题2:
出现异常
检查发现原因:
在创建objectMapper时,未对其进行实例化
更改之后 出现404:
正常情况 因此我们目前还未创建 127.0.0.1:8080/game_room.html
验证多开处理
可以看到我们使用两个浏览器登录zhangsan的账号后,页面上并没有什么提示"多开"响应 仅仅是打开控制台才能看到
但是在第二个浏览器窗口点击”开始匹配“按钮时,会显示 连接已断开请重新登录
当前我们虽然能够禁止一个账户的多开效果(主要是禁止在多个客户端进行匹配),但是在界面上没有一个明确的提示
此处需要调整前端代码,出现多开时,给客户一个明显的提示
更改之后,在第二次登录zhangsan账户时,会立马显示”当前和服务器的连接已经断开!请重新登录!“,并跳转回登录界面
另外,这里修改了js代码,再刷新页面的时候要使用ctrl+f5 强制刷新,否则应用的还是旧版本的js代码
匹配模块小结
-
点击开始匹配之后
1)先触发js中的按钮的点击事件回调
这里会发送一个websocket
的请求给服务器
2)服务器处理这个匹配请求
此处的payload数据就是上面的websocket
发送的JSON
数据,将客户端发送的数据,服务端读了出来,然后对其进行一个解析,解析为一个MatchRequest
对象,这个对象中就包含了一个关键的字段
拿message里面的内容进行判断,看是startMatch
还是stopMatch
使用matcher.add(user)
把玩家加入到匹配队列中
服务器立即给客户端返回一个响应,告知客户端,已经把用户加入到匹配队列中
3)客户端收到服务器返回的响应之后,就会立即进行处理
其中resp
就是上面我们所受到的response
对象
4)匹配器的处理
由于当前只有一个玩家,点击了开始匹配,此时队列中也就只有一个元素 因此扫描线程 会在wait处堵塞
5)此时又有一个玩家也点击了匹配操作
这里的流程同刚才的123一样
当又有一个玩家点击匹配之后,就会从匹配队列中的wait中返回 于是继续执行匹配逻辑
匹配器匹配到多个玩家就会创建一个房间 把房间加入到房间管理器中
给两个哈希表中都去添加键值对的内容
添加三组映射:
1.房间ID到房间对象的映射
2.玩家1的ID到房间ID的映射
3.玩家2的ID到房间ID的映射
对战模块
约定好前后端交互的接口
对战模块和匹配模块使用的是两套逻辑,使用的是不同的websocket的路径进行处理,可以做到更好的解耦合~
建立连接
ws://127.0.0.1:8080/game
建立连接响应
服务器要生成一些游戏的初始信息,通过这个响应告诉客户端
{
message: 'gameReady', //消息的类别是游戏就绪
ok: true,
reason:'',
roomId:'12345678', //玩家所处在的房间id
thisUserId: 1, //玩家自己的id
thatUserId: 2 //玩家对手的id
whiteUser:1 //那个玩家执白子(先手)
}
这些都是玩家匹配成功之后,要有服务器生成的内容,把这个内容返回到浏览器中
针对“落子”的请求和响应
请求
此处更建议大家使用行和列 而不是坐标x和y
后面的代码中需要使用二维数组来表示这个棋盘 通过下表取二维数组元素[row][col]
如果使用x,y [y][x]
感觉比较奇怪
{
message: 'putChess',
userId:1,
row:0,
col:0, //落子的坐标,往哪一行,哪一列来落子
}
响应
{
message: 'putChess',
userId: 1,
row: 0,
col: 0,
winner:0 //winner表示当前是否分出胜负 如果winner为0,表示胜负未分,还需要继续对战,如果winner非0,则表示当前的获胜方的id
}
以上交互接口的设计,其实也不一定非得按照我们写的这种格式约定,我们也就而已使用其他的约定方式
不管是哪种格式,只要能够解决我们的问题,只要简约方便就可以了
实现game_room.html
这个页面就是匹配成功之后,要跳转到的新页面
canvas
是HTML5引入的一个标签 “画布” 可以在画布上画画
此处的棋盘和棋子都是画上去的
canvas
这个标签有一组配套的js的canvas api,通过这个api就可以实现一些“画画”的效果
例如,展示一个棋盘,就画很多的直线,就能构成棋盘的网格
表示一个棋子,就画一个圆圈,并且填充上颜色
还需要响应点击事件,在鼠标落子的地方来画圆圈
阅读一下script.js
表示当前游戏中的棋盘,通过这个棋盘来表示当前哪个位置有子了
当前玩家点击的时候,如果有子的位置就不能继续落子了
0表示空闲位置,非0表示有子了
drawImage()
是指把图片画上去
initChessBoard()
绘制棋盘
针对chess(棋盘canvas)设定了点击回调
点击回调中的事件参数 这里就会记录点击的实际位置(坐标)
match.floor()
这里是为了让点击操作能够对应到网格线上~
总体的棋盘尺寸是450px*450px 整个棋盘上是15行,15列 每一行每一列占用的尺寸就是30px
oneStep()
走一步(里面会绘制一个棋子)
最终实现的页面结果:
服务器实现连接游戏房间
之前已经写了一个OnlineUserManager
对象了 也确实能够管理用户的在线状态 但是这个状态仅仅是局限于game_hall
这个页面中 现在是在game_room
中
之前在退出game_hall
页面的时候,就会断开
的连接 也就会在服务器的OnlineUserManager
中删除对应的元素
因此玩家从游戏大厅页面离开之后,需要重新
连接游戏房间逻辑梳理
连接游戏房间的线程安全问题
但凡是服务器端得开发,尤其是多个客户端来并发访问服务器,访问同一个数据的时候,就可能引发线程安全问题
这一段逻辑就可以视为多线程环境(两个客户端是并发连入的)
如果恰好是两个客户端同时执行到这个逻辑if(room.getUser1() == null)
,此时就会出现问题,玩家1和玩家2都会认为自己是先手方
因此就需要把这里的逻辑判定 使用锁保护起来 避免多个客户端都认为自己是玩家1
接下来需要考虑加锁对象是谁??
原则是,要竞争的资源是什么,就对谁加锁
(对谁加锁 针对这个对象访问的时候才有互斥效果)
在这个逻辑里是多个玩家/线程,在同时 访问/修改 同一个room
对象~就需要针对room对象来加锁
解决先手判定错误的bug
客户端代码中尝试获取响应中的isWhite
字段
实际的响应数据中,根本就没有isWhite
字段 有的只是whiteUser
字段
发送落子请求
在script.js
里增加代码
function send(row, col) {
let req = {
message: 'putChess',
userId: gameInfo.thisUserId,
row: row,
col: col
};
websocket.send(JSON.stringify(req));
}
注意:客户端和服务器两边的二维数组的区别
服务器这边的数组元素有三种状态:
服务器这边的二维数组,要起到的效果是进行判定胜负,要知道玩家1和玩家2的子落在哪里
客户端这边的数组元素只有两种状态:
客户端的二维数组只是用来判定这个位置有没有子 无0有1,只是为了避免出现重复落子的情况 一个位置落子多次
如果直接在客户端来判定胜负关系,是否可行呢?
不太可行 游戏中的关键逻辑一般还是要交给服务器来进行(防止外挂)
外挂的工作过程就是 篡改客户端这边的逻辑
发送落子响应
处理异常
发现此处有一个问题,空指针异常。深深的怀疑onlineUserManager
为空
//要想给用户发送 websocket 数据,就需要获取到这个用户 WebSocketSession
WebSocketSession session1 = onlineUserManager.getFromGameRoom(user1.getUserId());
WebSocketSession session2 = onlineUserManager.getFromGameRoom(user2.getUserId());
当前这两个属性在Room
这个类里面
//引入OnlineUserManager
@Autowired
private OnlineUserManager onlineUserManager;
//引入RoomManager,用来房间销毁
private RoomManager roomManager;
但是Room
类自身不是Spring
组件,没有被注册进去,自然@Autowired
无法生效
如果这么写,就成了单例了,Room
显然不应该是单例,应该是多例
此处Room
是已经被我们手动管理起来了:RoomManager
当前显然,Room
不应该作为Spring
中的组件~又希望能够从Spring
中拿到对应的onlineUerManager
和roomManager
就需要通过手动注入的方式来获取到实例了
判定胜负
判定棋面上是否出现五子连珠
一行,一列,一个对角线
因此我们不需要判定整个棋盘,只需要以row
、col
这个位置为中心,判定周围若干个格子
先以一行 为例来考虑判定结果
如果棋盘上出现了五子连珠,一定是和新落子的位置是相关的
五种一行五子连珠的情况:假设x是我们新落的子
- a b c d x
- a b c x d
- a b x c d
- a x b c d
- x a b c d
假设这一行中最左侧的点,
第一个点 r,c
第二个点 r,c+1
第三个点 r,c+2
第四个点 r,c+3
第五个点 r,c+4
最左边第一个点的运动范围,此处(row,col)
这个位置是玩家这次的落子位置
第一种情况:
最左边点:r=row,c=col-4
第二种情况:
最左边点:r=row,c=col-3
第三种情况:
最左边点:r=row,c=col-2
第四种情况:
最左边点:r=row,c=col-1
第五种情况:
最左边点:r=row,c=col
//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;
}
}
以一列 为例判定所有结果
坐标假设为 row,col
,五种情况如下:
一 二 三 四 五
a a a a x
b b b x a
c c x b b
d x c c c
x d d d d
假设这一列中最上面的点,坐标为
第一个点:r,c
第二个点:r+1,c
第三个点:r+2,c
第四个点:r+3,c
第五个点:r+4,c
最上边第一个点的运动范围,此处(row,col)
这个位置是玩家这次的落子位置
第一种情况:
最左边点:r=row-4,c=col
第二种情况:
最左边点:r=row-3,c=col
第三种情况:
最左边点:r=row-2,c=col
第四种情况:
最左边点:r=row-1,c=col
第五种情况:
最左边点:r=row,c=col
//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;
}
}
胜负未分 直接返回0return 0;
更新玩家分数
问题:
1.玩家比赛完成之后,比赛的胜负场数和分数没有改变
2.玩家掉线的情况,需要通知对手,你自动获胜
落子这里,对对方是否在线做过检测 但是这个检测是在落子的时候做的检测
刚才发现,当进行完一局游戏之后,分数没有顺利的被更新
刚才的逻辑中,主要是两部分:
1.一局游戏进行完之后,需要把信息写入数据库
2.返回到游戏大厅之后,要重新从数据库来获取
此处的关键点,就是数据库里面的内容对不对~
客户端的这个代码,实现了从服务器获取玩家信息的操作
分析到这里,就知道了~当前从服务器拿信息的这个接口,获取到的user对象不是数据库中的最新对象,而是之前在登录过程中,往session里存的user对象
后续我们已经更新了数据库的内容,但是session里的user没有发生改变
此处的解决方案:
根据当前的session中拿到的user对象,重新查询数据库
获取到的user对象才返回给客户端
更改:
在进行了一局之后
调整弹窗
处理回退按钮的问题
assign
更换为replace
游戏界面中 点击回退直接回退到登陆界面
部署程序到云服务器
1.先把数据库中的数据给构造好
2.微调页面(websocket建立连接的url进行调整)
如果服务器就在浏览的本机上,可以这么写~
如果服务器程序部署到其他机器上,此时就不能使用127.0.0.1了,而需要指定不同机器的ip
服务器部署到那个机器上,就需要制定哪个ip
这个ip就要写成云服务器的外网ip
此处我们要修改代码,让这个ip能够适应不同的主机
3.打包,并进行上传
借助maven来打包
命名为 zyx_gobang
4.运行程序,通过外网进行访问
使用命令 java -jar zyx-gobang.jar
启动java包