学习平台
AcWing SpringBoot框架课
系统流程
改进之处
存在的问题1:之前地图的创建是在前端写的,所以如果两个用户匹配之后,在各自的前端生成地图,则生成的地图很大程度上是不一样的,这样肯定是不行的。
解决办法:因此,在本节中,我们首先需要将生成地图这个过程放在后端中。这样当两个玩家匹配之后,需要调用一个单独的任务,这个任务会统一的给两个玩家生成地图。
问题2:裁判逻辑,也就是判断两个玩家的输赢也不应该放在前端。
解决办法:也需要将判断输赢这个过程放在后端,这样就可以避免恶意篡改结果。
前端在这个时候只是将后端的结果进行渲染展示,而不做任何判断逻辑。(并不是所有的游戏都需要将逻辑放在云端)
环境配置
集成WebSocket
1.在pom.xml
中导入依赖
spring-boot-starter-websocket
fastjson
2.添加config.WebSocketConfig
配置类
1 2 3 4 5 6 7 8 9 10 11 12 13
| import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration public class WebSocketConfig {
@Bean public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter(); } }
|
3.添加consumer.WebSocketServer
类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| import org.springframework.stereotype.Component;
import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint;
@Component @ServerEndpoint("/websocket/{token}") public class WebSocketServer { @OnOpen public void onOpen(Session session, @PathParam("token") String token) { }
@OnClose public void onClose() { }
@OnMessage public void onMessage(String message, Session session) { }
@OnError public void onError(Session session, Throwable error) { error.printStackTrace(); } }
|
对该类进行修改补充。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| package com.kob.backend.consum;
import com.kob.backend.mapper.UserMapper; import com.kob.backend.pojo.User; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component;
import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.concurrent.ConcurrentHashMap;
@Component @ServerEndpoint("/websocket/{token}") public class WebSocketServer {
private static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();
private User user; private Session session = null;
private static UserMapper userMapper;
@Autowired public void setUserMapper(UserMapper userMapper) { WebSocketServer.userMapper = userMapper; }
@OnOpen public void onOpen(Session session, @PathParam("token") String token) { this.session = session; System.out.println("connected!");
Integer userId = Integer.parseInt(token); this.user = userMapper.selectById(userId); users.put(userId, this);
}
@OnClose public void onClose() { System.out.println("disconnected!"); if(this.user != null) { users.remove(this.user.getId()); } }
@OnMessage public void onMessage(String message, Session session) { System.out.println("receive message!"); }
@OnError public void onError(Session session, Throwable error) { error.printStackTrace(); }
public void sendMessage(String message) { synchronized (this.session) { try { this.session.getBasicRemote().sendText(message); } catch (IOException e) { e.printStackTrace(); } } } }
|
4.配置config.SecurityConfig
需要将所有的websocket
请求放行
1 2 3 4
| @Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers("/websocket/**"); }
|
前端实现
- 首先需要实现
store/pk.js
来保存用户的匹配信息
status
:用来保存用户的状态,matching表示正在匹配,playing表示正在对战。其实就是控制前端展示的时匹配界面还是对战界面。
socket
:用来保存通过后端创建的websocket链接
opponent_username
:用来保存匹配的对手的用户名
opponent_photo
:用来保存匹配的对手的用户头像。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| export default { state: { status: "matching", 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: { } }
|
- 实现
views/pk/PkIndexView.vue
。
通过挂在函数onMouted
和卸载函数onUnmouted
来实现websocket链接的创建和断开。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| <template> <PlayGround> 对战 </PlayGround> </template>
<script>
import PlayGround from '@/components/PlayGround.vue' import { onMounted, onUnmounted } from 'vue'; import { useStore } from 'vuex';
export default { components: { PlayGround }, setup() { const store = useStore(); const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.id}/`;
let socket = null; onMounted(() => { // 挂载函数,页面打开时,会执行,创建一个websocket链接 store.commit("updateOpponent", { username: "我的对手", photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png", }); socket = new WebSocket(socketUrl);
socket.onopen = () => { console.log("connected!"); store.commit("updateSocket", socket); // 将创建的socket保存 }
socket.onmessage = msg => { const data = JSON.parse(msg.data); console.log(data); }
socket.onclose = () => { console.log("disconnected!"); } });
onUnmounted(() => { // 卸载函数,离开页面时调用,需要将websocket链接断开,避免冗余链接 socket.close(); }); }, } </script>
<style scoped>
</style>
|
验证jwt
这部分属于对前面部分的优化,前面向后端发送socket
请求时直接将用户的id
传了过去,这样不安全,因此需要将此改为传用户的token
1 2 3
| <script> const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}/`; </script>
|
同样需要在后端对前端传过来的token进行验证并取出所需要的userId
- 实现
consumer/utils/JwtAuthentication.java
来验证token并返回用户id.
这里实现的时静态方法。因此调用的时候直接通过类名.方法名
调用即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| package com.kob.backend.consumer.utils;
import com.kob.backend.utils.JwtUtil; import io.jsonwebtoken.Claims;
public class JwtAuthentication {
public static Integer getUserId(String token) { Integer userId = -1; try { Claims claims = JwtUtil.parseJWT(token); userId = Integer.parseInt(claims.getSubject()); } catch (Exception e) { throw new RuntimeException(e); } return userId; } }
|
- 修改
consumer/WebSocketServer.java
中的onOpen
函数,实现从上面的方法中获取用户名。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| @OnOpen public void onOpen(Session session, @PathParam("token") String token) throws IOException { this.session = session; System.out.println("connected!");
Integer userId = JwtAuthentication.getUserId(token); this.user = userMapper.selectById(userId);
if(this.user != null) { users.put(userId, this); } else { this.session.close(); } }
|
匹配界面前端实现
界面布局
前端实现
实现步骤
- 首先实现匹配页面
components/MatchGround.vue
- 需要注意的细节:当按下【开始匹配】按钮后,需要讲该按钮变为【取消匹配】,按下【取消匹配】按钮后,则需要讲按钮变为【开始匹配】
- 当开始匹配时,需要使用socket向后端发送一个
event
为start-matching
。取消匹配时,也需要向后端发送一个消息event为stop-matching
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| <template> <div class="matchground"> <div class="row"> <div class="col-6"> <div class="user-photo"> <img :src="$store.state.user.photo" alt=""> </div> <div class="user-username"> {{ $store.state.user.username }} </div> </div> <div class="col-6"> <div class="user-photo"> <img :src="$store.state.pk.opponent_photo" alt=""> </div> <div class="user-username"> {{ $store.state.pk.opponent_username }} </div> </div> <div class="col-12" style="text-align: center; padding-top: 15vh;"> <button @click="click_match_btn" type="button" class="btn btn-warning btn-lg">{{ match_btn_info }}</button> </div> </div> </div> </template>
<script> import { ref } from 'vue' import { useStore } from 'vuex';
export default { setup() { const store = useStore(); let match_btn_info = ref("开始匹配");
const click_match_btn = () => { if(match_btn_info.value === "开始匹配") { match_btn_info.value ="取消匹配"; store.state.pk.socket.send(JSON.stringify({ event: "start-matching", })); } else { match_btn_info.value = "开始匹配"; store.state.pk.socket.send(JSON.stringify({ event: "stop-matching", })); } }
return { match_btn_info, click_match_btn, } } }
</script>
<style scoped> div.matchground{ width: 60vw; height: 70vh; background-color: rgba(50, 50, 50, 0.5); margin: 40px auto 0; }
div.user-photo{ text-align: center; padding-top: 10vh; } div.user-photo > img { border-radius: 50%; width: 20vh; } div.user-username{ text-align: center; font-size: 24px; font-weight: 600; color: white; padding-top: 2vh; } </style>
|
- 修改Pk主页面
views/pk/PkIndexView.vue
,当没有匹配成功时显示匹配页面MatchGround.vue
,当匹配成功后显示对战页面PlayGround.vue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <template> <PlayGround v-if="$store.state.pk.status === 'playing'"></PlayGround> <MatchGround v-if="$store.state.pk.status === 'matching'"></MatchGround> </template>
<script> import MatchGround from '@/components/MatchGround.vue';
export default { components: { PlayGround, MatchGround, }, }; </script>
|
实现效果
点击开始匹配后:按钮变为取消匹配
匹配界面后端实现
实现步骤
- 首先在
consumer/WebSocketServer
中修改onMessage
函数,用来接收前端发送的字符串event
- 当event为
start-matching
时,调用startMatching()
函数
- 当event为
stop-matching
时,调用stopMatching()
函数
1 2 3 4 5 6 7 8 9 10 11 12 13
| @OnMessage public void onMessage(String message, Session session) { 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(); } }
|
- 在
consumer/WebSocketServer
开一个线程池,用来保存所有正在匹配的用户信息。
1
| final private static CopyOnWriteArrayList<User> matchpool = new CopyOnWriteArrayList<>();
|
- 修改
onClose
函数,当关闭连接时需要讲用户信息从线程池中移除。
1 2 3 4 5 6 7 8 9
| @OnClose public void onClose() { System.out.println("disconnected!"); if(this.user != null) { users.remove(this.user.getId()); matchpool.remove(this.user); } }
|
- 简单实现
startMatching
和stopMatching
,开始匹配时将用户信息加入匹配池,取消匹配的时候,讲用户从匹配池中移除。
1 2 3 4 5 6 7 8 9
| private void startMatching() { System.out.println("start matching"); matchpool.add(this.user); }
private void stopMatching() { System.out.println("stop matching"); matchpool.remove(this.user); }
|
实现傻瓜式匹配
实现步骤
具体思路:当线程池中的用户数量大于等于2时,讲用户两两匹配,这里之所以为傻瓜式匹配,就是没考虑战力rating的差距。
- 修改
consumer/WebSocketServer
中的startMatching
函数
匹配成功后,需要向前端发送消息。包括匹配成功的信息,和对手的相关信息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| private void startMatching() { System.out.println("start matching"); matchpool.add(this.user);
while(matchpool.size() >= 2) { Iterator<User> it = matchpool.iterator(); User a = it.next(), b = it.next(); matchpool.remove(a); matchpool.remove(b);
JSONObject respA = new JSONObject(); respA.put("event", "success-matching"); respA.put("opponent_username", b.getUsername()); respA.put("opponent_photo", b.getPhoto()); WebSocketServer webSocketServerA = users.get(a.getId()); webSocketServerA.sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject(); respB.put("event", "success-matching"); respB.put("opponent_username", a.getUsername()); respB.put("opponent_photo", a.getPhoto()); users.get(b.getId()).sendMessage(respB.toJSONString()); } }
|
- 前端实现,在
views/pk/PkIndexView.vue
中修改onMouted
挂载函数中的socket.message
- 当Pk页面被卸载时,需要将状态重新改为
matching
,需要重新匹配。修改onUnmounted
当接收到后端发送的event
为success-matching
时,则表示匹配成功,则需要调用store
中的函数将对手信息保存下来。然后在2000ms后将状态修改为playing
就可以跳转到对战页面。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| <script> onMounted(() => { // 挂载函数,页面打开时,会执行,创建一个websocket链接 store.commit("updateOpponent", { username: "我的对手", photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png", }); socket = new WebSocket(socketUrl);
socket.onopen = () => { console.log("connected!"); store.commit("updateSocket", socket); }
socket.onmessage = msg => { const data = JSON.parse(msg.data); if(data.event === "success-matching") { // 表示匹配成功 store.commit("updateOpponent", { username: data.opponent_username, photo: data.opponent_photo, }); setTimeout(() => { store.commit("updateStatus", "playing"); }, 2000); } console.log(data); }
socket.onclose = () => { console.log("disconnected!"); store.commit("updateStatus", "matching"); } }); onUnmounted(() => { // 卸载函数,离开页面时调用,需要将websocket链接断开,避免冗余链接 socket.close(); store.commit("updateStatus", "matching"); }); </script>
|
实现效果
当匹配成功后,会显示对手的头像和用户名信息。
然后跳转到对战页面,但是因为地图实现还在前端,所以两个玩家的地图不一致。因此后面就需要针对这个内容进行优化,将地图生成放在后端实现。
生成地图优化(同步)
需要将地图生成放在后端,这样可以保证两个玩家使用的是同一张地图。
实现步骤
- 将地图生成的逻辑转移至后端
consumer/utils/Game.java
中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
| package com.kob.backend.consumer.utils;
import java.util.Random;
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}, 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] = 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
中匹配成功后,调用Game
类中的createMap()
函数,进行地图生成。并将生成的地图分别传给两个玩家的前端。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| private void startMatching() { System.out.println("start matching"); matchpool.add(this.user);
while(matchpool.size() >= 2) { Iterator<User> it = matchpool.iterator(); User a = it.next(), b = it.next(); matchpool.remove(a); matchpool.remove(b);
Game game = new Game(13, 14, 20); game.createMap();
JSONObject respA = new JSONObject(); respA.put("event", "success-matching"); respA.put("opponent_username", b.getUsername()); respA.put("opponent_photo", b.getPhoto()); respA.put("gamemap", game.getG()); WebSocketServer webSocketServerA = users.get(a.getId()); webSocketServerA.sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject(); respB.put("event", "success-matching"); respB.put("opponent_username", a.getUsername()); respB.put("opponent_photo", a.getPhoto()); respB.put("gamemap", game.getG()); users.get(b.getId()).sendMessage(respB.toJSONString());
} }
|
- 在
store/pk.js
中引入一个变量gamemap
,并实现对应的赋值函数updateGamemap
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| export default { state: { status: "matching", socket: null, opponent_username: "", opponent_photo: "", gamemap: null, }, 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; }, updateGamemap(state, gamemap) { state.gamemap = gamemap; } }, actions: { }, modules: { } }
|
- 修改
views/pk/PkIndexView.vue
中的socket.message
函数。当匹配成功后需要接受后端发送过来的地图信息,并调用updateGamemap
函数对pk.js
中保存的地图信息进行更新。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <script> socket.onmessage = msg => { const data = JSON.parse(msg.data); if(data.event === "success-matching") { // 表示匹配成功 store.commit("updateOpponent", { username: data.opponent_username, photo: data.opponent_photo, }); setTimeout(() => { store.commit("updateStatus", "playing"); }, 2000); store.commit("updateGamemap", data.gamemap); } console.log(data); } </script>
|
- 将前端写的生成地图的逻辑代码删除;修改
scripts/GameMap.js
,需要在构造函数中传入store,用来接收地图信息。
- 新加
this.store = store
- 删除原来的判断连通性函数,因为在后端实现的时候就判断了
- 修改
create_walls
函数,只用来将g
中的信息绘制出来
- 修改start()函数,不用循环1000次直到找到连通性的地图才结束。因为后端传过来的地图已经保证了连通性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
| import { AcGameObject } from "./AcGameObject"; import { Snake } from "./Snake"; import { Wall } from "./Wall";
export class GameMap extends AcGameObject { constructor(ctx, parent, store) { super();
this.ctx = ctx; this.parent = parent; this.store = store; this.L = 0;
this.rows = 13; this.cols = 14;
this.walls = []; this.inner_walls_count = 20;
this.snakes = [ new Snake({id: 0, color: "#4876EC", r: this.rows - 2, c: 1}, this), new Snake({id: 1, color: "#F94848", r: 1, c: this.cols - 2}, this), ]; }
create_walls() { const g = this.store.state.pk.gamemap; for(let r = 0; r < this.rows; r ++) { for(let c = 0; c < this.cols; c ++) { if(g[r][c]) this.walls.push(new Wall(r, c, this)); } } }
add_listening_events() { this.ctx.canvas.focus();
const [snake0, snake1] = this.snakes; this.ctx.canvas.addEventListener("keydown", e => { if(e.key === 'w') snake0.set_direction(0); else if(e.key === 'd') snake0.set_direction(1); else if(e.key === 's') snake0.set_direction(2); else if(e.key === 'a') snake0.set_direction(3); else if(e.key === 'ArrowUp') snake1.set_direction(0); else if(e.key === 'ArrowRight') snake1.set_direction(1); else if(e.key === 'ArrowDown') snake1.set_direction(2); else if(e.key === 'ArrowLeft') snake1.set_direction(3); }); }
start() { this.create_walls(); this.add_listening_events(); }
update_size() { this.L = parseInt(Math.min(this.parent.clientWidth / this.cols, this.parent.clientHeight / this.rows)); this.ctx.canvas.width = this.L * this.cols; this.ctx.canvas.height = this.L * this.rows; }
check_ready() { for(const snake of this.snakes) { if(snake.status !== "idle") return false; if(snake.direction === -1) return false; } return true; }
next_step() { for(const snake of this.snakes) { snake.next_step(); } }
check_valid(cell) { for(const wall of this.walls) { if(wall.r === cell.r && wall.c === cell.c) return false; } for(const snake of this.snakes) { let k = snake.cells.length; if(!snake.check_tail_increasing()) { k --; } for(let i = 0; i < k; i ++) { if(snake.cells[i].r === cell.r && snake.cells[i].c === cell.c) return false; } } return true; }
update() { this.update_size(); if(this.check_ready()) { this.next_step(); } this.render(); }
render() { const color_even = '#AAD751', color_odd = '#A2D149' for(let r = 0; r < this.rows; r ++) { for(let c = 0; c < this.cols; c ++) { if((r + c) % 2 == 0) this.ctx.fillStyle = color_even; else this.ctx.fillStyle = color_odd; this.ctx.fillRect(c * this.L, r * this.L, this.L, this.L); } } }
destroy() { } }
|
实现效果
当匹配成功后,可以看到两个玩家的地图是完全一样的。
实现玩家的位置同步
玩家1的视角:自己在左下角,对手在右上角
玩家2的视角:自己在右上角,对手在左下角
因此需要将玩家的位置信息也放在后端实现consumer/utils/Player.java
,并且在后端实现的地图类consumer/utils/Game.java
里引入两个玩家的信息并进行同步。
实现步骤
后端实现
- 后端:实现玩家类
consumer/utils/Player.java
。里面包含玩家的id、起始位置(横纵坐标)以及玩家每一步所走的方向。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| package com.kob.backend.consumer.utils;
import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor;
import java.util.List;
@Data @NoArgsConstructor @AllArgsConstructor public class Player { private Integer id; private Integer sx; private Integer sy;
private List<Integer> steps; }
|
- 后端:修改
consumer/utils/Game.java
- 引入两个玩家类
playerA
,playerB
。其中A是左下角,B是右上角位置
- 修改构造函数,对两个玩家对象进行初始化。
- 实现两个玩家的
get
函数。
- 修改
startMatching
函数,当两个玩家匹配成功后,许需要将两个玩家的信息(id、sx、sy)以及地图信息发送给前端。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| private final Player playerA, playerB;
public Game(Integer rows, Integer cols, Integer inner_walls_count, Integer idA, Integer idB) { this.rows = rows; this.cols = cols; this.inner_walls_count = inner_walls_count; this.g = new int[rows][cols]; playerA = new Player(idA, this.rows - 2, 1, new ArrayList<>()); playerB = new Player(idB, 1, this.cols - 2, new ArrayList<>()); }
public Player getPlayerA() { return playerA; }
public Player getPlayerB() { return playerB; }
private void startMatching() { System.out.println("start matching"); matchpool.add(this.user);
while(matchpool.size() >= 2) { Iterator<User> it = matchpool.iterator(); User a = it.next(), b = it.next(); matchpool.remove(a); matchpool.remove(b);
Game game = new Game(13, 14, 20, a.getId(), b.getId()); game.createMap(); JSONObject respGame = new JSONObject(); respGame.put("a_id", game.getPlayerA().getId()); respGame.put("a_sx", game.getPlayerA().getSx()); respGame.put("a_sy", game.getPlayerA().getSy()); respGame.put("b_id", game.getPlayerB().getId()); respGame.put("b_sx", game.getPlayerB().getSx()); respGame.put("b_sy", game.getPlayerB().getSy()); respGame.put("map", game.getG());
JSONObject respA = new JSONObject(); respA.put("event", "success-matching"); respA.put("opponent_username", b.getUsername()); respA.put("opponent_photo", b.getPhoto()); respA.put("game", respGame); WebSocketServer webSocketServerA = users.get(a.getId()); webSocketServerA.sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject(); respB.put("event", "success-matching"); respB.put("opponent_username", a.getUsername()); respB.put("opponent_photo", a.getPhoto()); respB.put("game", respGame); users.get(b.getId()).sendMessage(respB.toJSONString());
} }
|
前端实现
- 前端:修改
store/pk.js
,
- 添加自己以及对手的信息(包括两个玩家的id,两个玩家的起始位置,以及地图信息),
- 添加更新函数,用来给上面的信息进行赋值
updateGame
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| export default { state: { status: "matching", socket: null, opponent_username: "", opponent_photo: "", gamemap: null, a_id: 0, a_sx: 0, a_sy: 0, b_id: 0, b_sx: 0, b_sy: 0, }, getters: { }, mutations: { updateSocket(state, socket) { state.socket = socket; }, updateOpponent(state, opponent) { state.opponent_username = opponent.username; state.opponent_photo = opponent.photo; }, updateStatus(state, status) { state.status = status; }, updateGame(state, game) { state.gamemap = game.map; state.a_id = game.a_id; state.a_sx = game.a_sx; state.a_sy = game.a_sy; state.b_id = game.b_id; state.b_sx = game.b_sx; state.b_sy = game.b_sy; }, }, actions: { }, modules: { } }
|
- 前端:修改
views/pk/PkIndexView.vue
中的socket.onmessage
函数。当匹配成功后,通过调用updateGame来将后端传过来的所有信息保存在store/pk.js
中。store.commit("updateGame", data.game);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| <script> socket.onmessage = msg => { const data = JSON.parse(msg.data); if(data.event === "success-matching") { // 表示匹配成功 store.commit("updateOpponent", { username: data.opponent_username, photo: data.opponent_photo, }); setTimeout(() => { store.commit("updateStatus", "playing"); }, 2000); store.commit("updateGame", data.game); } } </script>
|
实现效果
如上面所说,虽然不能直观的看出,但是对于玩家1的视角:自己在左下角,对手在右上角;玩家2的视角:自己在右上角,对手在左下角
实现蛇的移动(同步)⭐⭐
因为每局游戏现在有三个棋盘,每个用户各一个棋盘,再加上云端维护一个棋盘。因此需要保证三个棋盘的状态同步。
因为如果出现了多局游戏对战,可能会发生冲突,因此需要将Game类改为支持多线程,每产生一局游戏都会开一个线程。
实现步骤
后端实现
- 将后端的
consumer/utils/Game
变为支持多线程的类
- 继承
Thread
类
- 重写
Thread
类的入口函数run()
方法
1 2 3 4 5 6 7 8 9
| public class Game extends Thread {
@Override public void run() { } }
|
- 在
consumer/WebSocketServer
的startMatching
函数中启动一个线程。当匹配成功后启动一个线程。
- 需要保存
game
对象:private Game game = null;
- 修改
startMatching
函数,匹配成功后启动一个线程。启动线程后需要将game对象分别保存在两名玩家自己的game对象中。users.get(a.getId()).game = game; users.get(b.getId()).game = game;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| private Game game = null;
private void startMatching() { System.out.println("start matching"); matchpool.add(this.user);
while(matchpool.size() >= 2) { Iterator<User> it = matchpool.iterator(); User a = it.next(), b = it.next(); matchpool.remove(a); matchpool.remove(b);
Game game = new Game(13, 14, 20, a.getId(), b.getId()); game.createMap();
game.start(); users.get(a.getId()).game = game; users.get(b.getId()).game = game;
JSONObject respGame = new JSONObject(); respGame.put("a_id", game.getPlayerA().getId()); respGame.put("a_sx", game.getPlayerA().getSx()); respGame.put("a_sy", game.getPlayerA().getSy()); respGame.put("b_id", game.getPlayerB().getId()); respGame.put("b_sx", game.getPlayerB().getSx()); respGame.put("b_sy", game.getPlayerB().getSy()); respGame.put("map", game.getG());
JSONObject respA = new JSONObject(); respA.put("event", "success-matching"); respA.put("opponent_username", b.getUsername()); respA.put("opponent_photo", b.getPhoto()); respA.put("game", respGame); WebSocketServer webSocketServerA = users.get(a.getId()); webSocketServerA.sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject(); respB.put("event", "success-matching"); respB.put("opponent_username", a.getUsername()); respB.put("opponent_photo", a.getPhoto()); respB.put("game", respGame); users.get(b.getId()).sendMessage(respB.toJSONString());
} }
|
- 在
consumer/utils/Game.java
中实现具体的操作逻辑⭐
- 为了记录两名玩家的下一步操作,需要引入两个成员变量
nextStepA
和nextStepB
,初始都为null
- 引入一个成员变量
status
,用来记录游戏的状态,playing
表示正在进行游戏,finished
表示游戏结束了。
- 引入一个成员变量
loser
,记录游戏的输家。all
表示为平局,A表示A输,B表示B输。
- 因为
nextStepA
和nextStepB
可能两个线程同时读写,涉及到读写冲突,因此需要实现一个锁给每一个操作加锁。private ReentrantLock lock = new ReentrantLock();
- 实现两个
set
函数,来给两个变量nextStepA
和nextStepB
赋值。因为可能会产生读写冲突,因此需要加锁。
- 实现
nextStep()
函数,等待两名玩家进行操作,如果5s内两名玩家都已经操作了,则返回true
,否则返回false
,则游戏就该结束了。
- 注意:这边有个小细节,循环50次,每次睡100ms,如果时循环5次,每次睡1000ms,则可能按下键后他会把1s睡完才执行,会卡顿一下。
- 实现
run()
函数,每一步操作先调用nextStep
函数,来判断两个玩家是否都有输入了。
- 如果没有获取下一步操作,修改游戏状态为
finished
,然后判断一些游戏的输家并记录在loser
中,然后将结果发送给前端,通过调用sendResult
函数
- 如果获取到下一步操作,首先通过调用
judge
函数来判断两条蛇的操作是否合法:
- 如果
judge
结束之后,游戏状态仍为playing
,则向前端发送移动的信息,调用sendMove()
函数
- 否则,通过调用
sendResult
函数向前端发送结果信息。结束游戏。
- 实现
sendResult
函数:需要使用JSONObject
向前端传送信息,包括(游戏结束的提示event: result
,以及输家loser
),然后调用sendAllMessage
函数向两个玩家都发送信息。
- 实现
sendMove
函数:需要使用JSONObject
向前端传送信息,包括(移动的提示event: move
,以及两个玩家的移动方向a_direction: nextStepA
和b_direction: nextStepB
,然后调用sendAllMessage
函数向两个玩家都发送信息,发送玩信息后需要将nextStepA
和nextStepB
清空。
- 实现
sendAllMessage
函数:在这个函数中需要调用WebSocketServer
类中的users
,来找到两个玩家的websocket,在发送消息。
- 修改
WebSocketServer
的users
为public
,因为是静态的,所以可以直接通过类名.users
使用。
- 根据玩家的
id
来找到对应的websocket
,并调用onMessage
来向前端发送消息。WebSocketServer.users.get(playerA.getId()).sendMessage(message);
- 实现
judge
函数:实现起来比较复杂,在这段代码后详细说明。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
| private Integer nextStepA = null; private Integer nextStepB = null;
private String status = "playing"; private String loser = "";
private ReentrantLock lock = new ReentrantLock();
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(); }
}
private boolean nextStep() { try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); } for (int i = 0; i < 50; i++) { try { Thread.sleep(100); 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; }
private void judge() {
}
private void sendAllMessage(String message) { WebSocketServer.users.get(playerA.getId()).sendMessage(message); WebSocketServer.users.get(playerB.getId()).sendMessage(message); }
private void sendMove() { lock.lock(); try { JSONObject resp = new JSONObject(); resp.put("event", "move"); resp.put("a_direction", nextStepA); resp.put("b_direction", nextStepB); sendAllMessage(resp.toJSONString()); nextStepA = nextStepB = null; } finally { lock.unlock(); } }
private void sendResult() { JSONObject resp = new JSONObject(); resp.put("event", "result"); resp.put("loser", loser); sendAllMessage(resp.toJSONString()); }
@Override public void run() { for (int i = 0; i < 1000; i++) { if (nextStep()) { judge(); if ("playing".equals(status)) { sendMove(); } else { sendResult(); break; } } else { status = "finished"; lock.lock(); try { if (nextStepA == null && nextStepB == null) loser = "all"; else if (nextStepA == null) loser = "A"; else loser = "B"; } finally { lock.unlock(); } sendResult(); break; } } }
|
前端实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| <script> socket.onmessage = msg => { const data = JSON.parse(msg.data); if(data.event === "success-matching") { // 表示匹配成功 store.commit("updateOpponent", { username: data.opponent_username, photo: data.opponent_photo, }); setTimeout(() => { store.commit("updateStatus", "playing"); }, 2000); store.commit("updateGame", data.game); } else if (data.event === "move") { // 接收到后端的消息为 需要移动 const game = store.state.pk.gameObject; const [snake0, snake1] = game.snakes; // 取出两条蛇 snake0.set_direction(data.a_direction); snake1.set_direction(data.b_direction); } else if (data.event === "result") { // 游戏已经产生结果了 const game = store.state.pk.gameObject; const [snake0, snake1] = game.snakes; // 取出两条蛇 if (data.loser === "all" || data.loser === "A") { snake0.status = "die"; } if (data.loser === "all" || data.loser === "B") { snake1.status = "die"; } } } </script>
|
实现效果
两条蛇可以交互移动,如果某一条蛇超过5s没操作或者装障碍物或者撞到两个蛇的身体,则这条蛇输。
游戏结果优化
当游戏结束后,需要提示对应玩家的输赢信息。例如玩家A赢了,在玩家A的界面就现实Win,在玩家B的界面就显示Lose
实现步骤
这里是纯前端就可以实现,因为所需要的信息前面实现的后端已经满足了。
- 在
store/pk.js
中添加变量loser
用来保存输的玩家,同时实现updateLoser
函数
1 2 3 4 5 6 7 8 9 10
| export default { state: { loser: "none", }, mutations: { updateLoser(state, loser) { state.loser = loser; } }, }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
| <template> <div class="result-board"> <div class="result-board-text" v-if="$store.state.pk.loser === 'all'"> Draw </div> <div class="result-board-text" v-else-if="$store.state.pk.loser === 'A' && $store.state.pk.a_id === parseInt($store.state.user.id)"> Lose </div> <div class="result-board-text" v-else-if="$store.state.pk.loser === 'B' && $store.state.pk.b_id === parseInt($store.state.user.id)"> Lose </div> <div class="result-board-text" v-else> Win </div> <div class="result-board-button"> <button @click="restart" type="button" class="btn btn-warning btn-lg"> 再来! </button> </div> </div> </template>
<script> import { useStore } from 'vuex';
export default { setup() { const store = useStore(); const restart = () => { store.commit("updateStatus", "matching"); // 进入匹配界面 store.commit("updateLoser", "none"); store.commit("updateOpponent", { username: "我的对手", photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png", }); };
return { restart, }; } }
</script>
<style scoped> div.result-board{ height: 30vh; width: 30vw; background-color: rgba(50, 50, 50, 0.5); position: absolute; top: 30vh; left: 35vw; } div.result-board-text { text-align: center; color: white; font-size: 50px; font-weight: 600; font-style: italic; padding-top: 5vh; } div.result-board-button { text-align: center; padding-top: 7vh; } </style>
|
- 修改
views/pk/PkIndexView.vue
,导入ResultBoard
组件,当$store.state.pk.loser != 'none'
显示该组件
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <template> <PlayGround v-if="$store.state.pk.status === 'playing'"></PlayGround> <MatchGround v-if="$store.state.pk.status === 'matching'"></MatchGround> <ResultBoard v-if="$store.state.pk.loser != 'none'"></ResultBoard> </template>
<script> import ResultBoard from '@/components/ResultBoard.vue' export default { components: { ResultBoard, }, } </script>
|
实现效果
点击在来按钮时,跳转回匹配界面
游戏对战记录保存
数据库配置
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
代码实现
- 实现Record对应的实体类
pojo/Record
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| package com.kob.backend.pojo;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor;
import java.util.Date;
@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; }
|
1 2 3 4 5 6 7 8 9 10 11 12 13
| package com.kob.backend.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.kob.backend.pojo.Record; import org.apache.ibatis.annotations.Mapper;
@Mapper public interface RecordMapper extends BaseMapper<Record> { }
|
- 在
consumer/WebSocketServer
中实现RecordMapper
,并编写对应的set函数。
1 2 3 4 5 6
| public static RecordMapper recordMapper;
@Autowired public void setRecordMapper(RecordMapper recordMapper) { WebSocketServer.recordMapper = recordMapper; }
|
实现效果
当对局结束后,会将对局结果和对局记录保存到数据库中。
数据库数据
实现匹配系统的微服务⭐
项目框架
微服务项目创建&配置
创建spring cloud项目
配置Spring Cloud项目
- 因为spring cloud是一个父级项目,所以将
src
目录删除
- 在
pom.xml
中添加依赖
- 添加
<packaging>pom</packaging>
- 添加
spring-cloud-dependencies
依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.3</version> <relativePath/> </parent> <groupId>com.kob</groupId> <artifactId>backendcloud</artifactId> <packaging>pom</packaging>
<version>0.0.1-SNAPSHOT</version> <name>backendcloud</name> <description>backendcloud</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2021.0.3</version> <type>pom</type> <scope>import</scope> </dependency>
</dependencies>
<build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
</project>
|
创建子项目
创建子项目matchingSystem
创建项目matchingsystem
项目结构如下
创建子项目Backend
同样的方法创建一个新的Module:backend
将之前实现的backend中的src以及pom.xml中的依赖复制到新的模块中
项目结构如下:
pom.xml文件内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>backendcloud</artifactId> <groupId>com.kob</groupId> <version>0.0.1-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion>
<groupId>com.kob.backend</groupId> <artifactId>backend</artifactId>
<properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> <version>2.7.1</version> </dependency>
<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.24</version> <scope>provided</scope> </dependency>
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.29</version> </dependency>
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency>
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-generator</artifactId> <version>3.5.3</version> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>2.7.1</version> </dependency>
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency>
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> <version>2.7.2</version> </dependency>
<dependency> <groupId>com.alibaba.fastjson2</groupId> <artifactId>fastjson2</artifactId> <version>2.0.11</version> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> </project>
|
实现匹配系统matchingsystem
pom配置
将backendcloud
的pom.xml
中的dependencies
剪切至matchingsystem
中的pom.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| <?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>backendcloud</artifactId> <groupId>com.kob</groupId> <version>0.0.1-SNAPSHOT</version> </parent> <modelVersion>4.0.0</modelVersion>
<groupId>com.kob.matchingsystem</groupId> <artifactId>matchingsystem</artifactId>
<properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties>
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>
<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>2021.0.3</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies>
</project>
|
项目端口配置
在matchingsystem/src/main/resources/application.properties
中配置
matchingsystem匹配代码实现
添加/移除玩家代码实现
- 实现匹配的service接口
matchingsystem/service/MatchingService
1 2 3 4 5 6 7 8 9 10
| package com.kob.matchingsystem.service;
public interface MatchingService { public String addPlayer(Integer userId, Integer rating); public String removePlayer(Integer userId); }
|
- 实现对应的实现类
matchingsystem/service/impl/MatchingServiceImpl
。这边只是简单输出一些信息,用来判断功能是否可用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| package com.kob.matchingsystem.service.impl;
import com.kob.matchingsystem.service.MatchingService; import org.springframework.stereotype.Service;
@Service public class MatchingServiceImpl implements MatchingService { @Override public String addPlayer(Integer userId, Integer rating) { System.out.println("add Player" + userId + " " + rating); return "add player success"; }
@Override public String removePlayer(Integer userId) { System.out.println("remove player" + userId); return "remove player success"; } }
|
- 实现controller,
matchingsystem/controller/MatchingController
,这边接收参数需要使用MultiValueMap
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| package com.kob.matchingsystem.controller;
import com.kob.matchingsystem.service.MatchingService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController;
import java.util.Objects;
@RestController @RequestMapping("/player") public class MatchingController { @Autowired private MatchingService matchingService;
@PostMapping("/add") public String addPlayer(@RequestParam MultiValueMap<String, String> data) { Integer userId = Integer.parseInt(Objects.requireNonNull(data.getFirst("user_id"))); Integer rating = Integer.parseInt(Objects.requireNonNull(data.getFirst("rating"))); return matchingService.addPlayer(userId, rating); }
@PostMapping("/remove") public String removePlayer(@RequestParam MultiValueMap<String, String> data) { Integer userId = Integer.parseInt(Objects.requireNonNull(data.getFirst("user_id"))); return matchingService.removePlayer(userId); } }
|
恶意链接拦截
这里只允许通过本地的链接(127.0.0.1)进行访问,防止恶意访问
1 2 3 4 5 6
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>2.7.1</version> </dependency>
|
- 编写
matchingsystem/config/SecurityConfig
配置类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| package com.kob.matchingsystem.config;
import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.config.http.SessionCreationPolicy;
@Configuration @EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/player/add/", "/player/remove/").hasIpAddress("127.0.0.1") .antMatchers(HttpMethod.OPTIONS).permitAll() .anyRequest().authenticated(); } }
|
backend匹配代码改写
实现一个backend/config/RestTemplateConfig
,用来返回一个RestTemplate
对象,【RestTemplate 可以在两个spring boot之间进行通讯】,实现微服务通讯
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| package com.kob.backend.config;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.client.RestTemplate;
@Configuration public class RestTemplateConfig { @Bean public RestTemplate getRestTemplate() { return new RestTemplate(); } }
|
在backend/consumer/WebSocketServer
引入RestTemplate
对象
首先修改数据库,将rating
字段放在user
表中,而不放在bot
表中。需要修改对应的实现类Bot
和User
,同时需要修改对应的一些类register
、updateBot
、addBot
。
- 然后修改
backend/consumer/WebSocketServer
,将匹配成功 后生成地图、启动线程以及像前端发送消息单独放在一个函数中startGame
,(之前实在startMatching
函数中)
- 将所有有关
matchpool
(之前实现的模拟线程池)的操作删除,因为我们需要使用匹配系统微服务进行实现,需要删除的函数包括onClose
、stopMatching
、startMatching
- 实现对应的参数设置以及使用
RestTemplate
对象进行匹配系统之间的通讯。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85
| private static RestTemplate restTemplate;
@Autowired public void setRestTemplate(RestTemplate restTemplate) { WebSocketServer.restTemplate = restTemplate; }
@OnClose public void onClose() { System.out.println("disconnected!"); if(this.user != null) { users.remove(this.user.getId()); MultiValueMap<String, String> data = new LinkedMultiValueMap<>(); data.add("user_id", this.user.getId().toString()); restTemplate.postForObject(removePlayerUrl, data, String.class); } }
private void startGame(Integer aId, Integer bId) { User a = userMapper.selectById(aId); User b = userMapper.selectById(bId);
Game game = new Game(13, 14, 20, a.getId(), b.getId()); game.createMap();
game.start(); users.get(a.getId()).game = game; users.get(b.getId()).game = game;
JSONObject respGame = new JSONObject(); respGame.put("a_id", game.getPlayerA().getId()); respGame.put("a_sx", game.getPlayerA().getSx()); respGame.put("a_sy", game.getPlayerA().getSy()); respGame.put("b_id", game.getPlayerB().getId()); respGame.put("b_sx", game.getPlayerB().getSx()); respGame.put("b_sy", game.getPlayerB().getSy()); respGame.put("map", game.getG());
JSONObject respA = new JSONObject(); respA.put("event", "success-matching"); respA.put("opponent_username", b.getUsername()); respA.put("opponent_photo", b.getPhoto()); respA.put("game", respGame); WebSocketServer webSocketServerA = users.get(a.getId()); webSocketServerA.sendMessage(respA.toJSONString());
JSONObject respB = new JSONObject(); respB.put("event", "success-matching"); respB.put("opponent_username", a.getUsername()); respB.put("opponent_photo", a.getPhoto()); respB.put("game", respGame); users.get(b.getId()).sendMessage(respB.toJSONString()); }
private void startMatching() { System.out.println("start matching"); MultiValueMap<String, String> data = new LinkedMultiValueMap<>(); data.add("user_id", this.user.getId().toString()); data.add("rating", this.user.getRating().toString()); restTemplate.postForObject(addPlayerUrl, data, String.class); }
private void stopMatching() { System.out.println("stop matching"); MultiValueMap<String, String> data = new LinkedMultiValueMap<>(); data.add("user_id", this.user.getId().toString()); restTemplate.postForObject(removePlayerUrl, data, String.class); }
|
实现效果
点击开始匹配后,调用/player/add/
,输出对应的信息
点击取消匹配后,调用/player/remove
,输出对应的信息
匹配系统业务逻辑实现
需要实现matchingsystem/service/impl/MatchingServiceImpl
中的两个方法。
这里的匹配逻辑是:当接收一个玩家发送匹配请求后,将玩家信息存入一个池子(数组)中,开一个新的线程,每隔1s扫描一遍整个数组,将能够匹配的玩家匹配在一起。
匹配的时候需要将两名分值比较接近的玩家匹配在一起。随着时间的推移,两名玩家允许的分差可以越来越大。同时等待时间长的人优先匹配.
代码实现
- 首先在
matchingsystem/service/impl/utils
中实现一个类Player
,用来保存正在参加匹配的用户的信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| package com.kob.matchingsystem.service.impl.utils;
import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor;
@Data @AllArgsConstructor @NoArgsConstructor public class Player { private Integer userId; private Integer rating; private Integer waitingTime; }
|
- 在
matchingsystem
中实现一个线程池,用来保存所有正在匹配的用户信息,
- 实现
matchingsystem/service/utils/MatchingPool
类,需要继承Thread类,实现多线程。
- 使用
List<Player>
来保存所有正在匹配的用户。因为可能被多个线程同时读写,因此需要定义一个锁ReentrantLock
,用来保证读写不冲突。
- 重写
Thread
类中的run
方法。用来每秒循环一次,每次循环需要通过increaseWaitingTime
函数实现每个正在匹配的用户的等待时间加1,然后调用matchPlayers
函数进行玩家匹配。
increaseWaitingTime
函数:就是循环遍历一遍所有正在匹配的用户,给每个用户的等待时间加1即可。
matchPlayers
函数:首先定义一个布尔类型的数组,用来保存哪个用户已经匹配成功。然后双层循环遍历,判断两两玩家是否匹配,若都没有匹配,则调用checkMatch
函数进行判断当前两个用户是否可以匹配。如果可以匹配,则通过sendResult
函数向backend
发送匹配的玩家的消息。最后将匹配成功的用户从List
中移除
checkMatch
函数:首先计算两个用户rating
差值,只有当用户的战力差小于等于两个玩家中最小的等待时间*10才能匹配成功。
sendResult
函数:定义一个MultiValueMap
来保存匹配成功的两个用户的id,并通过restTemplate
向backend
发送.
- 这里需要导入
matchingsystem/config/RestTemplate
类。
- 在
matchingsystem/MatchingSystemApplication
的主函数中启动匹配线程。⭐意思是当这个服务启动时,就开始每1s检测一下是否存在可以匹配的两个用户。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
| package com.kob.matchingsystem.service.impl.utils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate;
import java.util.ArrayList; import java.util.List; import java.util.concurrent.locks.ReentrantLock;
@Component public class MatchingPool extends Thread{ private static List<Player> players = new ArrayList<>(); private ReentrantLock lock = new ReentrantLock();
private static RestTemplate restTemplate;
private final static String startGameUrl = "http://127.0.0.1:3000/pk/game/start/";
@Autowired public void setRestTemplate(RestTemplate restTemplate) { MatchingPool.restTemplate = restTemplate; }
public void addPlayer(Integer userId, Integer rating) { lock.lock(); try { players.add(new Player(userId, rating, 0)); }finally { lock.unlock(); } }
public void removePlayer(Integer userId) { lock.lock(); try { List<Player> newPlayers = new ArrayList<>(); for (Player player : players) { if (!player.getUserId().equals(userId)) { newPlayers.add(player); } } players = newPlayers; }finally { lock.unlock(); } }
private void increaseWaitingTime() { for(Player player : players ) { player.setWaitingTime(player.getWaitingTime() + 1); } }
private boolean checkMatch(Player a, Player b) { int ratingDelta = Math.abs(a.getRating() - b.getRating()); int waitingTime = Math.min(a.getWaitingTime(), b.getWaitingTime()); return ratingDelta <= waitingTime * 10; }
private void sendResult(Player a, Player b) { System.out.println("send result: " + a + " " + b); MultiValueMap<String, String> data = new LinkedMultiValueMap<>(); data.add("a_id", a.getUserId().toString()); data.add("b_id", b.getUserId().toString()); restTemplate.postForObject(startGameUrl, data, String.class); }
private void matchPlayers() { System.out.println("match players: " + players.toString()); boolean[] used = new boolean[players.size()]; for (int i = 0; i < players.size(); i++) { if (used[i]) continue; for (int j = i + 1; j < players.size(); j++) { if(used[j]) continue; Player a = players.get(i), b = players.get(j); if(checkMatch(a, b)) { used[i] = used[j] = true; sendResult(a, b); break; } } } List<Player> newPlayers = new ArrayList<>(); for (int i = 0; i < players.size(); i++) { if(!used[i]) newPlayers.add(players.get(i)); } players = newPlayers; }
@Override public void run() { while(true) { try { Thread.sleep(1000); lock.lock(); try { increaseWaitingTime(); matchPlayers(); } finally { lock.unlock(); } } catch (InterruptedException e) { e.printStackTrace(); break; } } } }
|
在backend中实现:
首先修改backend/config/SecurityConfig
中的configure
函数,将/pk/game/start
链接方向,并且只能通过本地访问。
1 2 3 4 5 6 7 8 9 10 11 12 13
| @Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/user/account/token/", "/user/account/register/").permitAll() .antMatchers("/pk/game/start/").hasIpAddress("127.0.0.1") .antMatchers(HttpMethod.OPTIONS).permitAll() .anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class); }
|
实现对应的backeng/service/pk/StartGameService
以及backend/service/impl/pk/StartGameServiceImpl
,在StartGameServiceImpl
中通过传来的匹配成功的两个玩家的id,然后调用WebSocketServer.startGame
函数,生成地图、向前端返回等一系列操作。这里需要把startGame
函数变为public static
。
1 2 3 4 5 6 7 8 9 10
| package com.kob.backend.service.pk;
public interface StartGameService { String startGame(Integer aId, Integer bId); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| package com.kob.backend.service.impl.pk;
import com.kob.backend.consumer.WebSocketServer; import com.kob.backend.service.pk.StartGameService; import org.springframework.stereotype.Service;
@Service public class StartGameServiceImpl implements StartGameService { @Override public String startGame(Integer aId, Integer bId) { System.out.println("start game: " + aId + " " + bId); WebSocketServer.startGame(aId, bId); return "start game success"; } }
|
实现backend/controller/pk/StartGameController
,接收从matchingsystem
中传来的id
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| package com.kob.backend.controller.pk;
import com.kob.backend.service.pk.StartGameService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.util.MultiValueMap; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController;
import java.util.Objects;
@RestController @RequestMapping("/pk") public class StartGameController {
@Autowired private StartGameService startGameService;
@PostMapping("/game/start") public String startGame(@RequestParam MultiValueMap<String, String> data) { int aId = Integer.parseInt(Objects.requireNonNull(data.getFirst("a_id"))); int bId = Integer.parseInt(Objects.requireNonNull(data.getFirst("b_id"))); return startGameService.startGame(aId, bId); } }
|
实现效果
这里将用户qqy的rating改为了1400,用户xzt的rating为1500,因此理论来说需要匹配10s才能匹配成功。
可以看到,10s后匹配成功了。并且当匹配成功后,匹配池会将匹配成功的两名玩家删除。
业务逻辑优化
存在的问题
当两名玩家正在匹配,此时其中一名玩家将匹配页面直接关闭,但是匹配池中还会存在,这个时候另一个玩家就不会匹配成功,并且会在后台报异常。
这是因此如果关闭页面则该用户的users
就不存在,所以需要在backend
中的所有存在users.get
位置加上判断。
- 修改
backend/consumer/WebSocketServer
中的startGame
函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
|
public static void startGame(Integer aId, Integer bId) { User a = userMapper.selectById(aId); User b = userMapper.selectById(bId);
Game game = new Game(13, 14, 20, a.getId(), b.getId()); game.createMap();
game.start(); if (users.get(a.getId()) != null) users.get(a.getId()).game = game; if (users.get(b.getId()) != null) users.get(b.getId()).game = game;
JSONObject respGame = new JSONObject(); respGame.put("a_id", game.getPlayerA().getId()); respGame.put("a_sx", game.getPlayerA().getSx()); respGame.put("a_sy", game.getPlayerA().getSy()); respGame.put("b_id", game.getPlayerB().getId()); respGame.put("b_sx", game.getPlayerB().getSx()); respGame.put("b_sy", game.getPlayerB().getSy()); respGame.put("map", game.getG());
JSONObject respA = new JSONObject(); respA.put("event", "success-matching"); respA.put("opponent_username", b.getUsername()); respA.put("opponent_photo", b.getPhoto()); respA.put("game", respGame); if(users.get(a.getId()) != null) { WebSocketServer webSocketServerA = users.get(a.getId()); webSocketServerA.sendMessage(respA.toJSONString()); }
JSONObject respB = new JSONObject(); respB.put("event", "success-matching"); respB.put("opponent_username", a.getUsername()); respB.put("opponent_photo", a.getPhoto()); respB.put("game", respGame); if(users.get(b.getId()) != null) users.get(b.getId()).sendMessage(respB.toJSONString()); }
|
- 修改
backend/consumer/utils/Game
中的sendAllMessage
函数
1 2 3 4 5 6 7 8 9
|
private void sendAllMessage(String message) { if(WebSocketServer.users.get(playerA.getId()) != null) WebSocketServer.users.get(playerA.getId()).sendMessage(message); if(WebSocketServer.users.get(playerB.getId()) != null) WebSocketServer.users.get(playerB.getId()).sendMessage(message); }
|