整个匹配的过程是异步过程,也就是在Matching system
中执行匹配的过程,会执行一个未知的时间,当出现符合条件的匹配结果时,才会立即将结果返回给前端。这种流程很难用之前的Http
来达到预期效果(http
为请求一次返回一次,且一般立即响应)。对于匹配系统,请求一次,返回的时间位置,而且可能多次返回。
用websocket
协议,不仅客户端可以向服务器主动发送请求,服务器也可以主动向客户端发送请求,是一种对称的通信方式。
之前的地图生成方式,是在用户本地(浏览器中)随机生成,如果两名玩家都在本地实现地图,地图就会产生冲突。因此,需要将生成地图的整个过程,由服务器统一完成。此外,判断游戏是否失败的逻辑(蛇撞击),如果在用户本地(浏览器)中实现,就可能会导致用户作弊。所以,不仅是生成地图,而是整个游戏的过程(蛇的移动、判定),都要做服务器端统一完成,服务器端的相关参数、判定结果返回给前端,前端只用来渲染画面,不做任何判定逻辑。
将前端建立的每个websocket
连接在后端维护起来
添加consumer.WebSocketServer
类
package com.kob.backend.consumer;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接
}
@OnClose
public void onClose() {
// 关闭链接
}
@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
}
在用户开始匹配的时候,每个client
向后端发送一个请求,就会在后端开辟一个线程,创建并维护一个websocket
连接(实际上就是new
一个WebSocketServer
类的实例)
WebSocketServer client1 = new WebSocketServer();
WebSocketServer client2 = new WebSocketServer();
后端接收到请求之后,将信息发送给匹配系统。
1)在pom.xml
文件中添加依赖:
spring-boot-starter-websocket
fastjson
2)添加config.WebSocketConfig
配置类:
package com.kob.backend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
3)添加consumer.WebSocketServer
类
package com.kob.backend.consumer;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接时自动调用
}
@OnClose
public void onClose() {
// 关闭链接时自动调用
}
@OnMessage
public void onMessage(String message, Session session) {
// Server从Client接收消息时触发
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
}
上面最核心的一个函数是onMessage
,负责Server从Client接收消息时处理相关逻辑。那如何在通过后端,向前端client发送信息呢?
定义Session
对象,每个连接本质上是通过Session
维护
private Session session = null;
新增sendMessage
函数,用于后端向当前连接发送信息
public void sendMessage(String message){
// Server发送消息
synchronized (this.session){
try{
this.session.getBasicRemote().sendText(message);
}catch (IOException e){
e.printStackTrace();
}
}
}
另外还需要存储下每个connection
对应的用户是谁,这样才能清楚哪两个用户之间发生了匹配,用户信息也要存储到Session
中。并且需要根据用户的ID
,找到相应的WebSocketServer
连接是哪一个,所以将两者的映射关系存储在ConcurrentHashMap
中,ConcurrentHashMap
是一个线程安全的哈希表
private User user;
private static ConcurrentHashMap<Integer,WebSocketServer>
userConnectionInfo = new ConcurrentHashMap<>();
由于WebSocket
不属于Spring
的一个组件,不是单例模式,因此,注入mapper
的方式有些区别
private static UserMapper userMapper;
@Autowired
public void setUserMapper(UserMapper userMapper){
WebSocketServer.userMapper = userMapper;
}
在建立连接时,需要建立用户ID与WebSocketServer
实例的映射
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接时自动调用
this.session = session;
System.out.println("Connected!");
int userId = Integer.parseInt(token);//假设token为userId
this.user = userMapper.selectById(userId);
userConnectionInfo.put(userId, this);
}
在关闭连接时,删除这种映射
@OnClose
public void onClose() {
// 关闭链接时自动调用
System.out.println("Disconnected!");
if(this.user != null){
userConnectionInfo.remove(this.user.getId());
}
}
此时的WebSocketServer.java
为:
@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
private User user;
private static ConcurrentHashMap<Integer,WebSocketServer>
userConnectionInfo = new ConcurrentHashMap<>();
private Session session = null;
private static UserMapper userMapper;
@Autowired
public void setUserMapper(UserMapper userMapper){
WebSocketServer.userMapper = userMapper;
}
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接时自动调用
this.session = session;
System.out.println("Connected!");
int userId = Integer.parseInt(token);//假设token为userId
this.user = userMapper.selectById(userId);
userConnectionInfo.put(userId, this);
}
@OnClose
public void onClose() {
// 关闭链接时自动调用
System.out.println("Disconnected!");
if(this.user != null){
userConnectionInfo.remove(this.user.getId());
}
}
@OnMessage
public void onMessage(String message, Session session) {
// Server从Client接收消息时触发
System.out.println("Receive message!");
}
@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
public void sendMessage(String message){
synchronized (this.session){
try{
this.session.getBasicRemote().sendText(message);
}catch (IOException e){
e.printStackTrace();
}
}
}
}
4)配置config.SecurityConfig
,将/websocket/{token}
一类的url
链接全部放行
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/websocket/**");
}
我们在views\pk\PkIndexView.vue
中对WebSocket
进行测试
期望在当前组件被加载成功之后,建立一个连接。
需要引入vue
的两个与生命周期有关的函数
onMounted
是当组件被挂载完成之后执行的函数onUnmounted
是当组件被卸载之后执行的函数同时,需要将WebSocket
存储到全局变量中,在store中开一个新的module用于存储所有和pk相关的全局变量
src\store\pk.js
export default ({
state: {
status:"matching",//matching表示匹配界面 playing表示对战界面
socket:null,//存储前后端建立的connection
opponent_username:"",//对手名
opponent_photo:"",//对手头像
},
mutations: {
},
actions: {
},
modules: {
}
})
由于在成功创建连接之后,需要将连接信息,存储到全局变量中
所以需要在src\store\pk.js
实现几个辅助函数
export default ({
state: {
status:"matching",//matching表示匹配界面 playing表示对战界面
socket:null,//存储前后端建立的connection
opponent_username:"",//对手名
opponent_photo:"",//对手头像
},
mutations: {
updateSocket(state, socket){
state.socket = socket;
},
updateOpponent(state, opponent){
state.opponent_username = opponent.username;
state.opponent_photo = opponent.photo;
},
updateStatus(state, status){
state.status = status;
}
},
actions: {
},
modules: {
}
})
然后在views\pk\PkIndexView.vue
引入全局变量useStore
在当前组件被挂载的时候(可以简单理解为页面被打开的时候),也就是onMounted
执行的时候,我们需要创建connection
,在onUnmounted
执行的时候,关闭连接。
export default {
components:{
PlayGround
},
setup() {
const store = useStore();
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.id}`;
let socket = null;
onMounted(() => {
socket = new WebSocket(socketUrl);
socket.onopen = () => {//如果连接成功,将socket存储到全局变量中
console.log("connected!");
store.commit("updateSocket",socket);
}
socket.onmessage = msg =>{
const data = JSON.parse(msg.data);
console.log(data);
}
socket.onclose = () =>{
console.log("disconnected!");
}
});
onUnmounted(()=>{
socket.close();
})
}
}
当进入到对战页面时,可以在后端和浏览器的控制台中看到连接成功的输出
此时如果切换到其他页面,又会断开连接
注意如果刷新页面,就会先断开连接,后建立连接
而且,必须要在页面卸载时,关闭连接
onUnmounted(()=>{
socket.close();
})
否则,切换到其他页面的时候,没有关闭连接,但是在每一次进来的时候,又会创建连接
刷新或者关闭时,会关闭所有的连接,从输出看出不止一个
所以,如果不进行正常关闭,在切换到其他页面时,旧连接不会关闭,因此会产生很多冗余的连接。
在成功连接后,后端输出获取到的用户信息如下:
此时建立连接时,是直接将用户的ID传输过来,但这样显然是不安全的,因为前端可以通过修改{token}的方式,伪装成任意一个用户的身份建立连接,因此需要添加验证,这里仍然是使用Jwt进行验证
前端直接将jwt-token
传过去
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`;
后端验证的方式,在config.filter.JwtAuthenticationTokenFilter
已经给出
那就是就是如果能从token
中解析出userId
就认为是合法的,否则就是不合法
String userid;
try {
Claims claims = JwtUtil.parseJWT(token);
//如果能解析出userid表示合法 否则不合法
userid = claims.getSubject();
} catch (Exception e) {
throw new RuntimeException(e);
}
为了日后方便,将这段代码提出,放在一个单独的工具类consumer.utils.JwtAuthentication
中
package com.kob.backend.consumer.utils;
import com.kob.backend.utils.JwtUtil;
import io.jsonwebtoken.Claims;
public class JwtAuthentication {
public static Integer getUserId(String token){
int userId = -1;
try {
Claims claims = JwtUtil.parseJWT(token);
//如果能解析出userid表示合法 否则不合法
userId = Integer.parseInt(claims.getSubject());
} catch (Exception e) {
throw new RuntimeException(e);
}
return userId;
}
}
此时WebSocketServer.java中onOpen
函数体更新为
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
// 建立连接时自动调用
this.session = session;
System.out.println("Connected!");
int userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);
if(this.user != null)
userConnectionInfo.put(userId, this);
else
this.session.close();
}
这样就能够成功实现jwt
验证
此时前端还只有对战界面,并没有匹配界面,我们需要实现匹配界面,以及匹配界面和对战界面的切换
与切换有关的全局变量,就是在pk.js
中定义的status
, matching
表示匹配界面,playing
表示对战界面
那就需要当status
为playing
的时候再显示对战页面
<template>
<PlayGround v-if="$store.state.pk.status === 'playing'"/>
</template>
并且需要创建一个新的组件MatchGround.vue
,用于表示匹配界面
<template>
<div class="matchground">
<div class="row">
<div class="col-6">
<div class="user_photo">
<img :src="$store.state.user.photo" alt="">
</div>
<div class="user_username">
{{ $store.state.user.username }}
</div>
</div>
<div class="col-6">
<div class="user_photo">
<img :src="$store.state.pk.opponent_photo" alt="">
</div>
<div class="user_username">
{{ $store.state.pk.opponent_username }}
</div>
</div>
</div>
<div class="row">
<div class="col-12" style="text-align:center; padding-top:12vh">
<button @click="click_match_btn" class="btn btn-success btn-lg">{{match_btn_info}}</button>
</div>
</div>
</div>
</template>
为按钮绑定一个click_match_btn
触发函数,当点击"开始匹配",使用WebSocket
的sent
API向后端发送包含event:"start-matching"
的字符串(注意,JSON.stringify
是将JSON
格式处理为字符串,后续还可以恢复JSON
格式)
<script>
import { ref } from 'vue'
import { useStore } from 'vuex';
export default {
setup(){
const store = useStore();
let match_btn_info = ref("开始匹配");
const click_match_btn = () =>{
if(match_btn_info.value === "开始匹配"){
match_btn_info.value = "取消";
//JSON.stringify将JSON转换为字符串
store.state.pk.socket.sent(JSON.stringify({
event:"start-matching",
}));
}else{
match_btn_info.value = "开始匹配";
store.state.pk.socket.sent(JSON.stringify({
event:"stop-matching",
}));
}
};
return{
match_btn_info,
click_match_btn,
}
}
}
</script>
后端收到请求时,就会将message
字符串解析为JSON
格式,然后根据event
值来分配给不同的任务
private void startMatching(){
System.out.println("start matching!");
}
private void stopMatching(){
System.out.println("stop matching!");
}
@OnMessage
public void onMessage(String message, Session session) {//当做路由 分配任务
// Server从Client接收消息时触发
System.out.println("Receive message!");
JSONObject data = JSONObject.parseObject(message);//将字符串解析成JSON
String event = data.getString("event");
if("start-matching".equals(event)){//防止event为空的异常
startMatching();
} else if ("stop-matching".equals(event)) {
stopMatching();
}
}
在后端需要建立一个线程安全的Set作为匹配池
private static CopyOnWriteArraySet<User>
matchpool = new CopyOnWriteArraySet<>();
然后在相应的时间添加和删除
@OnClose
public void onClose() {
// 关闭链接时自动调用
System.out.println("Disconnected!");
if(this.user != null){
userConnectionInfo.remove(this.user.getId());
matchpool.remove(this.user);
}
}
private void startMatching(){
System.out.println("start matching!");
matchpool.add(this.user);
}
private void stopMatching(){
System.out.println("stop matching!");
matchpool.remove(this.user);
}
由于现在还没有实现微服务,暂时先实现一个傻瓜式的匹配,也就是匹配池中大于等于两个用户的时候,就实现两两匹配,也就是两个用户user1
和user2
,并通过两个用户自己的连接,告诉前端匹配成功的相关消息
private void startMatching(){
System.out.println("start matching!");
matchpool.add(this.user);
while (matchpool.size() >= 2){
Iterator<User> iterator = matchpool.iterator();
User user1 = iterator.next();
User user2 = iterator.next();
matchpool.remove(user1);
matchpool.remove(user2);
//分别给user1和user2传送消息告诉他们匹配成功了
//通过user1的连接向user1发消息
JSONObject resp1 = new JSONObject();
resp1.put("event","start-matching");
resp1.put("opponent_username",user2.getUsername());
resp1.put("opponent_photo",user2.getPhoto());
WebSocketServer webSocketServer1 = userConnectionInfo.get(user1.getId());//获取user1的连接
webSocketServer1.sendMessage(resp1.toJSONString());
//通过user2的连接向user2发消息
JSONObject resp2 = new JSONObject();
resp2.put("event","start-matching");
resp2.put("opponent_username",user1.getUsername());
resp2.put("opponent_photo",user1.getPhoto());
WebSocketServer webSocketServer2 = userConnectionInfo.get(user2.getId());
webSocketServer2.sendMessage(resp2.toJSONString());
}
}
在前端的PkIndexView.vue
中,当接收到后端发送的消息之后,相关逻辑的实现在onmessage
函数中
如果匹配成功,就要更新对手信息
注意,需要两个用户进行测试的话,必须在两个不同的浏览器中。一个浏览器只能允许同时登录一个用户,因为在Local Storage
中会共用一个jwt_token
前端如何匹配成功,就更新对手的用户名和头像。
import PlayGround from '../../components/PlayGround.vue'
import MatchGround from '../../components/MatchGround.vue'
import { onMounted } from 'vue'
import { onUnmounted } from 'vue'
import { useStore } from 'vuex'
export default {
components:{
PlayGround,
MatchGround
},
setup() {
const store = useStore();
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`;
let socket = null;
onMounted(() => {
store.commit("updateOpponent",{
username:"我的对手",
photo:"https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",
});
socket = new WebSocket(socketUrl);
socket.onopen = () => {//如果连接成功,将socket存储到全局变量中
console.log("connected!");
store.commit("updateSocket",socket);
}
socket.onmessage = msg =>{
const data = JSON.parse(msg.data);
console.log(data);
if(data.event === "start-matching"){
store.commit("updateOpponent",{
username:data.opponent_username,
photo:data.opponent_photo
});
// store.commit("updateStatus","playing")
}
}
socket.onclose = () =>{
console.log("disconnected!");
}
});
onUnmounted(()=>{
socket.close();
})
}
}
然后在匹配成功之后,设置延迟两秒显示,然后跳转到对战页面
如果切换其他页面再切换回来的时候,地图又发生了变化,期望点击其他页面的时候自动放弃,再切换回来的时候,重新回到匹配页面。
那就需要在卸载页面(onUnmounted
)的时候,不仅需要断开连接,同时还要将状态切换为matching
状态。
但此时有一个很大的问题,就是两个人的游戏地图不一致。这是因为地图是在浏览器本地生成,为了解决同步问题,需要由服务器统一接管。
接下来需要在服务器端实现之前分析的Game流程
添加consumer.utils.Game.java
,用于管理整个游戏流程
参考assets\scripts\GameMap.js
画地图参考GameMap.js
中create_walls()
函数
import java.util.Random;
public class Game {
final private Integer rows;
final private Integer cols;
final private Integer inner_walls_count;
final private int[][] g;
//辅助数组
final private static int[] dx = {-1,0,1,0};
final private static int[] dy = {0,1,0,-1};
public Game(Integer rows, Integer cols, Integer inner_walls_count) {
this.rows = rows;
this.cols = cols;
this.inner_walls_count = inner_walls_count;
this.g = new int[rows][cols];
}
public int[][] getG() {//返回地图
return g;
}
private boolean check_connectivity(int sx, int sy,int tx, int ty){
if (sx == tx && sy == ty)
return true;
g[sx][sy] = 1;
for(int i = 0; i < 4; i++){
int x = sx + dx[i];
int y = sy + dy[i];
if(x >= 0 && x < this.rows && y >= 0 && y < this.cols && g[x][y] == 0){
if(check_connectivity(x, y, tx, ty)){
g[sx][sy] = 0;//恢复现场
return true;
}
}
}
g[sx][sy] = 0;//恢复现场
return false;
}
private boolean draw(){//绘制地图
for (int i = 0; i < this.rows; i++) {
for (int j = 0; j < this.cols; j++) {
g[i][j] = 0;//0表示可通行区域 1表示障碍物
}
}
//给四周加上障碍物
for(int r = 0; r < this.rows; r++){//给左右两侧设置为1
g[r][0]=1;
g[r][this.cols-1]=1;
}
for(int c = 0; c < this.cols; c++){//给上下两侧设置为1
g[0][c] = g[this.rows-1][c] = 1;
}
//在内部随机生成inner_walls_count个对称的障碍物
Random random = new Random();
for(int i = 0; i < this.inner_walls_count / 2; i++){
for (int j = 0; j < 1000; j++) {
int r = random.nextInt(this.rows);//返回0~rows-1的随机值
int c = random.nextInt(this.cols);//返回0~cols-1的随机值
if(g[r][c] == 1 || g[this.rows - 1 - r][this.cols - 1 - c] == 1)
continue;//已经有了 不能重复添加 直接进入下一轮循环 j++
if(r == this.rows - 2 && c == 1 || r == 1 && c == this.cols-2)
continue;//保证左上角和右下角不能有障碍物
//成功设置一个障碍物后 直接退出当前for i++
g[r][c] = g[this.rows - 1 - r][this.cols - 1 - c] = 1;
break;
}
}
//判断连通性
return check_connectivity(this.rows-2,1,1,this.cols-2);
}
public void createMap(){
for (int i = 0; i < 1000; i++) {
if(draw())
break;
}
}
}
然后在WebSocketServer.java
当开始匹配的时候,实例化一个Game对象用于生成地图,并将生成的地图返回给连接中的两个用户。
当然,最终的地图应该是保存在webSocket中,也就是只对当前匹配的两个用户可见,对其他连接的用户不可见,这一点放在后面实现。
private void startMatching(){
System.out.println("start matching!");
matchpool.add(this.user);
while (matchpool.size() >= 2){
Iterator<User> iterator = matchpool.iterator();
User user1 = iterator.next();
User user2 = iterator.next();
matchpool.remove(user1);
matchpool.remove(user2);
Game game = new Game(13,14,20);
game.createMap();
JSONObject resp1 = new JSONObject();
resp1.put("event","start-matching");
resp1.put("opponent_username",user2.getUsername());
resp1.put("opponent_photo",user2.getPhoto());
resp1.put("gamemap",game.getG());
WebSocketServer webSocketServer1 = userConnectionInfo.get(user1.getId());
webSocketServer1.sendMessage(resp1.toJSONString());
JSONObject resp2 = new JSONObject();
resp2.put("event","start-matching");
resp2.put("opponent_username",user1.getUsername());
resp2.put("opponent_photo",user1.getPhoto());
resp2.put("gamemap",game.getG());
WebSocketServer webSocketServer2 = userConnectionInfo.get(user2.getId());
webSocketServer2.sendMessage(resp2.toJSONString());
}
}
此时后端可以返回地图,前端写好接收地图的逻辑
src\store\pk.js
export default ({
state: {
status:"matching",//matching表示匹配界面 playing表示对战界面
socket:null,//存储前后端建立的connection
opponent_username:"",//对手名
opponent_photo:"",//对手头像
gamemap:null//地图
},
mutations: {
updateSocket(state, socket){
state.socket = socket;
},
updateOpponent(state, opponent){
state.opponent_username = opponent.username;
state.opponent_photo = opponent.photo;
},
updateStatus(state, status){
state.status = status;
},
updayeGamemap(state, gamemap){
state.gamemap = gamemap;
}
},
actions: {
},
modules: {
}
})
src\views\pk\PkIndexView.vue
socket.onmessage = msg => {
const data = JSON.parse(msg.data);
console.log(data);
if (data.event === "start-matching") {
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo
});
//匹配成功后,延时2秒,进入对战页面
setTimeout(() => {
store.commit("updateStatus", "playing")
}, 2000);
store.commit("updateGamemap",data.gamemap)//更新地图
}
}
后端获取gamemap
并更新到全局变量之后,要将获取到的gamemap
渲染到画布上
首先在组件GameMap.vue
中将全局变量store
传递到GameMap
的构造函数中
src\components\GameMap.vue
<script>
import { GameMap } from '../assets/scripts/GameMap'
import {onMounted, ref} from 'vue' //用于定义变量
import { useStore } from 'vuex';
export default {
setup(){
const store = useStore();
let parent = ref(null);
let canvas = ref(null);
onMounted(()=>{
new GameMap(canvas.value.getContext('2d'), parent.value, store)
});
return{
parent,
canvas
}
}
}
</script>
对于scripts\GameMap.js
相关的代码更新为:
export class GameMap extends GameObject {
constructor(ctx, parent, store){
super();
this.ctx = ctx;
this.parent = parent;
this.store = store;
this.L = 0;
this.rows = 13;
this.cols = 14;
this.inner_walls_count = 10;//定义内部障碍物数量
this.walls = [];//用于保存障碍物,属于对象数组
this.snakes = [
new Snake({id:0, color:"#4876EC",r: this.rows - 2, c: 1},this),
new Snake({id:1, color:"#F94848",r: 1, c: this.cols - 2},this),
];
}
//画地图:创建障碍物
create_walls(){
//直接将地图取出--后端传过来
console.log(this.store)
const g = this.store.state.pk.gamemap;
//创建障碍物对象 并添加到this.walls数组
for(let r = 0; r < this.rows; r++){
for(let c = 0; c < this.cols; c++){
if(g[r][c]){
this.walls.push(new Wall (r,c,this));
}
}
}
}
start(){
this.create_walls();//不用循环1000次了 因为直接接收的后端生成的
this.add_listening_events();
}
至此,就解决了地图同步问题