思维导图
实现匹配的原理
要实现匹配系统起码要有两个客户端 client1,client2, 当客户端打开对战页面并开始匹配时,会给后端服务器 server 发送一个请求,而匹配是一个异步的过程,什么时候返回结果是不可预知的,所以我们要写一个专门的匹配系统,维护一堆用户的集合,当用户发起匹配请求时,请求会先传给后端服务器,然后再传给匹配系统处理,匹配系统会不断地在用户里去筛选,将 rating 较为相近的的用户匹配到一组。当成功匹配后,匹配系统就会返回结果给 springboot 的后端服务器,继而返回给客户端即前端。然后我们就能在前端看到匹配到的对手是谁啦。
举个例子,两个客户端请求两个链接,新建两个类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| public class WebSocketServer { @OnOpen public void onOpen(Session session, @PathParam("token") String token) { WebSocketServer client1 = new WebSocketServer(); WebSocketServer client2 = new WebSocketServer(); }
@OnClose public void onClose() { }
@OnMessage public void onMessage(String message, Session session) { }
@OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }
|
websocket
因为匹配是异步的过程,且需要前后端双向交互,而普通的 http 协议是单向的,一问一答式的,属于立即返回结果的类型,不能满足我们的异步需求,因此我们需要一个新的协议 websocket
:不仅客户端可以主动向服务器端发送请求,服务器端也可以主动向客户端发送请求,是双向双通的,且支持异步。简单来说就是客户端向后端发送请求,经过不确定的时间,会返回一次或多次结果给客户端。
基本原理: 每一个 ws 连接都会在后端维护起来,客户端连接服务器的时候会创建一个 WebSocketServer
类。每创建一个链接就是 new 一个 WebSocketServer 类的实例,所有与链接相关的信息,都会存在这个类里面。
云端维护游戏流程
用户端进入匹配,后端把用户塞进匹配池。做一个类似于生产者-消费者模型的线程池,一旦有两名玩家符合匹配条件,就让他们成功匹配。
由服务端随机生成一张合法地图,通过websocket协议传给用户,让前端好渲染出来这张地图。
服务端将处理玩家的操作是否合法和对局的结果。玩家的操作可能来自于玩家的IO设备(键盘),也可能来自于玩家的代码(通过微服务来执行代码)。
后端添加配置和依赖
在pom.xml
中添加依赖:
spring-boot-starter-websocket
fastjson
添加config/WebSocketConfig
配置类
1 2 3 4 5 6 7 8 9 10 11 12 13
| mport org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration public class WebSocketConfig {
@Bean public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter(); } }
|
放行websocket连接,在config/SecurityConfig
下加入以下代码
1 2 3 4
| @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/websocket/**"); }
|
后端如何维护Websocket
基本原理就是,对于每一个前端建立的websocket,后端都new一个WebSocketServer实例来维护它。
实例大概长这样。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| public class WebSocketServer { @OnOpen public void onOpen(Session session, @PathParam("token") String token) { WebSocketServer client1 = new WebSocketServer(); WebSocketServer client2 = new WebSocketServer(); }
@OnClose public void onClose() { }
@OnMessage public void onMessage(String message, Session session) { }
@OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }
|
onOpen
函数会在连接建立时自动执行,onClose
函数会在链接关闭时自动执行,onMessage
用于处理前端发来的信息,是主要写业务逻辑的地方。向前端发信息的sendMessage
函数需要自己实现。
实现WebSocketServer类
实现后端向前端发送信息,要手写个辅助函数 sendMessage
首先要存储所有链接,因为我们要根据用户 Id 找到所对应的链接是什么,才可以通过这个链接向前端发请求。根据基本的语法知识可得,这个东西得用静态标识符static
修饰,因为这是一个所有实例的全局变量。
其次还要有链接与用户一一对应,每个链接都用一个 session 维护
需要注意的是:WebSocketServer 并不是一个标准的 Springboot 的组件,不是一个单例模式 (每一个类同一时间只能有一个实例,这里每建一个链接都会 new 一个类,所以不是单例模式),向里面注入数据库并不像在 Controller
里一样直接 @Autowired
,要改成先定义一个 static 变量,再 @Autowired
加入到 setUsersMapper
函数上,如下:
1 2 3 4 5
| private static UserMapper userMapper; @Autowired public void setUserMapper(UserMapper userMapper) { WebSocketServer.userMapper = userMapper; }
|
@Autowired 写在 set () 方法上,在 spring 会根据方法的参数类型从 ioc 容器中找到该类型的 Bean 对象注入到方法的行参中,并且自动反射调用该方法 (被 @Autowired 修饰的方法一定会执行),所以一般使用在 set 方法中、普通方法不用。
WebSocketServer
类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component;
import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.concurrent.ConcurrentHashMap;
@Component @ServerEndpoint("/websocket/{token}") public class WebSocketServer {
private static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
private Users user;
private Session session = null;
private static UsersMapper usersMapper;
@Autowired public void setUsersMapper(UsersMapper usersMapper) { WebSocketServer.usersMapper = usersMapper; }
@OnOpen public void onOpen(Session session, @PathParam("token") String token) { System.out.println("connected!"); this.session = session; Integer userId = Integer.parseInt(token); this.user = usersMapper.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(); }
private void sendMessage(String message) { synchronized (this.session) { try { this.session.getBasicRemote().sendText(message); } catch (IOException e) { e.printStackTrace(); } }
} }
|
前端调试
在store
下新建pk.js
文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| import ModuleUser from './user'
export default { state: { socket: null, opponent_username: "", opponent_photo: "", status: "matching", btninfo: "开始匹配", }, getters: {
}, mutations: { updateSocket(state,socket) { state.socket = socket; }, updateOpponent(state,opponent) { state.opponent_username = opponent.username; state.opponent_photo = opponent.photo; }, updateStatus(state,status) { state.status = status; }
}, actions: {
}, modules: { user: ModuleUser, } }
|
再把pk引入全局变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import ModulePk from './pk'
export default createStore({ state: { }, getters: { }, mutations: { }, actions: { }, modules: { user: ModuleUser, pk: ModulePk, } })
|
调试函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| setup() { const store = useStore(); const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.id}/`;
let socket = null; onMounted(() => { socket = new WebSocket(socketUrl); socket.onopen = () => { console.log("connected!"); store.commit("updateSocket",socket); }
socket.onmessage = msg => { const data = JSON.parse(msg.data); console.log(data); }
socket.onclose = () => { console.log("disconnected!"); } });
onUnmounted(() => { socket.close(); }); }
|
如果前端和后端都能输出connected!
,说明websocket连接建立成功!
添加jwt验证
注意:前端向后端传的信息是JwtToken,这里传userId只是为了调试方便点。
把前端传的信息改成:
1
| const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`;
|
在后端的consumer/utils/JwtAuthenciation
辅助类,作用是根据token判断用户是否存在。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| package com.popgame.backend.consumer.utils;
import com.popgame.backend.utils.JwtUtil; import io.jsonwebtoken.Claims;
public class JwtAuthentication { public static Integer getUserId(String token) { int userId = -1; try { Claims claims = JwtUtil.parseJWT(token); userId = Integer.parseInt(claims.getSubject()); } catch (Exception e) { throw new RuntimeException(e); } return userId; } }
|
接着修改一下后端的onOpen
函数
1 2 3 4 5 6 7 8 9 10
| this.session = session;
Integer userId = JwtAuthentication.getUserId(token); this.user = userMapper.selectById(userId); if (this.user != null) { users.put(userId, this); System.out.println("connected"); } else { this.session.close(); }
|
再调试一下,若前端能正常输出用户信息,说明代码正确。
前端实现匹配界面
利用store
全局变量和vue3
的v-if
实现匹配页面和对战页面的切换
1 2 3 4 5
| <template>
<PlayGround v-if="$store.state.pk.status === 'playing'" /> <MatchGround v-if="$store.state.pk.status === 'matching'" /> </template>
|
匹配界面的布局利用bootstrap
的grid
系统,自己:对手=1:1。匹配的逻辑也很简单,点击匹配按钮即可开始匹配;再点击即可取消匹配。若匹配成功,匹配按钮会显示“匹配成功”四个字。等待几秒后再跳转到对战界面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| <template> <div class="matchGround"> <div class="row"> <div class="col-6"> <div class="user-photo"> <img :src="$store.state.user.photo" alt=""> </div> <div class="user-username"> {{ $store.state.user.username }} </div> </div>
<div class="col-6">
<div class="user-photo"> <img :src="$store.state.pk.opponent_photo" alt=""> </div> <div class="user-username"> {{ $store.state.pk.opponent_username }} </div> </div>
<div class="col-12" style="padding-top: 15vh;text-align: center"> <button @click="click_match_btn" type="button" class="btn btn-success btn-lg"> {{ $store.state.pk.btninfo }} </button> </div> </div> </div> </template>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| <script> import { ref } from 'vue'; import store from '../store';
export default { setup() { let match_btn_info = ref("开始匹配"); store.state.pk.btninfo = "开始匹配"; const click_match_btn = () => { if (store.state.pk.btninfo === "开始匹配") { store.state.pk.btninfo = "取消匹配"; store.state.pk.socket.send(JSON.stringify({ event: "start-matching", })); } else if (store.state.pk.btninfo === "取消匹配"){ store.state.pk.btninfo = "开始匹配"; store.state.pk.socket.send(JSON.stringify({ event: "stop-matching", })); } }; return { match_btn_info, click_match_btn, } },
}
</script>
|
实现和后端的通信
注意这里:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| const click_match_btn = () => { if (store.state.pk.btninfo === "开始匹配") { store.state.pk.btninfo = "取消匹配"; store.state.pk.socket.send(JSON.stringify({ event: "start-matching", })); } else if (store.state.pk.btninfo === "取消匹配"){ store.state.pk.btninfo = "开始匹配"; store.state.pk.socket.send(JSON.stringify({ event: "stop-matching", })); } };
|
前端向后端发送了一个JSON字符串,后端可以在onMessage
函数里接收到前端 的请求。
1 2 3 4 5 6 7 8 9 10 11
| public void onMessage(String message, Session session) { JSONObject data = JSONObject.parseObject(message); String event = data.getString("event"); if ("start-matching".equals(event)) { startMatching(); } else if ("stop-matching".equals(event)) { stopMatching(); } }
|
将前端收到的JSON字符串通过Java方法转化成JSON对象,就可以从中获取信息了。
类似地,后端也可以通过sendMessage
函数向前端发送信息
1
| users.get(a.getId()).sendMessage(respa.toJSONString());
|
前端接受后端的消息也类似于后端,分建立、接受、断开三个部分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| setup() { const store = useStore(); const socket_url = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`; let socket = null; onMounted(() => { socket = new WebSocket(socket_url); }) socket.onopen = () => { store.commit("updateSocket", socket); } socket.onmessage = msg => { const data = JSON.parse(msg.data);
} socket.onclose = () => { console.log("disconnected!"); } }
|
写匹配池
用线程安全的 set 定义匹配池:
1
| private static final CopyOnWriteArraySet<User> matchPool = new CopyOnWriteArraySet<>();
|
开始匹配时,将用户放进匹配池里,取消匹配时将用户移除匹配池
匹配过程在目前调试阶段可以简单地两两匹配
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| private void startMatching() { System.out.println("startMatching"); matchPool.add(this.user); while (matchPool.size() >= 2) { Iterator<User> it = matchPool.iterator(); Game game = new Game(13, 14, 20); game.createMap(); User a = it.next(), b = it.next(); matchPool.remove(a); matchPool.remove(b);
JSONObject respa = new JSONObject(); respa.put("event", "start-matching"); respa.put("opponent_username", b.getUsername()); respa.put("opponent_photo", b.getPhoto()); respa.put("gamemap", game.getG()); users.get(a.getId()).sendMessage(respa.toJSONString());
JSONObject respb = new JSONObject(); respb.put("event", "start-matching"); respb.put("opponent_username", a.getUsername()); respb.put("opponent_photo", a.getPhoto()); respb.put("gamemap", game.getG()); users.get(b.getId()).sendMessage(respb.toJSONString()); } }
private void stopMatching() { System.out.println("stopMatching"); matchPool.remove(this.user); }
|
后端将处理好的信息传给前端后,前端接受并处理信息:
views/PKIndexView.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| <script> import PlayGround from '../../components/PlayGround.vue' import MatchGround from '../../components/MatchGround.vue' import { onMounted } from 'vue'; import { onUnmounted } from 'vue'; import { useStore } from 'vuex' export default { components: { PlayGround, MatchGround, }, setup() { const store = useStore(); const socket_url = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`; let socket = null; onMounted(() => {
store.commit("updateOpponent", { username: "我的对手", photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",
})
socket = new WebSocket(socket_url); socket.onopen = () => { console.log("connected!"); store.commit("updateSocket", socket); }
socket.onmessage = msg => { const data = JSON.parse(msg.data); if (data.event === "start-matching") { store.commit("updateOpponent", { username: data.opponent_username, photo: data.opponent_photo, }); store.state.pk.btninfo = "匹配成功", setTimeout(() => { store.commit("updateStatus", "playing"); }, 3000) } store.commit("updateGamemap", data.gamemap); console.log(data); }
socket.onclose = () => { console.log("disconnected!"); } });
onUnmounted(() => { store.commit("updateStatus", "matching"); socket.close(); }); } } </script>
|
后端重构生成地图功能
前文也提到过,生成地图,游戏逻辑等与游戏相关的操作都应该放在服务端,不然的话客户每次刷新得到的地图都不一样,游戏的公平性也不能得到保证。因此,我们要将之前在前端写的游戏逻辑全部转移到后端(云端),前端只负责动画的演示即可。
首先要在后端创建一个 Game 类实现游戏流程,其实就是把之前在前端写的 js 全部翻译成 Java 就好了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| package com.example.backend.consumer.utils;
import java.util.Random;
public class Game { private final Integer rows; private final Integer cols; private final Integer inner_walls_count;
private final static int[] dx = {-1, 0, 1, 0}, dy = {0, 1, 0, -1};
private int[][] g;
public Game(Integer rows, Integer cols, Integer inner_walls_count) { this.rows = rows; this.cols = cols; this.inner_walls_count = inner_walls_count; this.g = new int[rows][cols]; }
public int[][] getG() { return g; }
private void initMap() { for (int i = 0; i < this.rows; i++) { for (int j = 0; j < this.cols; j++) { g[i][j] = 0; } } for (int i = 0; i < this.rows; i++) { g[i][0] = 1; g[i][this.cols - 1] = 1; } for (int i = 0; i < this.cols; i++) { g[0][i] = 1; g[this.rows - 1][i] = 1; } }
private boolean check_connectivity(int sx, int sy, int ex, int ey) { if (sx == ex && sy == ey) return true; g[sx][sy] = 1; for (int i = 0; i < 4; i++) { int a = sx + dx[i], b = sy + dy[i]; if (a < 0 || a >= this.rows || b < 0 || b >= this.cols) continue; if (g[a][b] == 0 && this.check_connectivity(a, b, ex, ey)) { g[sx][sy] = 0; return true; } } g[sx][sy] = 0; return false; } private boolean draw() { initMap(); Random random = new Random(); for (int i = 0; i < this.inner_walls_count / 2; i++) { for (int j = 0; j < 100000; j++) { int r = random.nextInt(this.rows); int c = random.nextInt(this.cols); if (g[r][c] == 1 || g[this.rows - r - 1][this.cols - c - 1] == 1) continue; if (r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) continue; g[r][c] = 1; g[this.rows - r - 1][this.cols - c - 1] = 1; break; } } return this.check_connectivity(this.rows - 2, 1, 1, this.cols - 2); }
public void createMap() { for (int i = 0; i < 1000; i++) { if (draw()) { break; } } } }
|
然后把这个地图信息也返回给前端。在后端的startMatching
方法中加入以下代码:
1 2 3 4
| Game game = new Game(13, 14, 20); game.createMap(); respa.put("gamemap", game.getG()); respb.put("gamemap", game.getG());
|
在前端pk.js
中的state
里加入:gamemap: null
,mutation
里加入:
1 2 3
| updateGamemap(state, gamemap) { state.gamemap = gamemap; },
|
这样就行了。再把前端原先createmap
的代码删掉,改成使用store
里存储的变量。
1 2 3 4 5 6 7 8 9 10 11
| create_walls() { const g = this.store.state.pk.gamemap; for (let i = 0; i < this.rows; i++) { for (let j = 0; j < this.cols; j++) { if (g[i][j]) { this.walls.push(new Wall(i, j, this)); } } } return true; }
|