思维导图

实现匹配系统

实现匹配的原理

要实现匹配系统起码要有两个客户端 client1,client2, 当客户端打开对战页面并开始匹配时,会给后端服务器 server 发送一个请求,而匹配是一个异步的过程,什么时候返回结果是不可预知的,所以我们要写一个专门的匹配系统,维护一堆用户的集合,当用户发起匹配请求时,请求会先传给后端服务器,然后再传给匹配系统处理,匹配系统会不断地在用户里去筛选,将 rating 较为相近的的用户匹配到一组。当成功匹配后,匹配系统就会返回结果给 springboot 的后端服务器,继而返回给客户端即前端。然后我们就能在前端看到匹配到的对手是谁啦。

举个例子,两个客户端请求两个链接,新建两个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class WebSocketServer {
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接
WebSocketServer client1 = new WebSocketServer();
WebSocketServer client2 = new WebSocketServer();
}

@OnClose
public void onClose() {
// 关闭链接
}

@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
}

@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
}

websocket

因为匹配是异步的过程,且需要前后端双向交互,而普通的 http 协议是单向的,一问一答式的,属于立即返回结果的类型,不能满足我们的异步需求,因此我们需要一个新的协议 websocket:不仅客户端可以主动向服务器端发送请求,服务器端也可以主动向客户端发送请求,是双向双通的,且支持异步。简单来说就是客户端向后端发送请求,经过不确定的时间,会返回一次或多次结果给客户端。
基本原理: 每一个 ws 连接都会在后端维护起来,客户端连接服务器的时候会创建一个 WebSocketServer 类。每创建一个链接就是 new 一个 WebSocketServer 类的实例,所有与链接相关的信息,都会存在这个类里面。

703f1a598b1cfe5b132599c137.jpg

云端维护游戏流程

用户端进入匹配,后端把用户塞进匹配池。做一个类似于生产者-消费者模型的线程池,一旦有两名玩家符合匹配条件,就让他们成功匹配。

由服务端随机生成一张合法地图,通过websocket协议传给用户,让前端好渲染出来这张地图。

服务端将处理玩家的操作是否合法和对局的结果。玩家的操作可能来自于玩家的IO设备(键盘),也可能来自于玩家的代码(通过微服务来执行代码)。

后端添加配置和依赖

pom.xml中添加依赖:

  • spring-boot-starter-websocket
  • fastjson

添加config/WebSocketConfig配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
mport 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();
}
}

放行websocket连接,在config/SecurityConfig下加入以下代码

1
2
3
4
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/websocket/**");
}

后端如何维护Websocket

基本原理就是,对于每一个前端建立的websocket,后端都new一个WebSocketServer实例来维护它。

实例大概长这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class WebSocketServer {
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接
WebSocketServer client1 = new WebSocketServer();
WebSocketServer client2 = new WebSocketServer();
}

@OnClose
public void onClose() {
// 关闭链接
}

@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
}

@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}
}

onOpen函数会在连接建立时自动执行,onClose函数会在链接关闭时自动执行,onMessage用于处理前端发来的信息,是主要写业务逻辑的地方。向前端发信息的sendMessage函数需要自己实现。

实现WebSocketServer类

实现后端向前端发送信息,要手写个辅助函数 sendMessage

首先要存储所有链接,因为我们要根据用户 Id 找到所对应的链接是什么,才可以通过这个链接向前端发请求。根据基本的语法知识可得,这个东西得用静态标识符static修饰,因为这是一个所有实例的全局变量。

其次还要有链接与用户一一对应,每个链接都用一个 session 维护

需要注意的是:WebSocketServer 并不是一个标准的 Springboot 的组件,不是一个单例模式 (每一个类同一时间只能有一个实例,这里每建一个链接都会 new 一个类,所以不是单例模式),向里面注入数据库并不像在 Controller 里一样直接 @Autowired,要改成先定义一个 static 变量,再 @Autowired 加入到 setUsersMapper 函数上,如下:

1
2
3
4
5
private static UserMapper userMapper;
@Autowired
public void setUserMapper(UserMapper userMapper) {
WebSocketServer.userMapper = userMapper;
}

@Autowired 写在 set () 方法上,在 spring 会根据方法的参数类型从 ioc 容器中找到该类型的 Bean 对象注入到方法的行参中,并且自动反射调用该方法 (被 @Autowired 修饰的方法一定会执行),所以一般使用在 set 方法中、普通方法不用。

WebSocketServer类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {

//与线程安全有关的哈希表,将userID映射到相应用户的WebSocketServer
private static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();

//当前链接请求的用户
private Users user;

//后端向前端发信息,每个链接用session维护
private Session session = null;

private static UsersMapper usersMapper;

@Autowired
public void setUsersMapper(UsersMapper usersMapper) {
WebSocketServer.usersMapper = usersMapper; //静态变量访问要用类名访问
}

@OnOpen
public void onOpen(Session session, @PathParam("token") String token) {
// 建立连接
System.out.println("connected!");
this.session = session;
//为了方便调试,初阶段只把token当成userId看
Integer userId = Integer.parseInt(token);
this.user = usersMapper.selectById(userId);
users.put(userId, this);
}

@OnClose
public void onClose() {
// 关闭链接
System.out.println("disconnected!");
//断开连接的话要将user移除
if (this.user != null) {
users.remove((this.user.getId()));
}
}

@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
System.out.println("receive message!");
}

@OnError
public void onError(Session session, Throwable error) {
error.printStackTrace();
}

//后端向前端发信息
private void sendMessage(String message) {
//异步通信要加上锁
synchronized (this.session) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}

}
}

前端调试

store下新建pk.js文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import ModuleUser from './user'

export default {
state: {
socket: null, //ws链接
opponent_username: "",
opponent_photo: "",
status: "matching", //matching表示匹配界面,playing表示对战界面
btninfo: "开始匹配",
},
getters: {

},
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: {
user: ModuleUser,
}
}

再把pk引入全局变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import ModulePk from './pk'

export default createStore({
state: {
},
getters: {
},
mutations: {
},
actions: {
},
modules: {
user: ModuleUser,
pk: ModulePk,
}
})

调试函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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); //js自带的WebSocket()
socket.onopen = () => { //连接成功时调用的函数
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(); //卸载的时候断开连接
});
}

如果前端和后端都能输出connected!,说明websocket连接建立成功!

添加jwt验证

注意:前端向后端传的信息是JwtToken,这里传userId只是为了调试方便点。

把前端传的信息改成:

1
const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;

在后端的consumer/utils/JwtAuthenciation辅助类,作用是根据token判断用户是否存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.popgame.backend.consumer.utils;

import com.popgame.backend.utils.JwtUtil;
import io.jsonwebtoken.Claims;

public class JwtAuthentication {
public static Integer getUserId(String token) {
int userId = -1; //-1表示不存在
try {
Claims claims = JwtUtil.parseJWT(token);
userId = Integer.parseInt(claims.getSubject());
} catch (Exception e) {
throw new RuntimeException(e);
}
return userId;
}
}

接着修改一下后端的onOpen函数

1
2
3
4
5
6
7
8
9
10
this.session = session;

Integer userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);
if (this.user != null) {
users.put(userId, this);
System.out.println("connected");
} else {
this.session.close();
}

再调试一下,若前端能正常输出用户信息,说明代码正确。

前端实现匹配界面

利用store全局变量和vue3v-if实现匹配页面和对战页面的切换

1
2
3
4
5
<template>

<PlayGround v-if="$store.state.pk.status === 'playing'" />
<MatchGround v-if="$store.state.pk.status === 'matching'" />
</template>

匹配界面的布局利用bootstrapgrid系统,自己:对手=1:1。匹配的逻辑也很简单,点击匹配按钮即可开始匹配;再点击即可取消匹配。若匹配成功,匹配按钮会显示“匹配成功”四个字。等待几秒后再跳转到对战界面。

匹配演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<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 class="col-12" style="padding-top: 15vh;text-align: center">
<button @click="click_match_btn" type="button" class="btn btn-success btn-lg"> {{ $store.state.pk.btninfo }} </button>
</div>
</div>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<script>
import { ref } from 'vue';
import store from '../store';

export default {
setup() {
let match_btn_info = ref("开始匹配");
store.state.pk.btninfo = "开始匹配";
const click_match_btn = () => {
if (store.state.pk.btninfo === "开始匹配") {
store.state.pk.btninfo = "取消匹配";
store.state.pk.socket.send(JSON.stringify({
event: "start-matching",
}));
}
else if (store.state.pk.btninfo === "取消匹配"){
store.state.pk.btninfo = "开始匹配";
store.state.pk.socket.send(JSON.stringify({
event: "stop-matching",
}));
}
};
return {
match_btn_info,
click_match_btn,
}
},


}

</script>

实现和后端的通信

注意这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const click_match_btn = () => {
if (store.state.pk.btninfo === "开始匹配") {
store.state.pk.btninfo = "取消匹配";
store.state.pk.socket.send(JSON.stringify({
event: "start-matching",
}));
}
else if (store.state.pk.btninfo === "取消匹配"){
store.state.pk.btninfo = "开始匹配";
store.state.pk.socket.send(JSON.stringify({
event: "stop-matching",
}));
}
};

前端向后端发送了一个JSON字符串,后端可以在onMessage函数里接收到前端 的请求。

1
2
3
4
5
6
7
8
9
10
11
public void onMessage(String message, Session session) { //当做路由
// 从Client接收消息
JSONObject data = JSONObject.parseObject(message);
String event = data.getString("event");
if ("start-matching".equals(event)) {
startMatching();
}
else if ("stop-matching".equals(event)) {
stopMatching();
}
}

将前端收到的JSON字符串通过Java方法转化成JSON对象,就可以从中获取信息了。

类似地,后端也可以通过sendMessage函数向前端发送信息

1
users.get(a.getId()).sendMessage(respa.toJSONString()); //向前端发送信息

前端接受后端的消息也类似于后端,分建立、接受、断开三个部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
setup() {
const store = useStore();
const socket_url = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`;
let socket = null;
onMounted(() => {
socket = new WebSocket(socket_url);
})
socket.onopen = () => { //连接成功时调用的函数
store.commit("updateSocket", socket);
}
socket.onmessage = msg => { //前端接收到信息时调用的函数
const data = JSON.parse(msg.data); //将JAVA传来的JSON字符串转化为JSON对象
/*
*写具体业务
*/
}
socket.onclose = () => { //关闭时调用的函数
console.log("disconnected!");
}
}

写匹配池

用线程安全的 set 定义匹配池:

1
private static final CopyOnWriteArraySet<User> matchPool = new CopyOnWriteArraySet<>();

开始匹配时,将用户放进匹配池里,取消匹配时将用户移除匹配池
匹配过程在目前调试阶段可以简单地两两匹配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void startMatching() {
System.out.println("startMatching");
matchPool.add(this.user);
while (matchPool.size() >= 2) {
Iterator<User> it = matchPool.iterator();
//匹配成功a和b
Game game = new Game(13, 14, 20);
game.createMap();
User a = it.next(), b = it.next();
matchPool.remove(a);
matchPool.remove(b);

JSONObject respa = new JSONObject();
respa.put("event", "start-matching");
respa.put("opponent_username", b.getUsername());
respa.put("opponent_photo", b.getPhoto());
respa.put("gamemap", game.getG());
users.get(a.getId()).sendMessage(respa.toJSONString()); //向前端发送信息

JSONObject respb = new JSONObject();
respb.put("event", "start-matching");
respb.put("opponent_username", a.getUsername());
respb.put("opponent_photo", a.getPhoto());
respb.put("gamemap", game.getG());
users.get(b.getId()).sendMessage(respb.toJSONString()); //向前端发送信息
}
}

private void stopMatching() {
System.out.println("stopMatching");
matchPool.remove(this.user);
}

后端将处理好的信息传给前端后,前端接受并处理信息:

views/PKIndexView.vue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<script>
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 jwt_token = localStorage.getItem("jwt_token");
const socket_url = `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(socket_url);
socket.onopen = () => { //连接成功时调用的函数
console.log("connected!");
store.commit("updateSocket", socket);
}

socket.onmessage = msg => { //前端接收到信息时调用的函数
const data = JSON.parse(msg.data); //将JAVA传来的JSON字符串转化为JSON对象
if (data.event === "start-matching") {
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo,

});
store.state.pk.btninfo = "匹配成功",
setTimeout(() => {
store.commit("updateStatus", "playing");
}, 3000) //匹配成功,进入对战界面
}
store.commit("updateGamemap", data.gamemap);
console.log(data);
}

socket.onclose = () => { //关闭时调用的函数
console.log("disconnected!");
}
});

onUnmounted(() => { //当当前页面关闭时调用
store.commit("updateStatus", "matching");
socket.close(); //卸载的时候断开连接
});
}
}
</script>

后端重构生成地图功能

前文也提到过,生成地图,游戏逻辑等与游戏相关的操作都应该放在服务端,不然的话客户每次刷新得到的地图都不一样,游戏的公平性也不能得到保证。因此,我们要将之前在前端写的游戏逻辑全部转移到后端(云端),前端只负责动画的演示即可。

首先要在后端创建一个 Game 类实现游戏流程,其实就是把之前在前端写的 js 全部翻译成 Java 就好了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package com.example.backend.consumer.utils;

import java.util.Random;

public class Game {
private final Integer rows;
private final Integer cols;
private final Integer inner_walls_count;

private final static int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};

private int[][] g; //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 void initMap() {
for (int i = 0; i < this.rows; i++) {
for (int j = 0; j < this.cols; j++) {
g[i][j] = 0;
}
}
//四周是墙
for (int i = 0; i < this.rows; i++) {
g[i][0] = 1;
g[i][this.cols - 1] = 1;
}
for (int i = 0; i < this.cols; i++) {
g[0][i] = 1;
g[this.rows - 1][i] = 1;
}
}

private boolean check_connectivity(int sx, int sy, int ex, int ey) {
if (sx == ex && sy == ey) return true;
g[sx][sy] = 1;
for (int i = 0; i < 4; i++) {
int a = sx + dx[i], b = sy + dy[i];
if (a < 0 || a >= this.rows || b < 0 || b >= this.cols) continue;
if (g[a][b] == 0 && this.check_connectivity(a, b, ex, ey)) {
g[sx][sy] = 0;
return true;
}
}
g[sx][sy] = 0;
return false;
}
private boolean draw() { //画地图
initMap();
//随机障碍物
Random random = new Random();
for (int i = 0; i < this.inner_walls_count / 2; i++) {
for (int j = 0; j < 100000; j++) {
int r = random.nextInt(this.rows);
int c = random.nextInt(this.cols);
if (g[r][c] == 1 || g[this.rows - r - 1][this.cols - c - 1] == 1) continue; //已经有墙了
if (r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) continue; //排除起点
g[r][c] = 1;
g[this.rows - r - 1][this.cols - c - 1] = 1;
break;
}
}
return this.check_connectivity(this.rows - 2, 1, 1, this.cols - 2);
}

public void createMap() {
for (int i = 0; i < 1000; i++) {
if (draw()) {
break;
}
}
}
}

然后把这个地图信息也返回给前端。在后端的startMatching方法中加入以下代码:

1
2
3
4
Game game = new Game(13, 14, 20);
game.createMap();
respa.put("gamemap", game.getG());
respb.put("gamemap", game.getG());

在前端pk.js中的state里加入:gamemap: nullmutation里加入:

1
2
3
updateGamemap(state, gamemap) {
state.gamemap = gamemap;
},

这样就行了。再把前端原先createmap的代码删掉,改成使用store里存储的变量。

1
2
3
4
5
6
7
8
9
10
11
create_walls() {
const g = this.store.state.pk.gamemap;
for (let i = 0; i < this.rows; i++) {
for (let j = 0; j < this.cols; j++) {
if (g[i][j]) {
this.walls.push(new Wall(i, j, this));
}
}
}
return true;
}