流程分析
![image-20220809232853003.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
整个匹配的过程是异步过程,也就是在 Matching system 中执行匹配的过程,会执行一个未知的时间,当出现符合条件的匹配结果时,才会立即将结果返回给前端。这种流程很难用之前的 Http 来达到预期效果(http 为请求一次返回一次,且一般立即响应)。对于匹配系统,请求一次,返回的时间位置,而且可能多次返回。
用 websocket 协议,不仅客户端可以向服务器主动发送请求,服务器也可以主动向客户端发送请求,是一种对称的通信方式。
![image-20220809233425880.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
之前的地图生成方式,是在用户本地(浏览器中)随机生成,如果两名玩家都在本地实现地图,地图就会产生冲突。因此,需要将生成地图的整个过程,由服务器统一完成。此外,判断游戏是否失败的逻辑(蛇撞击),如果在用户本地(浏览器)中实现,就可能会导致用户作弊。所以,不仅是生成地图,而是整个游戏的过程(蛇的移动、判定),都要做服务器端统一完成,服务器端的相关参数、判定结果返回给前端,前端只用来渲染画面,不做任何判定逻辑。
![image-20220809235633315.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
websocket 原理
将前端建立的每个 websocket 连接在后端维护起来
添加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) { }
@OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }
|
在用户开始匹配的时候,每个 client 向后端发送一个请求,就会在后端开辟一个线程,创建并维护一个 websocket 连接(实际上就是 new 一个 WebSocketServer 类的实例)
1 2
| WebSocketServer client1 = new WebSocketServer(); WebSocketServer client2 = new WebSocketServer();
|
后端接收到请求之后,将信息发送给匹配系统。
集成 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 14 15
| package com.kob.backend.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration public class WebSocketConfig {
@Bean public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter(); } }
|
3)添加consumer.WebSocketServer
类
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.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) { }
@OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }
|
上面最核心的一个函数是onMessage
,负责 Server 从 Client 接收消息时处理相关逻辑。那如何在通过后端,向前端 client 发送信息呢?
定义Session
对象,每个连接本质上是通过Session
维护
1
| private Session session = null;
|
新增sendMessage
函数,用于后端向当前连接发送信息
1 2 3 4 5 6 7 8 9 10
| public void sendMessage(String message){ synchronized (this.session){ try{ this.session.getBasicRemote().sendText(message); }catch (IOException e){ e.printStackTrace(); } } }
|
另外还需要存储下每个connection
对应的用户是谁,这样才能清楚哪两个用户之间发生了匹配,用户信息也要存储到Session
中。并且需要根据用户的ID
,找到相应的WebSocketServer
连接是哪一个,所以将两者的映射关系存储在ConcurrentHashMap
中,ConcurrentHashMap
是一个线程安全的哈希表
1 2 3
| private User user; private static ConcurrentHashMap<Integer,WebSocketServer> userConnectionInfo = new ConcurrentHashMap<>();
|
由于WebSocket
不属于Spring
的一个组件,不是单例模式,因此,注入mapper
的方式有些区别
1 2 3 4 5 6
| private static UserMapper userMapper;
@Autowired public void setUserMapper(UserMapper userMapper){ WebSocketServer.userMapper = userMapper; }
|
在建立连接时,需要建立用户 ID 与WebSocketServer
实例的映射
1 2 3 4 5 6 7 8 9
| @OnOpen public void onOpen(Session session, @PathParam("token") String token) { this.session = session; System.out.println("Connected!"); int userId = Integer.parseInt(token); this.user = userMapper.selectById(userId); userConnectionInfo.put(userId, this); }
|
在关闭连接时,删除这种映射
1 2 3 4 5 6 7 8
| @OnClose public void onClose() { System.out.println("Disconnected!"); if(this.user != null){ userConnectionInfo.remove(this.user.getId()); } }
|
此时的WebSocketServer.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
| @Component @ServerEndpoint("/websocket/{token}") public class WebSocketServer { private User user; private static ConcurrentHashMap<Integer,WebSocketServer> userConnectionInfo = new ConcurrentHashMap<>(); private Session session = null;
private static UserMapper userMapper;
@Autowired public void setUserMapper(UserMapper userMapper){ WebSocketServer.userMapper = userMapper; } @OnOpen public void onOpen(Session session, @PathParam("token") String token) { this.session = session; System.out.println("Connected!"); int userId = Integer.parseInt(token); this.user = userMapper.selectById(userId); userConnectionInfo.put(userId, this); }
@OnClose public void onClose() { System.out.println("Disconnected!"); if(this.user != null){ userConnectionInfo.remove(this.user.getId()); } }
@OnMessage public void onMessage(String message, Session session) { System.out.println("Receive message!"); }
@OnError public void onError(Session session, Throwable error) { error.printStackTrace(); }
public void sendMessage(String message){ synchronized (this.session){ try{ this.session.getBasicRemote().sendText(message); }catch (IOException e){ e.printStackTrace(); } } } }
|
4)配置config.SecurityConfig
,将/websocket/{token}
一类的url
链接全部放行
1 2 3 4
| @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/websocket/**"); }
|
接口调试
我们在views\pk\PkIndexView.vue
中对WebSocket
进行测试
期望在当前组件被加载成功之后,建立一个连接。
需要引入vue
的两个与生命周期有关的函数
onMounted
是当组件被挂载完成之后执行的函数
onUnmounted
是当组件被卸载之后执行的函数
同时,需要将WebSocket
存储到全局变量中,在 store 中开一个新的 module 用于存储所有和 pk 相关的全局变量
src\store\pk.js
1 2 3 4 5 6 7 8 9 10 11
| export default { state: { status: "matching", socket: null, opponent_username: "", opponent_photo: "", }, mutations: {}, actions: {}, modules: {}, };
|
由于在成功创建连接之后,需要将连接信息,存储到全局变量中
所以需要在src\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
| export default { state: { status: "matching", socket: null, opponent_username: "", opponent_photo: "", }, mutations: { updateSocket(state, socket) { state.socket = socket; }, updateOpponent(state, opponent) { state.opponent_username = opponent.username; state.opponent_photo = opponent.photo; }, updateStatus(state, status) { state.status = status; }, }, actions: {}, modules: {}, };
|
然后在views\pk\PkIndexView.vue
引入全局变量useStore
在当前组件被挂载的时候(可以简单理解为页面被打开的时候),也就是onMounted
执行的时候,我们需要创建connection
,在onUnmounted
执行的时候,关闭连接。
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
| export default { components: { PlayGround, }, setup() { const store = useStore(); const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.id}`; let socket = null; onMounted(() => { socket = new WebSocket(socketUrl); socket.onopen = () => { 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(); }); }, };
|
建立连接
当进入到对战页面时,可以在后端和浏览器的控制台中看到连接成功的输出
![image-20220810145912567.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220810145928993.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
此时如果切换到其他页面,又会断开连接
![image-20220810150031718.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220810150017457.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
注意如果刷新页面,就会先断开连接,后建立连接
![image-20220810150242024.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
而且,必须要在页面卸载时,关闭连接
1 2 3
| onUnmounted(() => { socket.close(); });
|
否则,切换到其他页面的时候,没有关闭连接,但是在每一次进来的时候,又会创建连接
![image-20220810150549415.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
刷新或者关闭时,会关闭所有的连接,从输出看出不止一个
![image-20220810150637938.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
所以,如果不进行正常关闭,在切换到其他页面时,旧连接不会关闭,因此会产生很多冗余的连接。
在成功连接后,后端输出获取到的用户信息如下:
![image-20220810151904289.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
此时建立连接时,是直接将用户的 ID 传输过来,但这样显然是不安全的,因为前端可以通过修改{token}
的方式,伪装成任意一个用户的身份建立连接,因此需要添加验证,这里仍然是使用Jwt
进行验证
Jwt 验证
前端直接将jwt-token
传过去
1
| const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`;
|
后端验证的方式,在config.filter.JwtAuthenticationTokenFilter
已经给出
那就是就是如果能从token
中解析出userId
就认为是合法的,否则就是不合法
1 2 3 4 5 6 7 8
| String userid; try { Claims claims = JwtUtil.parseJWT(token); userid = claims.getSubject(); } catch (Exception e) { throw new RuntimeException(e); }
|
为了日后方便,将这段代码提出,放在一个单独的工具类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.java
中onOpen
函数体更新为
1 2 3 4 5 6 7 8 9 10 11 12
| @OnOpen public void onOpen(Session session, @PathParam("token") String token) throws IOException { this.session = session; System.out.println("Connected!"); int userId = JwtAuthentication.getUserId(token); this.user = userMapper.selectById(userId); if(this.user != null) userConnectionInfo.put(userId, this); else this.session.close(); }
|
这样就能够成功实现jwt
验证
前端实现
此时前端还只有对战界面,并没有匹配界面,我们需要实现匹配界面,以及匹配界面和对战界面的切换
与切换有关的全局变量,就是在pk.js
中定义的status
, matching
表示匹配界面,playing
表示对战界面
那就需要当status
为playing
的时候再显示对战页面
1 2 3
| <template> <PlayGround v-if="$store.state.pk.status === 'playing'" /> </template>
|
并且需要创建一个新的组件MatchGround.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
| <template> <div class="matchground"> <div class="row"> <div class="col-6"> <div class="user_photo"> <img :src="$store.state.user.photo" alt="" /> </div> <div class="user_username"> {{ $store.state.user.username }} </div> </div> <div class="col-6"> <div class="user_photo"> <img :src="$store.state.pk.opponent_photo" alt="" /> </div> <div class="user_username"> {{ $store.state.pk.opponent_username }} </div> </div> </div> <div class="row"> <div class="col-12" style="text-align:center; padding-top:12vh"> <button @click="click_match_btn" class="btn btn-success btn-lg"> {{ match_btn_info }} </button> </div> </div> </div> </template>
|
为按钮绑定一个click_match_btn
触发函数,当点击”开始匹配”,使用WebSocket
的sent
API 向后端发送包含event:"start-matching"
的字符串(注意,JSON.stringify
是将JSON
格式处理为字符串,后续还可以恢复JSON
格式)
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
| <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.sent(JSON.stringify({ event:"start-matching", })); }else{ match_btn_info.value = "开始匹配"; store.state.pk.socket.sent(JSON.stringify({ event:"stop-matching", })); } }; return{ match_btn_info, click_match_btn, } } } </script>
|
后端收到请求时,就会将message
字符串解析为JSON
格式,然后根据event
值来分配给不同的任务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| private void startMatching(){ System.out.println("start matching!"); } private void stopMatching(){ System.out.println("stop matching!"); } @OnMessage public void onMessage(String message, Session session) { System.out.println("Receive message!"); JSONObject data = JSONObject.parseObject(message); String event = data.getString("event"); if("start-matching".equals(event)){ startMatching(); } else if ("stop-matching".equals(event)) { stopMatching(); } }
|
在后端需要建立一个线程安全的 Set 作为匹配池
1 2
| private static CopyOnWriteArraySet<User> matchpool = new CopyOnWriteArraySet<>();
|
然后在相应的时间添加和删除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @OnClose public void onClose() { System.out.println("Disconnected!"); if(this.user != null){ userConnectionInfo.remove(this.user.getId()); matchpool.remove(this.user); } }
private void startMatching(){ System.out.println("start matching!"); matchpool.add(this.user); } private void stopMatching(){ System.out.println("stop matching!"); matchpool.remove(this.user); }
|
由于现在还没有实现微服务,暂时先实现一个傻瓜式的匹配,也就是匹配池中大于等于两个用户的时候,就实现两两匹配,也就是两个用户user1
和user2
,并通过两个用户自己的连接,告诉前端匹配成功的相关消息
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> iterator = matchpool.iterator(); User user1 = iterator.next(); User user2 = iterator.next(); matchpool.remove(user1); matchpool.remove(user2); JSONObject resp1 = new JSONObject(); resp1.put("event","start-matching"); resp1.put("opponent_username",user2.getUsername()); resp1.put("opponent_photo",user2.getPhoto()); WebSocketServer webSocketServer1 = userConnectionInfo.get(user1.getId()); webSocketServer1.sendMessage(resp1.toJSONString());
JSONObject resp2 = new JSONObject(); resp2.put("event","start-matching"); resp2.put("opponent_username",user1.getUsername()); resp2.put("opponent_photo",user1.getPhoto()); WebSocketServer webSocketServer2 = userConnectionInfo.get(user2.getId()); webSocketServer2.sendMessage(resp2.toJSONString()); } }
|
在前端的PkIndexView.vue
中,当接收到后端发送的消息之后,相关逻辑的实现在onmessage
函数中
如果匹配成功,就要更新对手信息
匹配测试
注意,需要两个用户进行测试的话,必须在两个不同的浏览器中。一个浏览器只能允许同时登录一个用户,因为在Local Storage
中会共用一个jwt_token
![image-20220810182946774.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
前端如何匹配成功,就更新对手的用户名和头像。
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
| import PlayGround from "../../components/PlayGround.vue"; import MatchGround from "../../components/MatchGround.vue"; import { onMounted } from "vue"; import { onUnmounted } from "vue"; import { useStore } from "vuex"; export default { components: { PlayGround, MatchGround, }, setup() { const store = useStore(); const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`; let socket = null; onMounted(() => { store.commit("updateOpponent", { username: "我的对手", photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png", }); socket = new WebSocket(socketUrl); socket.onopen = () => { console.log("connected!"); store.commit("updateSocket", socket); }; socket.onmessage = (msg) => { const data = JSON.parse(msg.data); console.log(data); if (data.event === "start-matching") { store.commit("updateOpponent", { username: data.opponent_username, photo: data.opponent_photo, }); } }; socket.onclose = () => { console.log("disconnected!"); }; });
onUnmounted(() => { socket.close(); }); }, };
|
![image-20220810184626741.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
`
然后在匹配成功之后,设置延迟两秒显示,然后跳转到对战页面
![image-20220810185856530.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220810192234151.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
如果切换其他页面再切换回来的时候,地图又发生了变化,期望点击其他页面的时候自动放弃,再切换回来的时候,重新回到匹配页面。
那就需要在卸载页面(onUnmounted
)的时候,不仅需要断开连接,同时还要将状态切换为matching
状态。
但此时有一个很大的问题,就是两个人的游戏地图不一致。这是因为地图是在浏览器本地生成,为了解决同步问题,需要由服务器统一接管。
地图同步
接下来需要在服务器端实现之前分析的 Game 流程
![image-20220809235633315.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
添加consumer.utils.Game.java
,用于管理整个游戏流程
参考assets\scripts\GameMap.js
画地图参考GameMap.js
中create_walls()
函数
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
| import java.util.Random;
public class Game { final private Integer rows; final private Integer cols; final private Integer inner_walls_count; final private int[][] g; final private static int[] dx = {-1,0,1,0}; final private static int[] dy = {0,1,0,-1}; public Game(Integer rows, Integer cols, Integer inner_walls_count) { this.rows = rows; this.cols = cols; this.inner_walls_count = inner_walls_count; this.g = new int[rows][cols]; }
public int[][] getG() { return g; }
private boolean check_connectivity(int sx, int sy,int tx, int ty){ if (sx == tx && sy == ty) return true; g[sx][sy] = 1;
for(int i = 0; i < 4; i++){ int x = sx + dx[i]; int y = sy + dy[i]; if(x >= 0 && x < this.rows && y >= 0 && y < this.cols && g[x][y] == 0){ if(check_connectivity(x, y, tx, ty)){ g[sx][sy] = 0; return true; } } } g[sx][sy] = 0; return false; } private boolean draw(){ for (int i = 0; i < this.rows; i++) { for (int j = 0; j < this.cols; j++) { g[i][j] = 0; } } for(int r = 0; r < this.rows; r++){ g[r][0]=1; 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); 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.java
当开始匹配的时候,实例化一个 Game 对象用于生成地图,并将生成的地图返回给连接中的两个用户。
当然,最终的地图应该是保存在 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
| private void startMatching(){ System.out.println("start matching!"); matchpool.add(this.user); while (matchpool.size() >= 2){ Iterator<User> iterator = matchpool.iterator(); User user1 = iterator.next(); User user2 = iterator.next(); matchpool.remove(user1); matchpool.remove(user2); Game game = new Game(13,14,20); game.createMap();
JSONObject resp1 = new JSONObject(); resp1.put("event","start-matching"); resp1.put("opponent_username",user2.getUsername()); resp1.put("opponent_photo",user2.getPhoto()); resp1.put("gamemap",game.getG()); WebSocketServer webSocketServer1 = userConnectionInfo.get(user1.getId()); webSocketServer1.sendMessage(resp1.toJSONString());
JSONObject resp2 = new JSONObject(); resp2.put("event","start-matching"); resp2.put("opponent_username",user1.getUsername()); resp2.put("opponent_photo",user1.getPhoto()); resp2.put("gamemap",game.getG()); WebSocketServer webSocketServer2 = userConnectionInfo.get(user2.getId()); webSocketServer2.sendMessage(resp2.toJSONString()); } }
|
此时后端可以返回地图,前端写好接收地图的逻辑
src\store\pk.js
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", socket: null, opponent_username: "", opponent_photo: "", gamemap: null, }, mutations: { updateSocket(state, socket) { state.socket = socket; }, updateOpponent(state, opponent) { state.opponent_username = opponent.username; state.opponent_photo = opponent.photo; }, updateStatus(state, status) { state.status = status; }, updayeGamemap(state, gamemap) { state.gamemap = gamemap; }, }, actions: {}, modules: {}, };
|
src\views\pk\PkIndexView.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| socket.onmessage = (msg) => { const data = JSON.parse(msg.data); console.log(data); if (data.event === "start-matching") { store.commit("updateOpponent", { username: data.opponent_username, photo: data.opponent_photo, }); setTimeout(() => { store.commit("updateStatus", "playing"); }, 2000); store.commit("updateGamemap", data.gamemap); } };
|
后端获取gamemap
并更新到全局变量之后,要将获取到的gamemap
渲染到画布上
首先在组件GameMap.vue
中将全局变量store
传递到GameMap
的构造函数中
src\components\GameMap.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <script> import { GameMap } from "../assets/scripts/GameMap"; import { onMounted, ref } from "vue"; //用于定义变量 import { useStore } from "vuex"; export default { setup() { const store = useStore(); let parent = ref(null); let canvas = ref(null); onMounted(() => { new GameMap(canvas.value.getContext("2d"), parent.value, store); }); return { parent, canvas, }; }, }; </script>
|
对于scripts\GameMap.js
相关的代码更新为:
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
| export class GameMap extends GameObject { constructor(ctx, parent, store){ super();
this.ctx = ctx; this.parent = parent; this.store = store; this.L = 0; this.rows = 13; this.cols = 14; this.inner_walls_count = 10; this.walls = [];
this.snakes = [ new Snake({id:0, color:"#4876EC",r: this.rows - 2, c: 1},this), new Snake({id:1, color:"#F94848",r: 1, c: this.cols - 2},this), ]; }
create_walls(){ console.log(this.store) const g = this.store.state.pk.gamemap; for(let r = 0; r < this.rows; r++){ for(let c = 0; c < this.cols; c++){ if(g[r][c]){ this.walls.push(new Wall (r,c,this)); } } } } start(){ this.create_walls(); this.add_listening_events(); }
|
至此,就解决了地图同步问题
![image-20220817162739007.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
玩家位置同步
后端修改
玩家的位置也要在服务端确定,确定完之后将每个玩家的位置传到前端。
添加一个玩家类
consumer.utils.Game.java
1 2 3 4 5 6 7 8 9 10 11
| import java.util.List;
@Data @AllArgsConstructor @NoArgsConstructor public class Player { private Integer id; private Integer sx; private Integer sy; private List<Integer> steps; }
|
在初始化Game
的时候,实例化两个Player
对象
![image-20220817175347817.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220817175807564.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
在WebSocketServer.java
中,为了方便管理,将与Game
相关的信息,封装成一个JSON
![image-20220817181541093.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
这样后端就可以将两名玩家的信息(包括生成的地图)传送给前端
前端修改
在src\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 35 36 37 38
| export default { state: { status: "matching", 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, }, 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; }, updateGame(state, game) { 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; state.gamemap = game.map; }, }, actions: {}, modules: {}, };
|
在src\views\pk\PkIndexView.vue
中,在onmessage
中,调用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 44
| <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 socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`; let socket = null; onMounted(() => { ....//省略 socket.onmessage = msg => { const data = JSON.parse(msg.data); console.log(data); if (data.event === "start-matching") { store.commit("updateOpponent", { username: data.opponent_username, photo: data.opponent_photo }); //匹配成功后,延时2秒,进入对战页面 setTimeout(() => { store.commit("updateStatus", "playing") }, 2000); store.commit("updateGame",data.game)//更新Game:包括玩家信息和地图 } } socket.onclose = () => { console.log("disconnected!"); } });
onUnmounted(() => { socket.close(); store.commit("updateStatus", "matching"); }) } } </script>
|
运行项目,使用用户名 sun 和用户名 hong 的登录,两个浏览器控制台console.log(data.game)
的输出内容一致,均为,同步成功
![image-20220817184701914.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
游戏同步:多线程
分析过程
之前只是两个棋盘,在浏览器本地通过 wsad 和上下左右来控制移动。
现在三个棋盘,两个 client 和一个 server,需要实现三个棋盘的同步
![image-20220818094655746.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
再来梳理一下之前的游戏流程
![image-20220818095616805.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
对于从等待用户 orBot 输入到判别系统这一过程是独立的,
![image-20220818095749236.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
但是一般代码的执行是单线程,也就是按照顺序执行,例如如果在当前线程执行操作,当等待用户输入的时候,线程就会卡死,需要我们这样一个线程中有多个游戏在运行,只有 Game1 结束之后才能跑 Game2,这样在第二个对局中,玩家就会漫长的等待。
![image-20220818100333814.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
因此,Game 不能作为一个单线程来处理,因此,需要另起一个新的线程来做。
也就是将 Game 变成一个支持多线程的类
![image-20220818100750453.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
多线程
首先为WebSocketServer
增加一个成员变量,用于记录链接中的 Game 实例
![image-20220818101937329.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
在确定两名匹配的玩家之后,更新两名玩家的WebSocketServer
连接上的Game
实例值。
![image-20220818102328316.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
然后回到Game.java
,将Game
变成一个支持多线程的类,只需将Game
继承Thread
类,就可以支持多线程
1
| public class Game extends Thread
|
然后重写多线程的入口函数run()
在开启一个新线程执行game.start()
的时候,新线程中的入口函数,就是run()
初始化两个成员变量,用于表示两名玩家的下一步操作
1 2 3 4 5 6 7 8
| private Integer nextStepA; private Integer nextStepB; public void setNextStepA(Integer nextStepA) { this.nextStepA = nextStepA; } public void setNextStepB(Integer nextStepB) { this.nextStepB = nextStepB; }
|
未来会在WebSocketServer.java
中,接收到输入的时候,调用这两个函数
也就是在蓝色的线程里面修改nextStepA
和nextStepB
的值,而在红色的线程里面,会读取这两个线程的值
![image-20220818103052021.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
这就涉及到两个线程会同时读写一个变量,可能会产生读写冲突,需要枷锁
定义一个锁
1
| private ReentrantLock lock = new ReentrantLock();
|
之后在setNextStepA
和setNextStepB
中
对两个变量进行更新之前,先锁上,操作完之后,解锁(不管有没有报异常)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| 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(); } }
|
在nextStep()
函数中,负责等待两名玩家的输入,如果都在指定时间内输入了,就返回true
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
| private boolean nextStep(){ try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); }
for (int i = 0; i < 5; i++) { try { Thread.sleep(1000); lock.lock(); 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; }
|
如果其中一个超时没有输入,游戏就终止,并且分出胜负。
因此还需要定义一个游戏状态status
和谁输了loser
1 2
| private String status = "playing"; private String loser = "";
|
最后,在线程的入口run()
中初始逻辑如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Override public void run() { for (int i = 0; i < 1000; i++) { if(nextStep()){ }else { status = "finished"; if(nextStepA == null && nextStepB == null){ loser = "all"; } else if (nextStepA == null) { loser = "A"; } else{ loser = "B"; } } } }
|
但是上面这段逻辑有个问题,如果两名玩家在五秒内没有给出操作,就会进入else
判断,此时本应该是平均,也就是loser = "all"
,但如果下面这段代码执行时,用户给出了输入,结果就会不符合预期。
1 2 3 4 5 6 7
| if(nextStepA == null && nextStepB == null){ loser = "all"; } else if (nextStepA == null) { loser = "A"; } else{ loser = "B"; }
|
所以,由于这里涉及到变量的读操作,为了在读的过程中被修改,因此也需要加锁。读完之后再解锁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| if(nextStep()){ System.out.println(); }else { status = "finished"; lock.lock(); try { if(nextStepA == null && nextStepB == null){ loser = "all"; } else if (nextStepA == null) { loser = "A"; } else{ loser = "B"; } }finally { lock.lock(); } }
|
然后来看if (nextStep())
判断,如果获取两个玩家的下一步操作
需要先进行judge()
,来判断输入是否合法
并且,虽然 A 和 B 都知道自己的操作,但是看不到对方的操作,因此需要中心服务器以广播的形式来告知。
![image-20220818174229481.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
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
| @Override public void run() { for (int i = 0; i < 1000; i++) { if (nextStep()) { judge(); if(status.equals("playing")){ sentMove(); }else { sentResult(); break; } } else { status = "finished"; lock.lock(); try { if (nextStepA == null && nextStepB == null) { loser = "all"; } else if (nextStepA == null) { loser = "A"; } else { loser = "B"; } } finally { lock.lock(); } sentResult(); break; } } }
|
而其中暂时不实现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
| private void sentAllmessage(String message){ WebSocketServer.userConnectionInfo.get(playerA.getId()).sendMessage(message); WebSocketServer.userConnectionInfo.get(playerB.getId()).sendMessage(message); } private void sentMove() { lock.lock(); try{ JSONObject resp = new JSONObject(); resp.put("event","move"); resp.put("a_direction",nextStepA); resp.put("b_direction",nextStepB); nextStepA = nextStepB = null; sentAllmessage(resp.toJSONString()); }finally { lock.unlock(); } }
private void sentResult() { JSONObject resp = new JSONObject(); resp.put("event","result"); resp.put("loser",loser); sentAllmessage(resp.toJSONString()); }
|
这样后端基本逻辑完成,接下来是前端与后端的通信,前端要将用户的操作发送过来,以及接收并处理中心服务器的广播
前后端通信
此前判断蛇的移动,在scripts\GameMap.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| add_listening_events(){ this.ctx.canvas.focus(); const [snake0, snake1] = this.snakes; this.ctx.canvas.addEventListener("keydown",e=>{ console.log(e.key); 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); }); }
|
这里,由于一个 client 负责一个玩家,只处理 wsad 即可。
修改如下,将玩家的操作操作传送到后端
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| add_listening_events(){ this.ctx.canvas.focus(); const [snake0, snake1] = this.snakes; this.ctx.canvas.addEventListener("keydown",e=>{ console.log(e.key); let d = -1; if(e.key === 'w') d = 0; else if (e.key === 'd') d = 1; else if (e.key === 's') d = 2; else if (e.key === 'a') d = 3;
if(d >= 0){ this.store.state.pk.socket.sent(JSON.stringify({ event:"move", direction:d, })) } }); }
|
后端接收并分配给专门的路由来进行处理
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 move(Integer direction) { if(game.getPlayerA().getId().equals(user.getId())){ game.setNextStepA(direction); }else if (game.getPlayerB().getId().equals(user.getId())) { game.setNextStepB(direction); } else { Exception e = new Exception("Error"); e.printStackTrace(); } } @OnMessage public void onMessage(String message, Session session) { System.out.println("Receive message!"); JSONObject data = JSONObject.parseObject(message); String event = data.getString("event"); if("start-matching".equals(event)){ startMatching(); } else if ("stop-matching".equals(event)) { stopMatching(); } else if ("move".equals(event)) { Integer direction = data.getInteger("direction"); System.out.println(direction); move(direction); } }
|
此时,client 端用户输入 WSAD 的时候,后端就能准确接收到信息。
![image-20220818183500945.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
同时,前端也要接收后端的广播来的信息,具体有两种event
,分别是move
和result
1) event == move
![image-20220818191839958.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
对操作进行更新需要用到Snack.js
中的set_direction
方法
![image-20220818192126187.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
两个玩家控制的snack
对象在保存在GameMap
对象中。
![image-20220818192357488.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
为了取到,需要将GameMap
对象,作为游戏对象,保存为全局变量
![image-20220818191356614.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
先在src\store\pk.js
中将gameObject
存入全局变量,并写好更新函数
![image-20220818191647251.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
这样就能获取到游戏对象,并且更新两个玩家控制的snack
的方向
![image-20220818195452452.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
此时,两个玩家都能够控制蛇正常移动
![image-20220818200929557.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
但是每次输入之后都会感觉到一些延迟,是因为输入之后可能线程还处于睡眠状态
![image-20220818200727010.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
调整为:
![image-20220818200738389.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
2) event == result
之前判断玩家输赢(蛇的状态)的逻辑在前端
1 2 3 4
| if (!this.gamemap.check_valid(this.next_cell)) { this.status = "die"; }
|
![image-20220819103307108.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
将这段代码去掉。现在要交由后端来播报结果。
判断输赢有两部分逻辑:撞墙和超时,超时的逻辑已经写好,现在写判断撞墙的逻辑
参考前端GameMap.js
中的check_valid(cell)
函数
![image-20220819104425612.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
后端逻辑如下:
1)首先需要将两名玩家所控制的蛇取到:
新建Cell
类代表蛇的单元
1 2 3 4 5 6 7
| @Data @AllArgsConstructor @NoArgsConstructor public class Cell { private Integer x; private Integer y; }
|
在Player.java
中,将蛇的身体返回
0、1、2、3 位置表示表示上右下左
![image-20220819105814184.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
对于四种操作0(w), 1(d), 2(s), 3(a)
分别在行和列方向上的偏移量
1 2
| int[] dx = {-1, 0, 1, 0}; int[] dy = {0, 1, 0, -1};
|
所以Player.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
| @Data @AllArgsConstructor @NoArgsConstructor public class Player { private Integer id; private Integer sx; private Integer sy; private List<Integer> steps; private boolean check_tail_increasing(int step){ if(step <= 10) return true; else return step % 3 == 1; } public List<Cell> getCells(){ List<Cell> res = new ArrayList<>(); int[] dx = {-1, 0, 1, 0}; int[] dy = {0, 1, 0, -1}; int x = sx; int y = sy; int step = 0; res.add(new Cell(x,y)); for (Integer d : steps) { x += dx[d]; y += dy[d]; res.add(new Cell(x,y)); if(!check_tail_increasing(++step)){ res.remove(0); } } return res; } }
|
2)判断两名玩家最后一步操作是否合法
- 没有撞到障碍物
- 没有撞到两条蛇的身体
- 没有撞到自己:最后一步与之前 n-1 个 Cell 是否重合
- 没有撞到别人:最后一步与之前 n-1 个 Cell 是否重合
- 由于 A 和 B 不可能走到同一个格子 因此不用判断最后一个格子是否重合
只需要判断最后一步,也就是蛇的最后一个 Cell 是否符合上面三种原则即可。
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
| private boolean check_valid(List<Cell> cellsA, List<Cell> cellsB) { int n = cellsA.size(); Cell cell = cellsA.get(n - 1); if(g[cell.getX()][cell.getY()] == 1) return false; for (int i = 0; i < n - 1; i++) { if(cellsA.get(i).getX().equals(cell.getX()) && cellsA.get(i).getY().equals(cell.getY())){ return false; } } for (int i = 0; i < n - 1; i++) { if(cellsB.get(i).getX().equals(cell.getX()) && cellsB.get(i).getY().equals((cell.getY()))){ 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){ loser = "B"; } else if (validB) { loser = "A"; } else { loser = "all"; } } }
|
此时就能正常的进行合法性判断。
![image-20220819141014616.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
游戏结果展示
最后,还需要将游戏的结果在前端展示,并且,设置一个重启按钮,点击重启之后,重新开始一局。
在pk.js
中新增变量,方便用于展示谁赢谁输
![image-20220819150811233.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
新增一个组件ResultBoard.vue
用于展示结果
![image-20220819150653501.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
核心代码如下:
![image-20220819152032707.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
然后在对战页面PkIndexView.vue
导入组件,使其在loser!=none
时展示出来
![image-20220819151024672.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
并且在收到后端播报结果时,更新全局变量中的 loser
![image-20220819151121848.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
最终的结果如下,成功的实现了结果展示和重来一局。
![image-20220819150244338.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
点击重启
![image-20220819151913696.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
此时,再匹配的用户,又可以开始新的一轮对战。
![image-20220819150538480.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
这样,游戏同步功能就全部完成。
对局记录
接下来来实现另外一功能,就是将对局记录保存在数据库中。
创建 record
1)创建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
2)创建 Pojo
注意,数据库中如果用下划线,则在 pojo 中要使用驼峰命名法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| @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; }
|
3)创建 Mapper
1 2 3
| @Mapper public interface RecordMapper extends BaseMapper<Record> { }
|
写入数据库
首先将RecordMapper
实例注入到WebSocketServer
中
![image-20220819153955853.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
在Game.java
中,在每次向client
播报结果之前,将记录保存到数据库
![image-20220819161328576.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
这样在每局游戏结束时,记录就被保存下来
![image-20220819161136837.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
后续就可以根据记录来复原游戏画面
实现匹配系统的微服务
微服务可以理解为在 SpringBoot 之外的另外一个 Server,负责处理一段独立的功能,与 SpringBoot Server 之间通过 http 通信。在游戏的匹配系统,之前是简单粗暴的放在一个集合上,当集合元素大于 2 时,取出两名玩家进行匹配,无法适应更加复杂的场景,因此现在要将这段程序独立出来。
![image-20220819163919485.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
微服务有多种实现方式,这里采用 SpringCloud。SpringCloud 和 SpringBoot 都相当于一个 Web Server 两者之间通过 Http 通信
![image-20220819164220414.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
由于 SpringCloud 实现的匹配系统和 SpringBoot 实现的游戏后端是并列的,因此项目结构需要改动。
创建SpringCloud
项目
![image-20220819164536854.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220819164609646.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
配置SpringCloud
项目
![image-20220819164950301.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220819165048025.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
在SpringCloud
项目中添加依赖:
Maven 仓库地址
spring-cloud-dependencies
![image-20220819165333956.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
添加子项目—MatchingSystem
1)添加和配置
创建SpringCloud
的子项目——matchingsystem
![image-20220819165439541.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220819165620115.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
然后需要配置一些依赖。
matchingsystem
本质上也是一个springboot
,所以需要将父级目录backendcloud
中porm.xml
中的依赖,SpringWeb
依赖,直接复制到子项目matchingsystem
对应的porm.xml
![image-20220819170225988.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
创建application.properties
,配置端口,由于游戏后端backend
中是 3000,这里设置为 3001
![image-20220819170743739.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
2)匹配系统的简单布局
对于匹配系统而言,需要实现的接口中需要有两个函数,addPlayer
和removePlayer
用于添加和删除玩家
创建接口,就需要创建controller
负责调用接口,service
负责声明接口,service.impl
负责实现接口
为了方便调试,暂时不实现具体功能。
MatchingService.java
![image-20220819174150845.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
MatchingServiceImpl.java
![image-20220819230327095.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
MatchingController.java
![image-20220819174309856.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
注意,对于MultiValueMap
结构而言,运行一个key
对应的多个value
,并用数组保存
map.getFirst("user_id")
表示user_id
所对应的value
数组中,第一个值 001
![image-20220819173518541.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
此时MatchingController
还有一个问题是,没有授权验证,这样就有外网通过其他请求来恶意攻击的风险。
与之前backend
一样,添加Spring Security
依赖
![image-20220819174800486.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
照着之前的代码,只用到configure
这一段代码,且不涉及Jwt-token
的验证,直接抄过来修改
![image-20220819175735806.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
我们期望的的是只能被后端服务器访问,而不能以其他方式访问。解决这个问题,可以根据 IP 地址来判断,也就是只能通过本地,而不能通过其他地方来访问。
如下图,对"/player/add/"
和"/player/remove/"
的请求放开
只有 IP 地址为本地(127.0.0.1)的Server
发出的请求才是有效的。
![image-20220819194957707.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
将原来的Main
函数,重命名为MatchingSystemApplication
,作为Springboot
的入口
![image-20220819180722912.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
这样就能够跑起来(只不过此时由于是 POST 的类型的请求,不支持通过浏览器直接请求)
![image-20220819195359065.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
由于请求是游戏后端 backend 发起的,因此还需要将两部分对接起来。
现在需要创建一个新的子项目,将之前的逻辑装在 backendclound 下面
添加子项目—Backend
1)添加和配置
在backendcloud
下面创建SpringCloud
的子项目——Backend
![image-20220819195926647.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220819200206342.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
然后,将之前的后端backend
项目的src
整个文件夹直接复制过来。
![image-20220819200315514.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
复制到backendcloud
下面的子模块backend
下面
![image-20220819200509777.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220819200446980.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
然后将后端backend
项目下的porm.xml
中的依赖也粘贴到下面这个位置
![image-20220819200735514.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220819200848602.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
其中的thymeleaf
用不到,删除即可。
目前的项目结构如下:
![image-20220819205001379.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
2)连通匹配系统
首先要打通
![image-20220819204842500.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
需要将下面这段修改,目前还只是简单粗暴的进行从集合中取出 User
![image-20220819205339894.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
修改如下:
将与matchpool
相关的所有操作都删掉
然后在startMatching()
调用时向MatchingSystem
发请求,申请为玩家匹配对手,在stopMatching()
和onClose()
调用时向MatchingSystem
发请求,申请取消玩家的匹配,
配置RestTemplate
向MatchingSystem
发请求,需要借助Springboot
中的一个工具RestTemplate
,它可以在两个 Spring 进程之间进行通信。
先配置一下这个工具,如果希望在WebSocketServer.java
中使用RestTemplate
,就需要加Bean
注解,这样才能够取出来。
可以理解为,需要用到某个工具的时候,就定义一个它的Configuration
,加一个注解Bean
,返回一个它的实例。
![image-20220819211004216.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
这样在未来使用的时候,就可以通过@Autowired
将其注入
![image-20220819211333528.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
原理是,如果加了@Autowired
,就会看一下这个接口(或者 Service)是否有一个唯一的注解为Bean
的函数和它对应,如果有的话就调用这个函数,将返回值赋过来。
现在看下怎么用。
在具体用之前,需要修改下数据库。将rating
字段,将bot
表中,移动到user
表中
同样,修改这两个表对应的 pojo
并且在所用调用User
和Bot
构造函数的时候修改
![image-20220819215130236.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220819215206709.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
然后进入正题。
使用RestTemplate
借助RestTemplate
向MatchingSystem
发请求
![image-20220819220345823.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
启动 Backend 模块对应的 Spring 服务
此时能够在服务中看到,启动了两个SpringBoot
Backend
对应的端口号为 3000
MatchingSystem
对应的端口号为 3001
![image-20220819224536454.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
用户登录之后,进行匹配,在MatchingSystem
对应的控制台下面add player1 1500
表示匹配系统接收到了来自后端的玩家 ID 为 6 的匹配请求。
![image-20220819230223396.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
当点击取消时,同样成功接收到了请求
![image-20220819230259939.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
控制台中看到的输出,是在匹配系统中实现的输出。
至此,游戏后端,向匹配系统发请求这一过程就完成。
![image-20220819230935439.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
接下来要实现匹配系统内部的逻辑。
匹配系统
匹配系统在接收到来自游戏后端的匹配请求之后,会将当前参与匹配的所有用户,放在一个池子(数组)里面。开辟额外新线程,每隔 1s 就扫描一遍整个数组,将能够匹配的玩家匹配到一起。我们期望匹配相近分值的玩家,随着时间的推移,可以逐步放宽分值要求,也就是允许两名匹配玩家的分值差距较大,直到所有玩家都可以在规定时间内匹配在一块为止。具体来说,第一秒,匹配分值差距 10 以内的玩家,第二秒,匹配分值差距 20 以内的玩家…..直到匹配完成为止。
现在需要将之前关于线程的那部分再重复一遍。
创建service.impl.utils.MatchingPool
用于维护这样一个线程,同时创建service.impl.utils.Player
来存储玩家(需要提前将Lombok
依赖添加到匹配系统的porm.xml
中)
![image-20220819232357797.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
对于Player
类,需要考虑三个属性:用户名,积分值,等待时间
![image-20220819233045648.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
MatchingPool 线程
对于MatchingPool
,是一个多线程的类,需要继承Thread
- 创建一个列表
players
保存玩家
- 添加玩家的函数
- 删除玩家的函数
由于players
变量多个线程(匹配线程,传入参数线程)共用,因此这个变量涉及到读写冲突,因此就需要加锁。还要注意,从列表中删除元素的时候,要注意重新判断该位置。
![image-20220824124004462.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
对于匹配系统而言,由于全局只有一个匹配线程,因此将其定义成静态变量,放在MatchingServiceImpl
中。
![image-20220824123924024.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
同时在MatchingPool
开一个线程,需要重写Thread
的run()
。对于线程的执行,我们期望周期性的执行,判断当前所有玩家中有没有匹配的。写一个死循环,Thread.sleep(1000)
,每 1 秒中自动执行一遍。对于每一名玩家而言,每等待一秒,对应的waitingTime
就会加一,相应的匹配阈值就会变大。
![image-20220824151459662.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
在matchPlayers()
中,尝试匹配所有玩家
![image-20220824164853700.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
注意,java 中的 break:跳出当前循环;但是如果是嵌套循环,则只能跳出当前的这一层循环,只有逐层 break 才能跳出所有循环。continue:终止当前循环,但是不跳出循环(在循环中 continue 后面的语句是不会执行了),继续往下根据循环条件执行循环。
以上用到的辅助函数
![image-20220824151636514.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
MatchingSystem
发送请求
对于sendResult
,负责将匹配的两名玩家作为参数返回到 backend
也就是这个过程
![image-20220824154705730.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
因为也要用到RestTemplateConfig
所以将backend.config.RestTemplateConfig
文件复制到matchingsystem.config.RestTemplateConfig
为了能让RestTemplateConfig
中的Bean
注入进来,添加@Component
![image-20220824155458434.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
注入之后,就可以使用RestTemplateConfig
来进行SpringBoot
服务之间的通信
注意要加端口号
![image-20220824172041862.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
Backend
接收请求
为了能将匹配的 a 和 b 作为参数返回到backend
,我们需要在backend
写一个接收信息的方法
对于这样的一个方法而言,同样的一个流程
service
service.impl
controller
变动的文件如下,除了SecurityConfig
,其他的均为新增文件
![image-20220824160431231.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
StartGameService.java
![image-20220824153546232.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
StartGameServiceImpl.java
![image-20220824153621228.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
其中的WebSocketServer.startGame(aId, bId)
内容为:
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
| public static void startGame(Integer aId, Integer bId){ User userA = userMapper.selectById(aId); User userB = userMapper.selectById(bId); Game game = new Game(13,14,20, userA.getId(), userB.getId()); game.createMap();
userConnectionInfo.get(userA.getId()).game = game; userConnectionInfo.get(userB.getId()).game = game;
game.start(); 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","start-matching"); respA.put("opponent_username",userB.getUsername()); respA.put("opponent_photo",userB.getPhoto()); respA.put("game",respGame); WebSocketServer webSocketServer1 = userConnectionInfo.get(userA.getId()); webSocketServer1.sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject(); respB.put("event","start-matching"); respB.put("opponent_username",userA.getUsername()); respB.put("opponent_photo",userA.getPhoto()); respB.put("game",respGame); WebSocketServer webSocketServer2 = userConnectionInfo.get(userB.getId()); webSocketServer2.sendMessage(respB.toJSONString()); }
|
StartGameController.java
![image-20220824153753802.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
SecurityConfig.java
,对于"/pk/start/game/"
这样一个 URL,只允许本地调用。
![image-20220824153836952.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
这样backend
端的接收函数就实现了
匹配池启动
这样一个匹配池线程我们选择在 Springboot 启动之前随之启动
![image-20220824105744026.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
Debug 调试
启动MatchingSystem
所对应的SpringBoot
服务,可以看到,每秒就会执行一次matchPlayers()
![image-20220824163955389.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
现在前端一个玩家点击“匹配”按钮
![image-20220824164454541.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
可以看到对应的waitingTime
每隔一秒加 1
![image-20220824164635862.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
点击取消之后,就会删除。
之后测试两个用户
![image-20220824170756125.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
很快实现匹配
![image-20220824170730058.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
为了便于测试,将两名玩家的分值差距调大。
![image-20220824171202665.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
分差 100,根据匹配规则,需要满足与自己的分值差距,小于自己的等待时间*10,
ratingDelta <= waitingTime * 10; (ratingDelta = 100)
意味着
waitingTime >= 10
因此,需要两名玩家的等待时间都>=10 的时候,两者匹配。
![image-20220824171226445.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
测试结果如下:
![image-20220824171939710.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
老板模式
有些时候玩家匹配成功之后,游戏过程中,突然老板进来了,然后此时立刻关闭网页。也就是不通过请求的方式想匹配系统发起取消匹配,而是直接断开连接。
也就是针对:玩家在匹配池,但是玩家已经断开连接
![image-20220824173157365.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220824173522989.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
如上,报异常的原因是因为,userConnectionInfo.get(userA.getId())
返回的是一个空对象,然后空对象是没有game
属性的,所以会报错。
因此这里需要加一些判断。如果已经断开连接,还是将其匹配到一起,但是 6 秒之内没有接收到操作就会判输。
WebSocketServer.java
![image-20220824174716195.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
![image-20220824174748271.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
同时,consumer.utils.Game.java
也要添加判断
![image-20220824175133843.png](data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7)
如果已经断开连接,还是将其匹配到一起(不报异常),但是 6 秒之内没有接收到操作就会判输。