SpringBoot学习笔记-实现微服务:匹配系统(上)

  1. 1. 配置WebSocket
  2. 2. 前后端WebSocket通信
    1. 2.1 WS通信的建立
    2. 2.2 加入JWT验证
  3. 3. 前后端匹配业务
    1. 3.1 实现前端页面
    2. 3.2 实现前后端交互逻辑
    3. 3.3 同步游戏地图

本节内容为实现前后端的 WebSocket 通信,并同步游戏地图的生成。

我们的游戏之后是两名玩家对战,因此需要实现联机功能,在这之前还需要实现一个匹配系统,能够匹配分数相近的玩家进行对战。

想要进行匹配就至少要有两个客户端,当两个客户端都向服务器发送匹配请求后并不会马上得到返回结果,一般会等待一段时间,这个时间是未知的,因此这个匹配是一个异步的过程,对于这种异步的过程或者是计算量比较大的过程我们都会用一个额外的服务来操作。

那么这个额外的用于匹配的服务可以称为 Matching System,这是另外一个程序(进程),当后端服务器接收到前端的请求后就会将请求发送给 Matching System,这个匹配系统维护了一堆用户的集合,它会不断地去匹配分数最接近的用户,当匹配成功一组用户后就会将结果返回给后端服务器,再由后端将匹配结果立即返回给对应的前端。这种服务就被称为微服务,可以用 Spring Cloud 实现。

用以前的 HTTP 请求很难达到这种效果,之前我们是在客户端向后端发送请求,且后端在短时间内就会返回结果,HTTP 请求只能满足这种一问一答式的服务。而我们现在需要实现的效果是客户端发送请求后不知道经过多长时间后端才会返回结果,对于这种情况需要使用 WebSocket 协议(WS),该协议不仅支持客户端向服务器发送请求,也支持服务器向客户端发送请求。

在前端向服务器发送请求后,服务器会维护好一个 WS 链接,这个链接其实就是一个 WebSocketServer 类的实例,所有和这个链接相关的信息都会存到这个类中。

1. 配置WebSocket

我们之前每次刷新网页就会随机生成游戏地图,该过程是在浏览器本地执行的,当我们要实现匹配功能时,地图就不能由两名玩家各自的客户端生成,否则就基本不可能完全一样了。

当匹配成功后应该由服务器端创建一个 Game 任务,将游戏放到该任务下执行,统一生成地图,且判断移动或者输赢等逻辑之后也应该移到后端来执行。

生成好地图后服务器就将地图传给两名玩家的前端,然后等待玩家的键盘输入或者是 Bot 代码的输入,Bot 代码的输入也属于一个微服务。

首先我们先在 pom.xml 文件中添加以下依赖:

  • spring-boot-starter-websocket
  • fastjson2

接着在 config 包下创建 WebSocketConfig 配置类:

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

然后我们创建一个 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
30
31
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();
}
}

之前我们配置的 Spring Security 设置了屏蔽除了授权之外的其他所有链接,因此我们需要在 SecurityConfig 类中放行一下 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
package com.kob.backend.config;

import com.kob.backend.config.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
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;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception { // AuthenticationManager用于处理身份验证
return super.authenticationManagerBean();
}

@Override
protected void configure(HttpSecurity http) throws Exception { // 配置HttpSecurity
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/account/login/", "/user/account/register/").permitAll() // 需要公开的链接在这边写即可
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();

http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}

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

如果是使用新版的配置而不是使用 WebSecurityConfigurerAdapter 可以按以下方式配置:

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

import com.kob.backend.config.filter.JwtAuthenticationTokenFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
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.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/user/account/login/", "/user/account/register/").permitAll()
.antMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated();

http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}

@Bean
public WebSecurityCustomizer webSecurityCustomizer(){
return (web) -> web.ignoring().antMatchers("/websocket/**");
}
}

2. 前后端WebSocket通信

2.1 WS通信的建立

WebSocket 不属于单例模式(同一个时间每个类只能有一个实例,我们每建一个 WS 链接都会新创建一个实例),不是标准的 Spring 中的组件,因此在注入 Mapper 时不能用 @Autowired 直接注入,一般是将 @Autowired 写在一个 set() 方法上,Spring 会根据方法的参数类型从 IoC 容器中找到该类型的 Bean 对象注入到方法的行参中,并且自动反射调用该方法。

我们先假设前端传过来的是用户 ID 而不是 JWT 令牌:

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

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;

@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
// ConcurrentHashMap是一个线程安全的哈希表,用于将用户ID映射到WS实例
private static final 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) {
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();
}
}
}
}

然后我们先在前端的 PKIndexView 组件中调试,当组件被挂载完成后发出请求建立 WS 链接,当被卸载后关闭 WS 链接:

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

let socket = null;
let socket_url = `ws://localhost:3000/websocket/${store.state.user.id}/`;

onMounted(() => {
socket = new WebSocket(socket_url);

store.commit("updateOpponent", {
username: "我的对手",
photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",
});

socket.onopen = () => { // 链接成功建立后会执行
console.log("Connected!");
store.commit("updateSocket", socket);
};

socket.onmessage = (msg) => { // 接收到后端消息时会执行
const data = JSON.parse(msg.data); // Spring传过来的数据是放在消息的data中
console.log(data);
};

socket.onclose = () => { // 关闭链接后会执行
console.log("Disconnected!");
};
});

onUnmounted(() => {
socket.close(); // 如果不断开链接每次切换页面都会创建新链接,就会导致有很多冗余链接
});
},
};
</script>

<style scoped></style>

现在我们在对战页面每次刷新后都可以在浏览器控制台或后端控制台中看到 WS 的输出信息。

接下来我们要将 WebSocket 存到前端的 store 中,在 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
export default {
state: {
status: "matching", // 当前状态,matching表示正在匹配,playing表示正在对战
socket: null, // 前端和后端建立的链接
opponent_username: "", // 对手的用户名
opponent_photo: "", // 对手的头像
},
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: {},
};

同时要在 store/index.js 中引入进来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { createStore } from "vuex";
import ModuleUser from "./user";
import ModulePk from "./pk";

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

2.2 加入JWT验证

现在我们直接使用用户的 ID 建立 WS 链接,这是不安全的,因为前端可以自行修改这个 ID,因此就需要加入 JWT 验证。

WebSocket 中没有 Session 的概念,因此我们在验证的时候前端就不用将信息放到表头里了,直接放到链接中就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...

<script>
...

export default {
...

setup() {
...

let socket_url = `ws://localhost:3000/websocket/${store.state.user.jwt_token}/`;

...
},
};
</script>

...

验证的逻辑可以参考之前的 JwtAuthenticationTokenFilter,我们可以把这个验证的模块单独写到一个文件中,在 consumer 包下创建 utils 包,然后创建一个 JwtAuthentication 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 = Integer.parseInt(claims.getSubject());
} catch (Exception e) {
throw new RuntimeException(e);
}
return userId;
}
}

然后就可以在 WebSocketServer 中解析 JWT 令牌:

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

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

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

@OnOpen
public void onOpen(Session session, @PathParam("token") String token) throws IOException {
this.session = session;
System.out.println("Connected!");

Integer userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);

if (user != null) {
users.put(userId, this);
} else {
this.session.close();
}
}

...
}

3. 前后端匹配业务

3.1 实现前端页面

我们需要实现一个前端的匹配页面,并能够切换匹配和对战页面,可以根据之前在 store 中存储的 status 状态来动态展示页面。首先在 components 目录下创建 MatchGround.vue 组件,其中需要展示玩家自己的头像和用户名以及对手的头像和用户名,当点击开始匹配按钮时向 WS 链接发送开始匹配的消息,点击取消按钮时发送取消匹配的消息:

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
<template>
<div class="matchground">
<div class="row">
<div class="col-md-6" style="text-align: center;">
<div class="photo">
<img class="img-fluid" :src="$store.state.user.photo">
</div>
<div class="username">
{{ $store.state.user.username }}
</div>
</div>
<div class="col-md-6" style="text-align: center;">
<div class="photo">
<img class="img-fluid" :src="$store.state.pk.opponent_photo">
</div>
<div class="username">
{{ $store.state.pk.opponent_username }}
</div>
</div>
<div class="col-md-12 text-center" style="margin-top: 14vh;">
<button @click="click_match_btn" type="button" class="btn btn-info 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({ // 将json封装成字符串发送给后端,后端会在onMessage()中接到请求
event: "start_match", // 表示开始匹配
}));
} else {
match_btn_info.value = "开始匹配";
store.state.pk.socket.send(JSON.stringify({
event: "stop_match", // 表示停止匹配
}));
}
};

return {
match_btn_info,
click_match_btn,
};
},
};
</script>

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

img {
width: 35%;
border-radius: 50%;
margin: 14vh 0 1vh 0;
}

.username {
font-size: 24px;
font-weight: bold;
color: white;
}
</style>

3.2 实现前后端交互逻辑

当用户点击开始匹配按钮后,前端要向服务器发出一个请求,后端接收到请求后应该将该用户放入匹配池中,由于目前还没有实现微服务,因此我们先在 WebSocketServer 后端用一个 Set 维护正在匹配的玩家,当匹配池中满两名玩家就将其匹配在一起,然后将匹配结果返回给两名玩家的前端:

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

import com.alibaba.fastjson2.JSONObject;
import com.kob.backend.consumer.utils.JwtAuthentication;
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.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;

@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {
// ConcurrentHashMap是一个线程安全的哈希表,用于将用户ID映射到WS实例
private static final ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
// CopyOnWriteArraySet也是线程安全的
private static final CopyOnWriteArraySet<User> matchPool = new CopyOnWriteArraySet<>(); // 匹配池
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) throws IOException {
this.session = session;
System.out.println("Connected!");

Integer userId = JwtAuthentication.getUserId(token);
this.user = userMapper.selectById(userId);

if (user != null) {
users.put(userId, this);
} else {
this.session.close();
}
}

@OnClose
public void onClose() {
System.out.println("Disconnected!");
if (this.user != null) {
users.remove(this.user.getId());
matchPool.remove(this.user);
}
}

@OnMessage
public void onMessage(String message, Session session) { // 一般会把onMessage()当作路由
System.out.println("Receive message!");
JSONObject data = JSONObject.parseObject(message);
String event = data.getString("event"); // 取出event的内容

if ("start_match".equals(event)) {
this.startMatching();
} else if ("stop_match".equals(event)) {
this.stopMatching();
}
}

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

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(); // 发送给A的信息
respA.put("event", "match_success");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
users.get(a.getId()).sendMessage(respA.toJSONString()); // A不一定是当前链接,因此要在users中获取

JSONObject respB = new JSONObject(); // 发送给B的信息
respB.put("event", "match_success");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
users.get(b.getId()).sendMessage(respB.toJSONString());
}
}

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

接着修改一下 PKIndexView,当接收到 WS 链接从后端发送过来的匹配成功消息后需要更新对手的头像和用户名:

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
...

<script>
...

export default {
...

setup() {
...

onMounted(() => {
...

socket.onmessage = (msg) => { // 接收到后端消息时会执行
const data = JSON.parse(msg.data); // Spring传过来的数据是放在消息的data中
console.log(data);

if (data.event === "match_success") { // 匹配成功
store.commit("updateOpponent", {
username: data.opponent_username,
photo: data.opponent_photo,
});
setTimeout(() => { // 3秒后再进入游戏地图界面
store.commit("updateStatus", "playing");
}, 3000);
}
};

socket.onclose = () => { // 关闭链接后会执行
console.log("Disconnected!");
store.commit("updateStatus", "matching"); // 进入游戏地图后玩家点击其他页面应该是默认退出游戏
};

...
});

...
},
};
</script>

...

测试的时候需要用两个浏览器,如果没有两个浏览器可以在 Edge 浏览器的右上角设置菜单中新建 InPrivate 窗口,这样就可以自己登录两个不同的账号进行匹配测试。

3.3 同步游戏地图

现在匹配成功后两名玩家进入游戏时看到的地图是不一样的,因为目前地图还都是在每名玩家本地的浏览器生成的,那么我们就需要将生成地图的逻辑放到服务器端。

先在后端的 consumer.utils 包下创建 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
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
package com.kob.backend.consumer.utils;

import java.util.Arrays;
import java.util.Random;

public class Game {
private final Integer rows;
private final Integer cols;
private final Integer inner_walls_count;
private final boolean[][] g;
private static final 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 boolean[rows][cols];
}

public boolean[][] 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] = true;
for (int i = 0; i < 4; i++) {
int nx = sx + dx[i], ny = sy + dy[i];
if (!g[nx][ny] && check_connectivity(nx, ny, tx, ty)) {
g[sx][sy] = false; // 注意在这里我们用的g就是原始数组,因此修改后要记得还原
return true;
}
}
g[sx][sy] = false; // 记得还原
return false;
}

private boolean drawMap() {
// 初始化障碍物标记数组
for (int i = 0; i < this.rows; i++) {
Arrays.fill(g[i], false);
}

// 给地图四周加上障碍物
for (int r = 0; r < this.rows; r++) {
g[r][0] = g[r][this.cols - 1] = true;
}
for (int c = 0; c < this.cols; c++) {
g[0][c] = g[this.rows - 1][c] = true;
}

// 添加地图内部的随机障碍物,需要有对称性因此枚举一半即可,另一半对称生成
Random random = new Random();
for (int i = 0; i < this.inner_walls_count / 2; i++) {
for (int j = 0; j < 10000; j++) {
int r = random.nextInt(this.rows); // 返回0~this.rows-1的随机整数
int c = random.nextInt(this.cols);
if (g[r][c] || g[this.rows - 1 - r][this.cols - 1 - c]) 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] = true;
break;
}
}

return check_connectivity(this.rows - 2, 1, 1, this.cols - 2);
}

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

然后在 WebSocketServer 类中当匹配成功时创建游戏地图,暂时先将其存到局部变量中,之后再进行优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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(); // 发送给A的信息
respA.put("event", "match_success");
respA.put("opponent_username", b.getUsername());
respA.put("opponent_photo", b.getPhoto());
respA.put("game_map", game.getG());
users.get(a.getId()).sendMessage(respA.toJSONString()); // A不一定是当前链接,因此要在users中获取

JSONObject respB = new JSONObject(); // 发送给B的信息
respB.put("event", "match_success");
respB.put("opponent_username", a.getUsername());
respB.put("opponent_photo", a.getPhoto());
respB.put("game_map", game.getG());
users.get(b.getId()).sendMessage(respB.toJSONString());
}

接下来需要在前端的 store/pk.js 文件中将地图存为全局变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export default {
state: {
...
game_map: null, // 游戏地图
},
getters: {},
mutations: {
...
updateGameMap(state, game_map) {
state.game_map = game_map;
},
},
actions: {},
modules: {},
};

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
...

<script>
...

export default {
...

setup() {
...

onMounted(() => {
...

socket.onmessage = (msg) => { // 接收到后端消息时会执行
const data = JSON.parse(msg.data); // Spring传过来的数据是放在消息的data中
console.log(data);

if (data.event === "match_success") { // 匹配成功
store.commit("updateOpponent", { // 更新对手信息
username: data.opponent_username,
photo: data.opponent_photo,
});
store.commit("updateGameMap", data.game_map); // 更新游戏地图

setTimeout(() => { // 3秒后再进入游戏地图界面
store.commit("updateStatus", "playing");
}, 3000);
}
};

...
});

...
},
};
</script>

...

然后需要在 GameMap.vue 中将全局变量传给游戏地图 GameMap.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
...

<script>
import { ref, onMounted } from "vue";
import { GameMap } from "@/assets/scripts/GameMap";
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>

...

最后就可以在 GameMap.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
35
36
...

export class GameMap extends AcGameObject {
constructor(ctx, parent, store) { // ctx表示画布,parent表示画布的父元素
...

this.store = store;
}

...

create_walls_online() { // 通过后端生成的数据创建地图
const g = this.store.state.pk.game_map;

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() {
// for (let i = 0; i < 10000; i++) {
// if (this.create_walls())
// break;
// }
this.create_walls_online(); // 在线生成地图
this.add_listening_events();
}

...
}