0%

AcWing SpringBoot项目实战05

学习平台

AcWing SpringBoot框架课

系统流程

image-20230322200513253

改进之处

存在的问题1:之前地图的创建是在前端写的,所以如果两个用户匹配之后,在各自的前端生成地图,则生成的地图很大程度上是不一样的,这样肯定是不行的。

解决办法:因此,在本节中,我们首先需要将生成地图这个过程放在后端中。这样当两个玩家匹配之后,需要调用一个单独的任务,这个任务会统一的给两个玩家生成地图。

问题2:裁判逻辑,也就是判断两个玩家的输赢也不应该放在前端。

解决办法:也需要将判断输赢这个过程放在后端,这样就可以避免恶意篡改结果。

前端在这个时候只是将后端的结果进行渲染展示,而不做任何判断逻辑。(并不是所有的游戏都需要将逻辑放在云端)

环境配置

集成WebSocket

1.在pom.xml中导入依赖

  • spring-boot-starter-websocket
  • fastjson

2.添加config.WebSocketConfig配置类

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

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
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();
}
}

对该类进行修改补充。

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
package com.kob.backend.consum;

import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
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;

/**
* 用户每建立一个连接,就会new一个新的实例,不是单例模式
*/
@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {

private static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>(); // 静态变量,对所有实例都可见,公共的,只有一个

private User user;
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!");

Integer userId = Integer.parseInt(token);
this.user = userMapper.selectById(userId);
users.put(userId, this);

}

@OnClose
public void onClose() {
// 关闭链接
System.out.println("disconnected!");
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();
}

public void sendMessage(String message) {
synchronized (this.session) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

4.配置config.SecurityConfig

需要将所有的websocket请求放行

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

前端实现

  • 首先需要实现store/pk.js来保存用户的匹配信息
    • status:用来保存用户的状态,matching表示正在匹配,playing表示正在对战。其实就是控制前端展示的时匹配界面还是对战界面。
    • socket:用来保存通过后端创建的websocket链接
    • opponent_username:用来保存匹配的对手的用户名
    • opponent_photo:用来保存匹配的对手的用户头像。
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
export default {
state: {
status: "matching", // matching 表示正在匹配,playing表示正在对战。
socket: null,
opponent_username: "",
opponent_photo: "",
},
getters: {
},
mutations: { // 用来给state赋值,相当于set(), 但是是私有的,
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

通过挂在函数onMouted和卸载函数onUnmouted来实现websocket链接的创建和断开。

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
<template>
<PlayGround>
对战
</PlayGround>
</template>

<script>

import PlayGround from '@/components/PlayGround.vue'
import { onMounted, onUnmounted } from 'vue';
import { useStore } from 'vuex';

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(() => { // 挂载函数,页面打开时,会执行,创建一个websocket链接
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 = () => {
console.log("connected!");
store.commit("updateSocket", socket); // 将创建的socket保存
}

socket.onmessage = msg => {
const data = JSON.parse(msg.data);
console.log(data);
}

socket.onclose = () => {
console.log("disconnected!");
}
});

onUnmounted(() => { // 卸载函数,离开页面时调用,需要将websocket链接断开,避免冗余链接
socket.close();
});
},
}
</script>

<style scoped>

</style>

验证jwt

这部分属于对前面部分的优化,前面向后端发送socket请求时直接将用户的id传了过去,这样不安全,因此需要将此改为传用户的token

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

同样需要在后端对前端传过来的token进行验证并取出所需要的userId

  • 实现consumer/utils/JwtAuthentication.java来验证token并返回用户id.

这里实现的时静态方法。因此调用的时候直接通过类名.方法名调用即可。

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
package com.kob.backend.consumer.utils;

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

/**
* @author xzt
* @version 1.0
* 进行Jwt验证
*/
public class JwtAuthentication {
/**
* 静态方法:根据token获取用户的userId
* @param token
* @return
*/
public static Integer getUserId(String token) {
Integer userId = -1;
try {
Claims claims = JwtUtil.parseJWT(token);
userId = Integer.parseInt(claims.getSubject());
} catch (Exception e) {
throw new RuntimeException(e);
}
return userId;
}
}
  • 修改consumer/WebSocketServer.java中的onOpen函数,实现从上面的方法中获取用户名。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
// 建立连接
this.session = session;
System.out.println("connected!");

Integer userId = JwtAuthentication.getUserId(token); // 验证token并返回用户id
this.user = userMapper.selectById(userId);

if(this.user != null) { // 如果用户存在,将用户链接保存在users中。
users.put(userId, this);
} else {
this.session.close();
}
}

匹配界面前端实现

界面布局

image-20230320113957463

前端实现

实现步骤

  • 首先实现匹配页面components/MatchGround.vue
    • 需要注意的细节:当按下【开始匹配】按钮后,需要讲该按钮变为【取消匹配】,按下【取消匹配】按钮后,则需要讲按钮变为【开始匹配】
    • 当开始匹配时,需要使用socket向后端发送一个eventstart-matching。取消匹配时,也需要向后端发送一个消息event为stop-matching
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
82
<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="text-align: center; padding-top: 15vh;">
<button @click="click_match_btn" type="button" class="btn btn-warning btn-lg">{{ match_btn_info }}</button>
</div>
</div>
</div>
</template>

<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 ="取消匹配";
store.state.pk.socket.send(JSON.stringify({
event: "start-matching",
}));
} else {
match_btn_info.value = "开始匹配";
store.state.pk.socket.send(JSON.stringify({
event: "stop-matching",
}));
}
}

return {
match_btn_info,
click_match_btn,
}
}
}

</script>

<style scoped>
div.matchground{
width: 60vw;
height: 70vh;
background-color: rgba(50, 50, 50, 0.5);
margin: 40px auto 0;
}

div.user-photo{
text-align: center;
padding-top: 10vh;
}
div.user-photo > img {
border-radius: 50%;
width: 20vh;
}
div.user-username{
text-align: center;
font-size: 24px;
font-weight: 600;
color: white;
padding-top: 2vh;
}
</style>
  • 修改Pk主页面views/pk/PkIndexView.vue,当没有匹配成功时显示匹配页面MatchGround.vue,当匹配成功后显示对战页面PlayGround.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<template>
<PlayGround v-if="$store.state.pk.status === 'playing'"></PlayGround>
<MatchGround v-if="$store.state.pk.status === 'matching'"></MatchGround>
</template>

<script>
import MatchGround from '@/components/MatchGround.vue';

export default {
components: {
PlayGround,
MatchGround,
},
};
</script>

实现效果

image-20230321090757277

点击开始匹配后:按钮变为取消匹配

image-20230321090813440

匹配界面后端实现

实现步骤

  • 首先在consumer/WebSocketServer中修改onMessage函数,用来接收前端发送的字符串event
    • 当event为start-matching时,调用startMatching()函数
    • 当event为stop-matching时,调用stopMatching()函数
1
2
3
4
5
6
7
8
9
10
11
12
13
@OnMessage
public void onMessage(String message, Session session) {
// 从Client接收消息
System.out.println("receive message!");
// 从json中取出前端传的event
JSONObject data = JSONObject.parseObject(message);
String event = data.getString("event");
if("start-matching".equals(event)) {
startMatching();
} else if("stop-matching".equals(event)) {
stopMatching();
}
}
  • consumer/WebSocketServer开一个线程池,用来保存所有正在匹配的用户信息。
1
final private static CopyOnWriteArrayList<User> matchpool = new CopyOnWriteArrayList<>();  // 开一个线程池,线程安全
  • 修改onClose函数,当关闭连接时需要讲用户信息从线程池中移除。
1
2
3
4
5
6
7
8
9
@OnClose
public void onClose() {
// 关闭链接
System.out.println("disconnected!");
if(this.user != null) {
users.remove(this.user.getId());
matchpool.remove(this.user);
}
}
  • 简单实现startMatchingstopMatching,开始匹配时将用户信息加入匹配池,取消匹配的时候,讲用户从匹配池中移除。
1
2
3
4
5
6
7
8
9
private void startMatching() {
System.out.println("start matching");
matchpool.add(this.user);
}

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

实现傻瓜式匹配

实现步骤

具体思路:当线程池中的用户数量大于等于2时,讲用户两两匹配,这里之所以为傻瓜式匹配,就是没考虑战力rating的差距。

  • 修改consumer/WebSocketServer中的startMatching函数

匹配成功后,需要向前端发送消息。包括匹配成功的信息,和对手的相关信息。

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
private void startMatching() {
System.out.println("start matching");
matchpool.add(this.user);

while(matchpool.size() >= 2) {
Iterator<User> it = matchpool.iterator();
User a = it.next(), b = it.next();
matchpool.remove(a);
matchpool.remove(b);

// 往前端传递匹配成功的信息
JSONObject respA = new JSONObject();
respA.put("event", "success-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
// 获取A的webSocket,并向前端发送信息
WebSocketServer webSocketServerA = users.get(a.getId());
webSocketServerA.sendMessage(respA.toJSONString());

JSONObject respB = new JSONObject();
respB.put("event", "success-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
//获取B的webSocket并向前端发送信息
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}
  • 前端实现,在views/pk/PkIndexView.vue中修改onMouted挂载函数中的socket.message
  • 当Pk页面被卸载时,需要将状态重新改为matching,需要重新匹配。修改onUnmounted

当接收到后端发送的eventsuccess-matching时,则表示匹配成功,则需要调用store中的函数将对手信息保存下来。然后在2000ms后将状态修改为playing就可以跳转到对战页面。

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
<script>
onMounted(() => { // 挂载函数,页面打开时,会执行,创建一个websocket链接
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 = () => {
console.log("connected!");
store.commit("updateSocket", socket);
}

socket.onmessage = msg => {
const data = JSON.parse(msg.data);
if(data.event === "success-matching") { // 表示匹配成功
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo,
});
setTimeout(() => {
store.commit("updateStatus", "playing");
}, 2000);
}
console.log(data);
}

socket.onclose = () => {
console.log("disconnected!");
store.commit("updateStatus", "matching");
}
});
onUnmounted(() => { // 卸载函数,离开页面时调用,需要将websocket链接断开,避免冗余链接
socket.close();
store.commit("updateStatus", "matching");
});
</script>

实现效果

当匹配成功后,会显示对手的头像和用户名信息。

image-20230321101122841

然后跳转到对战页面,但是因为地图实现还在前端,所以两个玩家的地图不一致。因此后面就需要针对这个内容进行优化,将地图生成放在后端实现。

image-20230321101159842

生成地图优化(同步)

需要将地图生成放在后端,这样可以保证两个玩家使用的是同一张地图。

实现步骤

  • 将地图生成的逻辑转移至后端consumer/utils/Game.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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
package com.kob.backend.consumer.utils;

import java.util.Random;

/**
* @author xzt
* @version 1.0
* 用来生成地图
*/
public class Game {
final private Integer rows;
final private Integer cols;
final private Integer inner_walls_count;
final private int[][] g; // 0表示空地,1表示障碍物
final private static int[] dx = {-1, 0, 1, 0}, 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;
}

/**
* 判断地图的连通性
* @param sx 起始横坐标
* @param sy 起始纵坐标
* @param tx 目标横坐标
* @param ty 目标纵坐标
* @return true表示连通,false表示不连通
*/
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;
}
}

// 给四周加墙
for (int r = 0; r < this.rows; r++) {
g[r][0] = g[r][this.cols - 1] = 1;
}
for (int c = 0; c < this.cols; c++) {
g[0][c] = g[this.rows - 1][c] = 1;
}

// 创建随机障碍物
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~this.rows-1中的一个随机值
int c = random.nextInt(this.cols);
if(g[r][c] == 1 || g[this.rows - 1 - r][this.cols - 1 - c] == 1) continue;
if(r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) continue;
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中匹配成功后,调用Game类中的createMap()函数,进行地图生成。并将生成的地图分别传给两个玩家的前端。
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
private void startMatching() {
System.out.println("start matching");
matchpool.add(this.user);

while(matchpool.size() >= 2) {
Iterator<User> it = matchpool.iterator();
User a = it.next(), b = it.next();
matchpool.remove(a);
matchpool.remove(b);

// 生成地图
Game game = new Game(13, 14, 20);
game.createMap();

// 往前端传递匹配成功的信息
JSONObject respA = new JSONObject();
respA.put("event", "success-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("gamemap", game.getG());
// 获取A的webSocket,并向前端发送信息
WebSocketServer webSocketServerA = users.get(a.getId());
webSocketServerA.sendMessage(respA.toJSONString());

JSONObject respB = new JSONObject();
respB.put("event", "success-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("gamemap", game.getG());
//获取B的webSocket并向前端发送信息
users.get(b.getId()).sendMessage(respB.toJSONString());

}
}
  • store/pk.js中引入一个变量gamemap,并实现对应的赋值函数updateGamemap
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

export default {
state: {
status: "matching", // matching 表示正在匹配,playing表示正在对战。
socket: null,
opponent_username: "",
opponent_photo: "",
gamemap: null,
},
getters: {
},
mutations: { // 用来给state赋值,相当于set(), 但是是私有的,
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;
},
updateGamemap(state, gamemap) {
state.gamemap = gamemap;
}
},
actions: { // 实现函数,公有函数,可以被外面调用,然后调用私有函数对变量进行赋值
},
modules: {
}
}
  • 修改views/pk/PkIndexView.vue中的socket.message函数。当匹配成功后需要接受后端发送过来的地图信息,并调用updateGamemap函数对pk.js中保存的地图信息进行更新。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script>
socket.onmessage = msg => {
const data = JSON.parse(msg.data);
if(data.event === "success-matching") { // 表示匹配成功
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo,
});
setTimeout(() => {
store.commit("updateStatus", "playing");
}, 2000);
store.commit("updateGamemap", data.gamemap);
}
console.log(data);
}
</script>
  • 将前端写的生成地图的逻辑代码删除;修改scripts/GameMap.js,需要在构造函数中传入store,用来接收地图信息。
    • 新加this.store = store
    • 删除原来的判断连通性函数,因为在后端实现的时候就判断了
    • 修改create_walls函数,只用来将g中的信息绘制出来
    • 修改start()函数,不用循环1000次直到找到连通性的地图才结束。因为后端传过来的地图已经保证了连通性。
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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import { AcGameObject } from "./AcGameObject";
import { Snake } from "./Snake";
import { Wall } from "./Wall";

export class GameMap extends AcGameObject {
constructor(ctx, parent, store) {
super(); // 执行基类的构造函数,就是AcGameObject的构造函数

this.ctx = ctx; // 一个画布
this.parent = parent;
this.store = store;
this.L = 0; // 表示地图中每一个小块的宽度

this.rows = 13; // 地图的行数
this.cols = 14; // 地图的列数

this.walls = []; // 保存创建的每个障碍物的对象
this.inner_walls_count = 20; // 定义在地图里面障碍物数量

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() {
const g = this.store.state.pk.gamemap; // 从PK.js中取出后端保存的地图
// 绘制墙
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));
}
}
}

add_listening_events() { // 监听 用户输入
this.ctx.canvas.focus(); // 添加 canvas 聚焦

const [snake0, snake1] = this.snakes;
this.ctx.canvas.addEventListener("keydown", e => {
if(e.key === 'w') snake0.set_direction(0);
else if(e.key === 'd') snake0.set_direction(1);
else if(e.key === 's') snake0.set_direction(2);
else if(e.key === 'a') snake0.set_direction(3);
else if(e.key === 'ArrowUp') snake1.set_direction(0);
else if(e.key === 'ArrowRight') snake1.set_direction(1);
else if(e.key === 'ArrowDown') snake1.set_direction(2);
else if(e.key === 'ArrowLeft') snake1.set_direction(3);
});
}

start() {
this.create_walls();
this.add_listening_events();
}

update_size() { // 更新地图的大小
this.L = parseInt(Math.min(this.parent.clientWidth / this.cols, this.parent.clientHeight / this.rows));
this.ctx.canvas.width = this.L * this.cols;
this.ctx.canvas.height = this.L * this.rows;
}

check_ready() { // 判断两条蛇是否都准备好下一回合
for(const snake of this.snakes) {
if(snake.status !== "idle") return false; // 不是静止,不能进入下一回合
if(snake.direction === -1) return false; // 没有给出下一步的指令,则不能进入下一回合
}
return true;
}

next_step() { // 让两条蛇进入下一回合
for(const snake of this.snakes) {
snake.next_step();
}
}

check_valid(cell) { // 检测目标位置是否合法: 没有撞到两条蛇的身体,或者障碍物
for(const wall of this.walls) {
if(wall.r === cell.r && wall.c === cell.c) return false; // 如果下一位置是障碍物
}
for(const snake of this.snakes) {
let k = snake.cells.length;
if(!snake.check_tail_increasing()) { // 当蛇尾会前进的时候
k --;
}
for(let i = 0; i < k; i ++) { // 遍历蛇的每个节点,判断是否会装上。
if(snake.cells[i].r === cell.r && snake.cells[i].c === cell.c)
return false;
}
}
return true;
}

update() {
this.update_size();
if(this.check_ready()) { // 两条蛇都准备好进入下一回合了
this.next_step(); // 让两条蛇进入下一回合
}
this.render();
}

render() {
// 画图
const color_even = '#AAD751', color_odd = '#A2D149' // 定义两种颜色
for(let r = 0; r < this.rows; r ++) {
for(let c = 0; c < this.cols; c ++) {
if((r + c) % 2 == 0) this.ctx.fillStyle = color_even;
else this.ctx.fillStyle = color_odd;
this.ctx.fillRect(c * this.L, r * this.L, this.L, this.L);
}
}
}

destroy() {

}
}

实现效果

当匹配成功后,可以看到两个玩家的地图是完全一样的。

image-20230322193150264

image-20230322193203690

实现玩家的位置同步

玩家1的视角:自己在左下角,对手在右上角

玩家2的视角:自己在右上角,对手在左下角

因此需要将玩家的位置信息也放在后端实现consumer/utils/Player.java,并且在后端实现的地图类consumer/utils/Game.java里引入两个玩家的信息并进行同步。

实现步骤

后端实现

  • 后端:实现玩家类consumer/utils/Player.java。里面包含玩家的id、起始位置(横纵坐标)以及玩家每一步所走的方向。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.kob.backend.consumer.utils;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.List;

/**
* @author xzt
* @version 1.0
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Player {
private Integer id;
private Integer sx;
private Integer sy;

private List<Integer> steps;
}
  • 后端:修改consumer/utils/Game.java
    • 引入两个玩家类playerAplayerB。其中A是左下角,B是右上角位置
    • 修改构造函数,对两个玩家对象进行初始化。
    • 实现两个玩家的get函数。
    • 修改startMatching函数,当两个玩家匹配成功后,许需要将两个玩家的信息(id、sx、sy)以及地图信息发送给前端。
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
private final Player playerA, playerB;  // 记录两个蛇的位置,playerA是左下角,playerB是右上角

public Game(Integer rows, Integer cols, Integer inner_walls_count, Integer idA, Integer idB) {
this.rows = rows;
this.cols = cols;
this.inner_walls_count = inner_walls_count;
this.g = new int[rows][cols];
playerA = new Player(idA, this.rows - 2, 1, new ArrayList<>());
playerB = new Player(idB, 1, this.cols - 2, new ArrayList<>());
}

public Player getPlayerA() {
return playerA;
}

public Player getPlayerB() {
return playerB;
}

private void startMatching() {
System.out.println("start matching");
matchpool.add(this.user);

while(matchpool.size() >= 2) {
Iterator<User> it = matchpool.iterator();
User a = it.next(), b = it.next();
matchpool.remove(a);
matchpool.remove(b);

// 生成地图
Game game = new Game(13, 14, 20, a.getId(), b.getId());
game.createMap();

JSONObject respGame = new JSONObject();
respGame.put("a_id", game.getPlayerA().getId());
respGame.put("a_sx", game.getPlayerA().getSx());
respGame.put("a_sy", game.getPlayerA().getSy());
respGame.put("b_id", game.getPlayerB().getId());
respGame.put("b_sx", game.getPlayerB().getSx());
respGame.put("b_sy", game.getPlayerB().getSy());
respGame.put("map", game.getG());

// 往前端传递匹配成功的信息
JSONObject respA = new JSONObject();
respA.put("event", "success-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("game", respGame);
// 获取A的webSocket,并向前端发送信息
WebSocketServer webSocketServerA = users.get(a.getId());
webSocketServerA.sendMessage(respA.toJSONString());

JSONObject respB = new JSONObject();
respB.put("event", "success-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("game", respGame);
//获取B的webSocket并向前端发送信息
users.get(b.getId()).sendMessage(respB.toJSONString());

}
}

前端实现

  • 前端:修改store/pk.js
    • 添加自己以及对手的信息(包括两个玩家的id,两个玩家的起始位置,以及地图信息),
    • 添加更新函数,用来给上面的信息进行赋值updateGame
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

export default {
state: {
status: "matching", // matching 表示正在匹配,playing表示正在对战。
socket: null,
opponent_username: "",
opponent_photo: "",
gamemap: null,
a_id: 0,
a_sx: 0,
a_sy: 0,
b_id: 0,
b_sx: 0,
b_sy: 0,
},
getters: {
},
mutations: { // 用来给state赋值,相当于set(), 但是是私有的,
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;
},
updateGame(state, game) {
state.gamemap = game.map;
state.a_id = game.a_id;
state.a_sx = game.a_sx;
state.a_sy = game.a_sy;
state.b_id = game.b_id;
state.b_sx = game.b_sx;
state.b_sy = game.b_sy;
},
},
actions: { // 实现函数,公有函数,可以被外面调用,然后调用私有函数对变量进行赋值
},
modules: {
}
}
  • 前端:修改views/pk/PkIndexView.vue中的socket.onmessage函数。当匹配成功后,通过调用updateGame来将后端传过来的所有信息保存在store/pk.js中。store.commit("updateGame", data.game);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
socket.onmessage = msg => {
const data = JSON.parse(msg.data);
if(data.event === "success-matching") { // 表示匹配成功
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo,
});
setTimeout(() => {
store.commit("updateStatus", "playing");
}, 2000);
store.commit("updateGame", data.game);
}
}
</script>

实现效果

如上面所说,虽然不能直观的看出,但是对于玩家1的视角:自己在左下角,对手在右上角;玩家2的视角:自己在右上角,对手在左下角

image-20230323105120740

实现蛇的移动(同步)⭐⭐

因为每局游戏现在有三个棋盘,每个用户各一个棋盘,再加上云端维护一个棋盘。因此需要保证三个棋盘的状态同步。

因为如果出现了多局游戏对战,可能会发生冲突,因此需要将Game类改为支持多线程,每产生一局游戏都会开一个线程。

image-20230322200513253

实现步骤

后端实现

  • 将后端的consumer/utils/Game变为支持多线程的类
    • 继承Thread
    • 重写Thread类的入口函数run()方法
1
2
3
4
5
6
7
8
9
public class Game extends Thread {
/**
* 新线程的入口函数
*/
@Override
public void run() {
// 执行每回合的操作
}
}
  • consumer/WebSocketServerstartMatching函数中启动一个线程。当匹配成功后启动一个线程
    • 需要保存game对象:private Game game = null;
    • 修改startMatching函数,匹配成功后启动一个线程。启动线程后需要将game对象分别保存在两名玩家自己的game对象中。users.get(a.getId()).game = game; users.get(b.getId()).game = game;
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
private Game game = null;

private void startMatching() {
System.out.println("start matching");
matchpool.add(this.user);

while(matchpool.size() >= 2) {
Iterator<User> it = matchpool.iterator();
User a = it.next(), b = it.next();
matchpool.remove(a);
matchpool.remove(b);

// 生成地图
Game game = new Game(13, 14, 20, a.getId(), b.getId());
game.createMap();

// 启动一个线程,
game.start();
users.get(a.getId()).game = game;
users.get(b.getId()).game = game;

JSONObject respGame = new JSONObject();
respGame.put("a_id", game.getPlayerA().getId());
respGame.put("a_sx", game.getPlayerA().getSx());
respGame.put("a_sy", game.getPlayerA().getSy());
respGame.put("b_id", game.getPlayerB().getId());
respGame.put("b_sx", game.getPlayerB().getSx());
respGame.put("b_sy", game.getPlayerB().getSy());
respGame.put("map", game.getG());

// 往前端传递匹配成功的信息
JSONObject respA = new JSONObject();
respA.put("event", "success-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("game", respGame);
// 获取A的webSocket,并向前端发送信息
WebSocketServer webSocketServerA = users.get(a.getId());
webSocketServerA.sendMessage(respA.toJSONString());

JSONObject respB = new JSONObject();
respB.put("event", "success-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("game", respGame);
//获取B的webSocket并向前端发送信息
users.get(b.getId()).sendMessage(respB.toJSONString());

}
}
  • consumer/utils/Game.java中实现具体的操作逻辑⭐
    • 为了记录两名玩家的下一步操作,需要引入两个成员变量nextStepAnextStepB,初始都为null
    • 引入一个成员变量status,用来记录游戏的状态,playing表示正在进行游戏,finished表示游戏结束了。
    • 引入一个成员变量loser,记录游戏的输家。all表示为平局,A表示A输,B表示B输。
    • 因为nextStepAnextStepB可能两个线程同时读写,涉及到读写冲突,因此需要实现一个锁给每一个操作加锁。private ReentrantLock lock = new ReentrantLock();
    • 实现两个set函数,来给两个变量nextStepAnextStepB赋值。因为可能会产生读写冲突,因此需要加锁。
    • 实现nextStep()函数,等待两名玩家进行操作,如果5s内两名玩家都已经操作了,则返回true,否则返回false,则游戏就该结束了。
      • 注意:这边有个小细节,循环50次,每次睡100ms,如果时循环5次,每次睡1000ms,则可能按下键后他会把1s睡完才执行,会卡顿一下。
    • 实现run()函数,每一步操作先调用nextStep函数,来判断两个玩家是否都有输入了。
      • 如果没有获取下一步操作,修改游戏状态为finished,然后判断一些游戏的输家并记录在loser中,然后将结果发送给前端,通过调用sendResult函数
      • 如果获取到下一步操作,首先通过调用judge函数来判断两条蛇的操作是否合法:
        • 如果judge结束之后,游戏状态仍为playing,则向前端发送移动的信息,调用sendMove()函数
        • 否则,通过调用sendResult函数向前端发送结果信息。结束游戏。
    • 实现sendResult函数:需要使用JSONObject向前端传送信息,包括(游戏结束的提示event: result,以及输家loser),然后调用sendAllMessage函数向两个玩家都发送信息。
    • 实现sendMove函数:需要使用JSONObject向前端传送信息,包括(移动的提示event: move,以及两个玩家的移动方向a_direction: nextStepAb_direction: nextStepB,然后调用sendAllMessage函数向两个玩家都发送信息,发送玩信息后需要将nextStepAnextStepB清空。
    • 实现sendAllMessage函数:在这个函数中需要调用WebSocketServer类中的users,来找到两个玩家的websocket,在发送消息。
      • 修改WebSocketServeruserspublic,因为是静态的,所以可以直接通过类名.users使用。
      • 根据玩家的id来找到对应的websocket,并调用onMessage来向前端发送消息。WebSocketServer.users.get(playerA.getId()).sendMessage(message);
    • 实现judge函数:实现起来比较复杂,在这段代码后详细说明。
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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
private Integer nextStepA = null;  // 记录两个玩家的下一步操作
private Integer nextStepB = null;

private String status = "playing"; // 游戏状态, playing ---> finished
private String loser = ""; // all 平局,A:A输,B:B输

private ReentrantLock lock = new ReentrantLock(); // 定义一个锁,用来保证nextStepA和nextStepB的读写一致性,因为会涉及到两个线程的读写操作

public void setNextStepA(Integer nextStepA) {
lock.lock();
try {
this.nextStepA = nextStepA;
} finally {
lock.unlock();
}

}

public void setNextStepB(Integer nextStepB) {
lock.lock();
try {
this.nextStepB = nextStepB;
} finally {
lock.unlock();
}

}


/**
* 等待两个玩家的下一步操作
* @return
*/
private boolean nextStep() {
try { // 在接收下一步时先睡200ms,防止在前端渲染的过程中玩家的输入进行遗漏
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 这边有个小细节,循环50次,每次睡100ms,如果时循环5次,每次睡1000ms,则可能按下键后他会把1s睡完才执行,会卡顿一下。
for (int i = 0; i < 50; i++) {
try {
Thread.sleep(100); // 先睡100ms秒,短暂的释放锁,给玩家输入的时间
lock.lock(); // 1s过后,拿住锁,然后在判断两名玩家是否已经输入
try {
if(nextStepA != null && nextStepB != null) {
playerA.getSteps().add(nextStepA);
playerB.getSteps().add(nextStepB);
return true;
}
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return false;
}


/**
* 判断两名玩家下一步操作是否合法
*/
private void judge() {

}

/**
* 向所有玩家发送信息
*/
private void sendAllMessage(String message) {
WebSocketServer.users.get(playerA.getId()).sendMessage(message); // 这里的sendMessage是WebSocketServer里的
WebSocketServer.users.get(playerB.getId()).sendMessage(message);
}

/**
* 像两个client传送移动信息,这里是两名玩家的移动信息
*/
private void sendMove() {
lock.lock();
try {
JSONObject resp = new JSONObject();
resp.put("event", "move");
resp.put("a_direction", nextStepA);
resp.put("b_direction", nextStepB);
sendAllMessage(resp.toJSONString()); // 将移动信息返回给前端
nextStepA = nextStepB = null; // 将两个玩家的下一步清空
} finally {
lock.unlock();
}
}

/**
* 像两个client公布结果, 这里是游戏结果,
*/
private void sendResult() {
JSONObject resp = new JSONObject();
resp.put("event", "result");
resp.put("loser", loser);
sendAllMessage(resp.toJSONString());
}

/**
* 新线程的入口函数
*/
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
if (nextStep()) { // 是否获取两条蛇的下一步操作
judge();
if ("playing".equals(status)) { // 两名玩家的下一步操作合法
sendMove();
} else {
sendResult();
break;
}
} else {
status = "finished";
lock.lock(); // 涉及nextStepA和nextStepB的读取操作,需要加锁
try {
if (nextStepA == null && nextStepB == null)
loser = "all";
else if (nextStepA == null)
loser = "A";
else
loser = "B";
} finally {
lock.unlock();
}
sendResult();
break;
}
}
}
  • 实现judge函数:用来判断两条蛇的下一不操作是否合法。因此需要获取到两条蛇的身体。

    • 实现一个consumer/utils/Cell类,用来保存蛇身的某个节点的信息

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

      import lombok.AllArgsConstructor;
      import lombok.Data;
      import lombok.NoArgsConstructor;

      /**
      * @author xzt
      * @version 1.0
      * 蛇身体的某个节点
      */
      @Data
      @NoArgsConstructor
      @AllArgsConstructor
      public class Cell {
      Integer x;
      Integer y;
      }
    • consumer/utils/Play.java中实现获取蛇的全部节点函数getCells()

      • 实现getCells()函数,通过遍历每一步蛇的移动方向,来将新的节点加入res中,新的节点就是蛇头,同时我们需要判断当前回合是否会增加长度,如果不增加,则需要删掉蛇尾(也就是下标为0的节点)。判断完之后,需要更新loser来获得游戏的输家。
      • 实现check_tail_increasing(step)函数:如果是前十回合,则蛇会增加长度,否则每三回合增加一次。
      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
      /**
      * 检查当前回合,蛇的身体是否会增长.
      * 前十回合会增长,后面每三回合增长一次。
      * @param steps
      * @return
      */
      private boolean check_tail_increasing(int step) {
      if(step <= 10) return true;
      return step % 3 == 1;
      }

      /**
      * 返回蛇的身体,由若干个Cell组成
      * @return 当前对象的所有节点信息
      */
      public List<Cell> getCells() {
      List<Cell> res = new ArrayList<>();
      int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};
      int x = sx, y = sy;

      int step = 0;
      res.add(new Cell(x, y));
      for (int d : steps) { // 遍历每一回合蛇的移动方向
      x += dx[d];
      y += dy[d];
      res.add(new Cell(x, y));
      if (!check_tail_increasing(++ step)) { // 如果蛇尾不增加,
      res.remove(0); // 需要将蛇尾删掉
      }
      }
      return res;
      }
    • 实现judge函数,判断两个蛇的下一步操作是否合法。需要实现一个辅助函数check_valid(cellsA, cellsB),来判断cellsA的头节点是否是墙或者碰撞到cellsA或者cellsB的身体。

      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
      /**
      * 判断蛇A的头节点是否合法
      * @param cellsA
      * @param cellsB
      * @return
      */
      private boolean check_valid(List<Cell> cellsA, List<Cell> cellsB) {
      int n = cellsA.size();
      Cell cell = cellsA.get(n - 1); // 新的蛇头
      if(g[cell.x][cell.y] == 1) return false; // 如果蛇的最后一步是障碍物

      for (int i = 0; i < n - 1; i++) { // 判断和蛇A有没有碰撞
      if(cellsA.get(i).x == cell.x && cellsA.get(i).y == cell.y)
      return false;
      }

      for (int i = 0; i < n - 1; i++) { // 判断和蛇B有没有碰撞
      if(cellsB.get(i).x == cell.x && cellsB.get(i).y == cell.y)
      return false;
      }
      return true;
      }

      /**
      * 判断两名玩家下一步操作是否合法
      */
      private void judge() {
      // 取出两条蛇的所有节点
      List<Cell> cellsA = playerA.getCells();
      List<Cell> cellsB = playerB.getCells();

      boolean validA = check_valid(cellsA, cellsB);
      boolean validB = check_valid(cellsB, cellsA);
      // 如果两个玩家的操作存在不合法。
      if(!validA || !validB) {
      status = "finished";
      if(!validA && !validB) loser = "all";
      else if(!validA) loser = "A";
      else loser = "B";
      }
      }

前端实现

  • 修改views/pk/PkIndexView.vue中的socket.message函数,来对接收到的后端消息进行处理。

    • 之前已经写过当接收到的eventsuccess-matching时的逻辑代码。

    • 当接收到的eventmove时,表示两个玩家都有了合法的输入,需要在前端渲染移动

      • 为了能够取到两条蛇,所以需要将前端的GameMap对象保存下来。

        • store/pk.js中添加gameObject: null,并且实现updateGameObject函数。

        • components/GameMap.vue中的加载函数中生成GameMap对象并调用updateGameObject保存。

          1
          2
          3
          4
          5
          6
          7
          8
          <script>
          onMounted(() => {
          store.commit(
          "updateGameObject",
          new GameMap(canvas.value.getContext('2d'), parent.value, store) //创建一个GameMap对象
          );
          });
          </script>
      • 通过gameObject来获取两条蛇的对象,分别调用set_direction()函数来设置对应的移动方向。

    • 当接收到的eventresult时,表示游戏已经产生结果了,需要渲染失败的玩家

      • 同样也需要通过gameObject来获取两条蛇的对象,然后判断后端传过来的data.loser,来将对应的蛇的状态设置为die即可。
      • 同时,需要将前端的判断删除,删除scripts/Snake.js中的next_step()函数中的最后的if语句
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
<script>
socket.onmessage = msg => {
const data = JSON.parse(msg.data);
if(data.event === "success-matching") { // 表示匹配成功
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo,
});
setTimeout(() => {
store.commit("updateStatus", "playing");
}, 2000);
store.commit("updateGame", data.game);
} else if (data.event === "move") { // 接收到后端的消息为 需要移动
const game = store.state.pk.gameObject;
const [snake0, snake1] = game.snakes; // 取出两条蛇
snake0.set_direction(data.a_direction);
snake1.set_direction(data.b_direction);
} else if (data.event === "result") { // 游戏已经产生结果了
const game = store.state.pk.gameObject;
const [snake0, snake1] = game.snakes; // 取出两条蛇
if (data.loser === "all" || data.loser === "A") {
snake0.status = "die";
}
if (data.loser === "all" || data.loser === "B") {
snake1.status = "die";
}
}
}
</script>

实现效果

两条蛇可以交互移动,如果某一条蛇超过5s没操作或者装障碍物或者撞到两个蛇的身体,则这条蛇输。

image-20230323155220988

游戏结果优化

当游戏结束后,需要提示对应玩家的输赢信息。例如玩家A赢了,在玩家A的界面就现实Win,在玩家B的界面就显示Lose

实现步骤

这里是纯前端就可以实现,因为所需要的信息前面实现的后端已经满足了。

  • store/pk.js中添加变量loser用来保存输的玩家,同时实现updateLoser函数
1
2
3
4
5
6
7
8
9
10
export default {
state: {
loser: "none", // none / all / A / B
},
mutations: {
updateLoser(state, loser) {
state.loser = loser;
}
},
}
  • 修改views/pk/PkIndexView.vue,当游戏产生结果后,调用updateLoserloser进行更新

  • 实现components/ResultBoard.vue

    • 现实 Draw/Win/Lose 需要根据$store.state.pk.loser的内容以及当前用户的id来进行判断。
    • 实现触发函数restart()当点击再来时,需要更新一些全局变量,将整个游戏的状态改为matching,则会自动跳转至匹配界面,将loser重新设置为none,将对手的头像和名字设为默认值。
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
<template>
<div class="result-board">
<div class="result-board-text" v-if="$store.state.pk.loser === 'all'">
Draw
</div>
<div class="result-board-text" v-else-if="$store.state.pk.loser === 'A' && $store.state.pk.a_id === parseInt($store.state.user.id)">
Lose
</div>
<div class="result-board-text" v-else-if="$store.state.pk.loser === 'B' && $store.state.pk.b_id === parseInt($store.state.user.id)">
Lose
</div>
<div class="result-board-text" v-else>
Win
</div>
<div class="result-board-button">
<button @click="restart" type="button" class="btn btn-warning btn-lg">
再来!
</button>
</div>
</div>
</template>

<script>
import { useStore } from 'vuex';

export default {
setup() {
const store = useStore();
const restart = () => {
store.commit("updateStatus", "matching"); // 进入匹配界面
store.commit("updateLoser", "none");
store.commit("updateOpponent", {
username: "我的对手",
photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",
});
};

return {
restart,
};
}
}

</script>

<style scoped>
div.result-board{
height: 30vh;
width: 30vw;
background-color: rgba(50, 50, 50, 0.5);
position: absolute;
top: 30vh;
left: 35vw;
}
div.result-board-text {
text-align: center;
color: white;
font-size: 50px;
font-weight: 600;
font-style: italic;
padding-top: 5vh;
}
div.result-board-button {
text-align: center;
padding-top: 7vh;
}
</style>
  • 修改views/pk/PkIndexView.vue,导入ResultBoard组件,当$store.state.pk.loser != 'none'显示该组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<PlayGround v-if="$store.state.pk.status === 'playing'"></PlayGround>
<MatchGround v-if="$store.state.pk.status === 'matching'"></MatchGround>
<ResultBoard v-if="$store.state.pk.loser != 'none'"></ResultBoard>
</template>

<script>
import ResultBoard from '@/components/ResultBoard.vue'
export default {
components: {
ResultBoard,
},
}
</script>

实现效果

image-20230323165124198

点击在来按钮时,跳转回匹配界面

image-20230323165136618

游戏对战记录保存

数据库配置

record表用来记录每局对战的信息

表中的列:

  • id: int
  • a_id: int
  • a_sx: int
  • a_sy: int
  • b_id: int
  • b_sx: int
  • b_sy: int
  • a_steps: varchar(1000)
  • b_steps: varchar(1000)
  • map: varchar(1000)
  • loser: varchar(10)
  • createtime: datetime

image-20230323165412377

代码实现

  • 实现Record对应的实体类pojo/Record
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
package com.kob.backend.pojo;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.util.Date;

/**
* @author xzt
* @version 1.0
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Record {
@TableId(type = IdType.AUTO)
private Integer id;
private Integer aId;
private Integer aSx;
private Integer aSy;
private Integer bId;
private Integer bSx;
private Integer bSy;
private String aSteps;
private String bSteps;
private String map;
private String loser;

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
private Date createTime;
}
  • 实现mapper/RecordMapper
1
2
3
4
5
6
7
8
9
10
11
12
13
package com.kob.backend.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.kob.backend.pojo.Record;
import org.apache.ibatis.annotations.Mapper;

/**
* @author xzt
* @version 1.0
*/
@Mapper
public interface RecordMapper extends BaseMapper<Record> {
}
  • consumer/WebSocketServer中实现RecordMapper,并编写对应的set函数。
1
2
3
4
5
6
public static RecordMapper recordMapper;

@Autowired
public void setRecordMapper(RecordMapper recordMapper) {
WebSocketServer.recordMapper = recordMapper;
}
  • consumer/utils/Game中编写saveToDatabase函数,实现将对战结果保存到数据库中,需要在sendResult中调用该函数。

    • 因为consumer/utils/Play中的steps是List类型,因此需要编写一个函数将steps转为String类型。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      /**
      * 将steps转为String保存
      * @return
      */
      public String getStepsString() {
      StringBuilder res = new StringBuilder();
      for(int d: steps) {
      res.append(d);
      }
      return res.toString();
      }
    • consumer/utils/Game中编写函数getMapString函数将map转为String类型。

      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
      /**
      * 将map转为String类型
      * @return
      */
      private String getMapString() {
      StringBuilder res = new StringBuilder();
      for (int i = 0; i < rows; i++) {
      for (int j = 0; j < cols; j++) {
      res.append(g[i][j]);
      }
      }
      return res.toString();
      }

      /**
      * 将对战记录保存到数据库中。
      */
      private void saveToDatabase() {
      Record record = new Record(
      null,
      playerA.getId(),
      playerA.getSx(),
      playerA.getSy(),
      playerB.getId(),
      playerB.getSx(),
      playerB.getSy(),
      playerA.getStepsString(),
      playerB.getStepsString(),
      getMapString(),
      loser,
      new Date()
      );
      // 保存在数据库
      WebSocketServer.recordMapper.insert(record);
      }

      /**
      * 像两个client公布结果, 这里是游戏结果,
      */
      private void sendResult() {
      JSONObject resp = new JSONObject();
      resp.put("event", "result");
      resp.put("loser", loser);
      saveToDatabase();
      sendAllMessage(resp.toJSONString());
      }

实现效果

当对局结束后,会将对局结果和对局记录保存到数据库中。

image-20230327144635988

数据库数据

image-20230327144713998

实现匹配系统的微服务⭐

项目框架

image-20230327150430601

微服务项目创建&配置

创建spring cloud项目

  • 创建项目

image-20230327150622450

  • 添加spring web依赖

image-20230327150708383

配置Spring Cloud项目

  • 因为spring cloud是一个父级项目,所以将src目录删除
  • pom.xml中添加依赖
    • 添加<packaging>pom</packaging>
    • 添加spring-cloud-dependencies依赖
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
<?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.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.kob</groupId>
<artifactId>backendcloud</artifactId>
<packaging>pom</packaging>

<version>0.0.1-SNAPSHOT</version>
<name>backendcloud</name>
<description>backendcloud</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-test</artifactId>
<scope>test</scope>
</dependency>

<!-- https://mvnrepository.com/artifact/org.springframework.cloud/spring-cloud-dependencies -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>

</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

创建子项目

image-20230327151644680

创建子项目matchingSystem

创建项目matchingsystem

image-20230327151727702

项目结构如下

image-20230327151811931

创建子项目Backend

同样的方法创建一个新的Module:backend

将之前实现的backend中的src以及pom.xml中的依赖复制到新的模块中

项目结构如下:

image-20230327154419855

pom.xml文件内容如下:

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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>backendcloud</artifactId>
<groupId>com.kob</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<groupId>com.kob.backend</groupId>
<artifactId>backend</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-jdbc -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>2.7.1</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.projectlombok/lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>

<!-- https://mvnrepository.com/artifact/mysql/mysql-connector-java -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.29</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-boot-starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-generator -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
<version>3.5.3</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.7.1</version>
</dependency>

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-impl -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-jackson -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-websocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
<version>2.7.2</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.alibaba.fastjson2/fastjson2 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.11</version>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

实现匹配系统matchingsystem

pom配置

backendcloudpom.xml中的dependencies剪切至matchingsystem中的pom.xml

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
<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>backendcloud</artifactId>
<groupId>com.kob</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>

<groupId>com.kob.matchingsystem</groupId>
<artifactId>matchingsystem</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</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-test</artifactId>
<scope>test</scope>
</dependency>

<!-- https://mvnrepository.com/artifact/org.springframework.cloud/spring-cloud-dependencies -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2021.0.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>

</project>

项目端口配置

matchingsystem/src/main/resources/application.properties中配置

1
server.port=3001

matchingsystem匹配代码实现

添加/移除玩家代码实现

  • 实现匹配的service接口matchingsystem/service/MatchingService
1
2
3
4
5
6
7
8
9
10
package com.kob.matchingsystem.service;

/**
* @author xzt
* @version 1.0
*/
public interface MatchingService {
public String addPlayer(Integer userId, Integer rating);
public String removePlayer(Integer userId);
}
  • 实现对应的实现类matchingsystem/service/impl/MatchingServiceImpl。这边只是简单输出一些信息,用来判断功能是否可用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.kob.matchingsystem.service.impl;

import com.kob.matchingsystem.service.MatchingService;
import org.springframework.stereotype.Service;

/**
* @author xzt
* @version 1.0
*/
@Service
public class MatchingServiceImpl implements MatchingService {
@Override
public String addPlayer(Integer userId, Integer rating) {
System.out.println("add Player" + userId + " " + rating);
return "add player success";
}

@Override
public String removePlayer(Integer userId) {
System.out.println("remove player" + userId);
return "remove player success";
}
}
  • 实现controller, matchingsystem/controller/MatchingController,这边接收参数需要使用MultiValueMap
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
package com.kob.matchingsystem.controller;

import com.kob.matchingsystem.service.MatchingService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Objects;

/**
* @author xzt
* @version 1.0
*/
@RestController
@RequestMapping("/player")
public class MatchingController {
@Autowired
private MatchingService matchingService;

@PostMapping("/add")
public String addPlayer(@RequestParam MultiValueMap<String, String> data) {
Integer userId = Integer.parseInt(Objects.requireNonNull(data.getFirst("user_id")));
Integer rating = Integer.parseInt(Objects.requireNonNull(data.getFirst("rating")));
return matchingService.addPlayer(userId, rating);
}

@PostMapping("/remove")
public String removePlayer(@RequestParam MultiValueMap<String, String> data) {
Integer userId = Integer.parseInt(Objects.requireNonNull(data.getFirst("user_id")));
return matchingService.removePlayer(userId);
}
}

恶意链接拦截

这里只允许通过本地的链接(127.0.0.1)进行访问,防止恶意访问

  • 导入spring security依赖包
1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.7.1</version>
</dependency>
  • 编写matchingsystem/config/SecurityConfig配置类
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
package com.kob.matchingsystem.config;



import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;

/**
* @author xzt
* @version 1.0
* 实现用户密码的加密存储, 放行登录,注册等接口
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/player/add/", "/player/remove/").hasIpAddress("127.0.0.1") // 只允许本地访问
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();
}
}

backend匹配代码改写

  • 实现一个backend/config/RestTemplateConfig,用来返回一个RestTemplate对象,【RestTemplate 可以在两个spring boot之间进行通讯】,实现微服务通讯

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    package com.kob.backend.config;

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.client.RestTemplate;

    /**
    * @author xzt
    * @version 1.0
    */
    @Configuration
    public class RestTemplateConfig {
    @Bean
    public RestTemplate getRestTemplate() {
    // RestTemplate 可以在两个spring boot之间进行通讯
    return new RestTemplate();
    }
    }
  • backend/consumer/WebSocketServer引入RestTemplate对象

  • 首先修改数据库,将rating字段放在user表中,而不放在bot表中。需要修改对应的实现类BotUser,同时需要修改对应的一些类registerupdateBotaddBot

  • 然后修改backend/consumer/WebSocketServer,将匹配成功 后生成地图、启动线程以及像前端发送消息单独放在一个函数中startGame,(之前实在startMatching函数中)
  • 将所有有关matchpool(之前实现的模拟线程池)的操作删除,因为我们需要使用匹配系统微服务进行实现,需要删除的函数包括onClosestopMatchingstartMatching
  • 实现对应的参数设置以及使用RestTemplate对象进行匹配系统之间的通讯。
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
82
83
84
85
private static RestTemplate restTemplate;

@Autowired
public void setRestTemplate(RestTemplate restTemplate) {
WebSocketServer.restTemplate = restTemplate;
}

@OnClose
public void onClose() {
// 关闭链接
System.out.println("disconnected!");
if(this.user != null) {
users.remove(this.user.getId());
MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
data.add("user_id", this.user.getId().toString());
restTemplate.postForObject(removePlayerUrl, data, String.class);
}
}

/**
* 匹配成功后,开始游戏前做的内容
* @param aId
* @param bId
*/
private void startGame(Integer aId, Integer bId) {
User a = userMapper.selectById(aId);
User b = userMapper.selectById(bId);

// 生成地图
Game game = new Game(13, 14, 20, a.getId(), b.getId());
game.createMap();

// 启动一个线程,
game.start();
users.get(a.getId()).game = game;
users.get(b.getId()).game = game;

JSONObject respGame = new JSONObject();
respGame.put("a_id", game.getPlayerA().getId());
respGame.put("a_sx", game.getPlayerA().getSx());
respGame.put("a_sy", game.getPlayerA().getSy());
respGame.put("b_id", game.getPlayerB().getId());
respGame.put("b_sx", game.getPlayerB().getSx());
respGame.put("b_sy", game.getPlayerB().getSy());
respGame.put("map", game.getG());

// 往前端传递匹配成功的信息
JSONObject respA = new JSONObject();
respA.put("event", "success-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("game", respGame);
// 获取A的webSocket,并向前端发送信息
WebSocketServer webSocketServerA = users.get(a.getId());
webSocketServerA.sendMessage(respA.toJSONString());

JSONObject respB = new JSONObject();
respB.put("event", "success-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("game", respGame);
//获取B的webSocket并向前端发送信息
users.get(b.getId()).sendMessage(respB.toJSONString());
}

/**
* 开始匹配函数
*/
private void startMatching() {
System.out.println("start matching");
MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
data.add("user_id", this.user.getId().toString());
data.add("rating", this.user.getRating().toString());
restTemplate.postForObject(addPlayerUrl, data, String.class); // 向matchingsystem发送请求 (请求链接,请求参数,返回值类型)
}

/**
* 取消匹配
*/
private void stopMatching() {
System.out.println("stop matching");
MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
data.add("user_id", this.user.getId().toString());
restTemplate.postForObject(removePlayerUrl, data, String.class);
}

实现效果

点击开始匹配后,调用/player/add/,输出对应的信息

image-20230327162358566

点击取消匹配后,调用/player/remove,输出对应的信息

image-20230327162434446

匹配系统业务逻辑实现

需要实现matchingsystem/service/impl/MatchingServiceImpl中的两个方法。

这里的匹配逻辑是:当接收一个玩家发送匹配请求后,将玩家信息存入一个池子(数组)中,开一个新的线程,每隔1s扫描一遍整个数组,将能够匹配的玩家匹配在一起。

匹配的时候需要将两名分值比较接近的玩家匹配在一起。随着时间的推移,两名玩家允许的分差可以越来越大。同时等待时间长的人优先匹配.

代码实现

  • 首先在matchingsystem/service/impl/utils中实现一个类Player,用来保存正在参加匹配的用户的信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.kob.matchingsystem.service.impl.utils;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
* @author xzt
* @version 1.0
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Player {
private Integer userId;
private Integer rating;
private Integer waitingTime; // 等待时间
}

  • matchingsystem中实现一个线程池,用来保存所有正在匹配的用户信息,
    • 实现matchingsystem/service/utils/MatchingPool类,需要继承Thread类,实现多线程。
    • 使用List<Player>来保存所有正在匹配的用户。因为可能被多个线程同时读写,因此需要定义一个锁ReentrantLock,用来保证读写不冲突。
    • 重写Thread类中的run方法。用来每秒循环一次,每次循环需要通过increaseWaitingTime函数实现每个正在匹配的用户的等待时间加1,然后调用matchPlayers函数进行玩家匹配。
    • increaseWaitingTime函数:就是循环遍历一遍所有正在匹配的用户,给每个用户的等待时间加1即可。
    • matchPlayers函数:首先定义一个布尔类型的数组,用来保存哪个用户已经匹配成功。然后双层循环遍历,判断两两玩家是否匹配,若都没有匹配,则调用checkMatch函数进行判断当前两个用户是否可以匹配。如果可以匹配,则通过sendResult函数向backend发送匹配的玩家的消息。最后将匹配成功的用户从List中移除
    • checkMatch函数:首先计算两个用户rating差值,只有当用户的战力差小于等于两个玩家中最小的等待时间*10才能匹配成功。
    • sendResult函数:定义一个MultiValueMap来保存匹配成功的两个用户的id,并通过restTemplatebackend发送.
      • 这里需要导入matchingsystem/config/RestTemplate类。
    • matchingsystem/MatchingSystemApplication的主函数中启动匹配线程。⭐意思是当这个服务启动时,就开始每1s检测一下是否存在可以匹配的两个用户。
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
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
package com.kob.matchingsystem.service.impl.utils;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;

/**
* @author xzt
* @version 1.0
*/
@Component
public class MatchingPool extends Thread{
private static List<Player> players = new ArrayList<>(); // 多个线程调用,涉及读写冲突
private ReentrantLock lock = new ReentrantLock();

private static RestTemplate restTemplate;

private final static String startGameUrl = "http://127.0.0.1:3000/pk/game/start/";

@Autowired
public void setRestTemplate(RestTemplate restTemplate) {
MatchingPool.restTemplate = restTemplate;
}

public void addPlayer(Integer userId, Integer rating) {
lock.lock();
try {
players.add(new Player(userId, rating, 0));
}finally {
lock.unlock();
}
}

public void removePlayer(Integer userId) {
lock.lock();
try {
List<Player> newPlayers = new ArrayList<>();
for (Player player : players) {
if (!player.getUserId().equals(userId)) {
newPlayers.add(player);
}
}
players = newPlayers;
}finally {
lock.unlock();
}
}

/**
* 将所有玩家的等待时间加1
*/
private void increaseWaitingTime() {
for(Player player : players ) {
player.setWaitingTime(player.getWaitingTime() + 1);
}
}

/**
* 判断两名玩家是否匹配
* @param a 玩家a
* @param b 玩家b
* @return true 已匹配,false未匹配
*/
private boolean checkMatch(Player a, Player b) {
int ratingDelta = Math.abs(a.getRating() - b.getRating());
// 分差需要能被a和b都接收,因此需要保证分差小于a和b的等待时间*10的最小值
int waitingTime = Math.min(a.getWaitingTime(), b.getWaitingTime());
return ratingDelta <= waitingTime * 10;
}

/**
* 返回a和b的匹配结果
* @param a
* @param b
*/
private void sendResult(Player a, Player b) {
System.out.println("send result: " + a + " " + b);
MultiValueMap<String, String> data = new LinkedMultiValueMap<>();
data.add("a_id", a.getUserId().toString());
data.add("b_id", b.getUserId().toString());
restTemplate.postForObject(startGameUrl, data, String.class);
}

/**
* 尝试匹配所有玩家,优先匹配等待时间久的玩家,也就是下标小的玩家
*/
private void matchPlayers() {
System.out.println("match players: " + players.toString());
boolean[] used = new boolean[players.size()];
for (int i = 0; i < players.size(); i++) {
if (used[i]) continue;
for (int j = i + 1; j < players.size(); j++) {
if(used[j]) continue;
Player a = players.get(i), b = players.get(j);
if(checkMatch(a, b)) {
used[i] = used[j] = true;
sendResult(a, b);
break;
}
}
}
// 将匹配成功的玩家删除
List<Player> newPlayers = new ArrayList<>();
for (int i = 0; i < players.size(); i++) {
if(!used[i])
newPlayers.add(players.get(i));
}
players = newPlayers;
}

@Override
public void run() {
while(true) {
try {
Thread.sleep(1000);
lock.lock();
try {
increaseWaitingTime();
matchPlayers();
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
}
}
}
  • 在backend中实现:

    • 首先修改backend/config/SecurityConfig中的configure函数,将/pk/game/start链接方向,并且只能通过本地访问。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @Override
      protected void configure(HttpSecurity http) throws Exception {
      http.csrf().disable()
      .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
      .and()
      .authorizeRequests()
      .antMatchers("/user/account/token/", "/user/account/register/").permitAll()
      .antMatchers("/pk/game/start/").hasIpAddress("127.0.0.1")
      .antMatchers(HttpMethod.OPTIONS).permitAll()
      .anyRequest().authenticated();

      http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
      }
    • 实现对应的backeng/service/pk/StartGameService以及backend/service/impl/pk/StartGameServiceImpl,在StartGameServiceImpl中通过传来的匹配成功的两个玩家的id,然后调用WebSocketServer.startGame函数,生成地图、向前端返回等一系列操作。这里需要把startGame函数变为public static

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      package com.kob.backend.service.pk;

      /**
      * @author xzt
      * @version 1.0
      */
      public interface StartGameService {
      String startGame(Integer aId, Integer bId);
      }

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      package com.kob.backend.service.impl.pk;

      import com.kob.backend.consumer.WebSocketServer;
      import com.kob.backend.service.pk.StartGameService;
      import org.springframework.stereotype.Service;

      /**
      * @author xzt
      * @version 1.0
      */
      @Service
      public class StartGameServiceImpl implements StartGameService {
      @Override
      public String startGame(Integer aId, Integer bId) {
      System.out.println("start game: " + aId + " " + bId);
      WebSocketServer.startGame(aId, bId);
      return "start game success";
      }
      }

    • 实现backend/controller/pk/StartGameController,接收从matchingsystem中传来的id

      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
      package com.kob.backend.controller.pk;

      import com.kob.backend.service.pk.StartGameService;
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.util.MultiValueMap;
      import org.springframework.web.bind.annotation.PostMapping;
      import org.springframework.web.bind.annotation.RequestMapping;
      import org.springframework.web.bind.annotation.RequestParam;
      import org.springframework.web.bind.annotation.RestController;

      import java.util.Objects;

      /**
      * @author xzt
      * @version 1.0
      */
      @RestController
      @RequestMapping("/pk")
      public class StartGameController {

      @Autowired
      private StartGameService startGameService;

      @PostMapping("/game/start")
      public String startGame(@RequestParam MultiValueMap<String, String> data) {
      int aId = Integer.parseInt(Objects.requireNonNull(data.getFirst("a_id")));
      int bId = Integer.parseInt(Objects.requireNonNull(data.getFirst("b_id")));
      return startGameService.startGame(aId, bId);
      }
      }

实现效果

这里将用户qqy的rating改为了1400,用户xzt的rating为1500,因此理论来说需要匹配10s才能匹配成功。

image-20230327200752649

可以看到,10s后匹配成功了。并且当匹配成功后,匹配池会将匹配成功的两名玩家删除。

image-20230327200822695

业务逻辑优化

存在的问题

当两名玩家正在匹配,此时其中一名玩家将匹配页面直接关闭,但是匹配池中还会存在,这个时候另一个玩家就不会匹配成功,并且会在后台报异常。

这是因此如果关闭页面则该用户的users就不存在,所以需要在backend中的所有存在users.get位置加上判断。

  • 修改backend/consumer/WebSocketServer中的startGame函数
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
/**
* 匹配成功后,开始游戏前做的内容
* @param aId 玩家1id
* @param bId 玩家2id
*/
public static void startGame(Integer aId, Integer bId) {
User a = userMapper.selectById(aId);
User b = userMapper.selectById(bId);

// 生成地图
Game game = new Game(13, 14, 20, a.getId(), b.getId());
game.createMap();

// 启动一个线程,
game.start();
if (users.get(a.getId()) != null)
users.get(a.getId()).game = game;
if (users.get(b.getId()) != null)
users.get(b.getId()).game = game;

JSONObject respGame = new JSONObject();
respGame.put("a_id", game.getPlayerA().getId());
respGame.put("a_sx", game.getPlayerA().getSx());
respGame.put("a_sy", game.getPlayerA().getSy());
respGame.put("b_id", game.getPlayerB().getId());
respGame.put("b_sx", game.getPlayerB().getSx());
respGame.put("b_sy", game.getPlayerB().getSy());
respGame.put("map", game.getG());

// 往前端传递匹配成功的信息
JSONObject respA = new JSONObject();
respA.put("event", "success-matching");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("game", respGame);
// 获取A的webSocket,并向前端发送信息
if(users.get(a.getId()) != null) {
WebSocketServer webSocketServerA = users.get(a.getId());
webSocketServerA.sendMessage(respA.toJSONString());
}

JSONObject respB = new JSONObject();
respB.put("event", "success-matching");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("game", respGame);
//获取B的webSocket并向前端发送信息
if(users.get(b.getId()) != null)
users.get(b.getId()).sendMessage(respB.toJSONString());
}
  • 修改backend/consumer/utils/Game中的sendAllMessage函数
1
2
3
4
5
6
7
8
9
/**
* 向所有玩家发送信息
*/
private void sendAllMessage(String message) {
if(WebSocketServer.users.get(playerA.getId()) != null)
WebSocketServer.users.get(playerA.getId()).sendMessage(message); // 这里的sendMessage是WebSocketServer里的
if(WebSocketServer.users.get(playerB.getId()) != null)
WebSocketServer.users.get(playerB.getId()).sendMessage(message);
}
正在加载今日诗词....