SpringBoot学习笔记-实现微服务:匹配系统(中)
笔记内容转载自 AcWing 的 SpringBoot 框架课讲义,课程链接:AcWing SpringBoot 框架课。
CONTENTS
- 1. 同步玩家位置
- 1.1 游戏信息的记录
- 1.2 实现多线程同步移动
- 2. 同步碰撞检测
- 3. 实现游戏结束界面
- 4. 持久化游戏状态
- 4.1 创建数据库表
- 4.2 保存游戏对局信息
1. 同步玩家位置
1.1 游戏信息的记录
两名玩家初始位置需要由服务器确定,且之后的每次移动都需要在服务器上判断。我们需要在 Game
类中添加 Player
类用来记录玩家的信息,在 consumer.utils
包下创建 Player
类:
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; // 记录历史走过的每一步方向
}
然后就可以在 Game
中创建玩家:
package com.kob.backend.consumer.utils;import java.util.ArrayList;
import java.util.Arrays;
import java.util.Random;public class Game {...private final Player playerA, playerB;public Game(Integer rows, Integer cols, Integer inner_walls_count, Integer idA, Integer idB) {...playerA = new Player(idA, rows - 2, 1, new ArrayList<>()); // 默认A在左下角B在右上角playerB = new Player(idB, 1, cols - 2, new ArrayList<>());}public Player getPlayerA() {return playerA;}public Player getPlayerB() {return playerB;}...
}
在 WebSocketServer
中创建 Game
时传入两名玩家的 ID,并且我们将与游戏内容相关的信息全部包装到一个 JSONObject
类中:
package com.kob.backend.consumer;import com.alibaba.fastjson2.JSONObject;
import com.kob.backend.consumer.utils.Game;
import com.kob.backend.consumer.utils.JwtAuthentication;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;import java.io.IOException;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {...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(); // 发送给A的信息respA.put("event", "match_success");respA.put("opponent_username", b.getUsername());respA.put("opponent_photo", b.getPhoto());respA.put("game", respGame);users.get(a.getId()).sendMessage(respA.toJSONString()); // A不一定是当前链接,因此要在users中获取JSONObject respB = new JSONObject(); // 发送给B的信息respB.put("event", "match_success");respB.put("opponent_username", a.getUsername());respB.put("opponent_photo", a.getPhoto());respB.put("game", respGame);users.get(b.getId()).sendMessage(respB.toJSONString());}}...
}
前端也需要进行相应的修改,在 store/pk.js
中创建两名玩家的信息:
export default {state: {...a_id: 0,a_sx: 0,a_sy: 0,b_id: 0,b_sx: 0,b_sy: 0,gameObject: null, // 整个GameMap对象},getters: {},mutations: {...updateGame(state, game) {state.game_map = 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;},updateGameObject(state, gameObject) {state.gameObject = gameObject;},},actions: {},modules: {},
};
在 GameMap.vue
中需要先将 GameMap
对象存下来,之后会在 PKIndexView
中用到:
...<script>
import { ref, onMounted } from "vue";
import { GameMap } from "@/assets/scripts/GameMap";
import { useStore } from "vuex";export default {setup() {const store = useStore();let parent = ref(null);let canvas = ref(null);onMounted(() => {store.commit("updateGameObject",new GameMap(canvas.value.getContext("2d"), parent.value, store));});return {parent,canvas,};},
};
</script>...
PKIndexView
中要传入从后端获取到的 game
数据:
...<script>
...export default {...setup() {...onMounted(() => {...socket.onmessage = (msg) => { // 接收到后端消息时会执行...if (data.event === "match_success") { // 匹配成功...store.commit("updateGame", data.game); // 更新游戏内容...}};...});...},
};
</script><style scoped></style>
1.2 实现多线程同步移动
我们需要实现两名玩家的客户端以及服务器端的移动同步,假如 Client1 发出了移动指令,那么就会将这个消息发送给服务器,同理另一个客户端 Client2 发出移动指令时也会将消息发送给服务器,服务器在接收到两名玩家的消息后再将消息同步给两名玩家。
我们在 WebSocketServer
中会维护一个游戏 Game
,这个 Game
也有自己的执行流程,它会先创建地图 creatMap
,接着会一步一步执行,即 nextStep
,每一步会等待两名玩家的操作,这个操作可以是键盘输入,也可以是由 Bot 代码执行的微服务返回回来的结果。获取输入后会将结果发送给一个评判系统 judge
,来判断两名玩家下一步是不是合法的,如果有一方不合法就游戏结束。
在等待用户输入时会有一个时间限制,比如5秒,如果有一方还没有输入则表示输了,同样也是游戏结束。否则如果两方输入的下一步都是合法的则继续循环 nextStep
。这个 nextStep
流程是比较独立的,而且每个游戏对局都有这个独立的过程,如果 Game
是单线程的,那么在等待用户输入时这个线程就会卡死,如果有多个游戏对局的话那么只能先卡死在某个对局中,其他对局的玩家体验就会很差,因此 Game
不能作为一个单线程来处理,每次在等待用户输入时都需要另起一个新的线程,这就涉及到了多线程的通信以及加锁的问题。
我们将 Game
类继承自 Thread
类即可转为多线程,然后需要实现 Thread
的入口函数,使用快捷键 Alt + Insert
,选择重写方法,需要重写的是 run()
方法,这是新线程的入口函数:
package com.kob.backend.consumer.utils;import com.alibaba.fastjson2.JSONObject;
import com.kob.backend.consumer.WebSocketServer;import java.util.ArrayList;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.locks.ReentrantLock;public class Game extends Thread {private final Integer rows;private final Integer cols;private final Integer inner_walls_count;private final boolean[][] g;private static final int[] dx = { -1, 0, 1, 0 }, dy = { 0, 1, 0, -1 };private final Player playerA, playerB;private Integer nextStepA = null; // 下一步操作,0、1、2、3分别表示四个方向,null表示还没有获取到private Integer nextStepB = null;private ReentrantLock lock = new ReentrantLock(); // 需要给nextStep变量上锁防止读写冲突private String status = "playing"; // 整局游戏的状态,结束后为finishedprivate String loser = ""; // 输的一方是谁,all表示平局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 boolean[rows][cols];playerA = new Player(idA, rows - 2, 1, new ArrayList<>()); // 默认A在左下角B在右上角playerB = new Player(idB, 1, cols - 2, new ArrayList<>());}public Player getPlayerA() {return playerA;}public Player getPlayerB() {return playerB;}public void setNextStepA(Integer nextStepA) { // 未来会在另一个线程中调用lock.lock(); // 操作nextStep变量前先上锁try {this.nextStepA = nextStepA;} finally {lock.unlock(); // 操作完后无论是否有异常都解锁}}public void setNextStepB(Integer nextStepB) {lock.lock();try {this.nextStepB = nextStepB;} finally {lock.unlock();}}public boolean[][] getG() {return g;}private boolean check_connectivity(int sx, int sy, int tx, int ty) {if (sx == tx && sy == ty) return true;g[sx][sy] = true;for (int i = 0; i < 4; i++) {int nx = sx + dx[i], ny = sy + dy[i];if (!g[nx][ny] && check_connectivity(nx, ny, tx, ty)) {g[sx][sy] = false; // 注意在这里我们用的g就是原始数组,因此修改后要记得还原return true;}}g[sx][sy] = false; // 记得还原return false;}private boolean drawMap() {// 初始化障碍物标记数组for (int i = 0; i < this.rows; i++) {Arrays.fill(g[i], false);}// 给地图四周加上障碍物for (int r = 0; r < this.rows; r++) {g[r][0] = g[r][this.cols - 1] = true;}for (int c = 0; c < this.cols; c++) {g[0][c] = g[this.rows - 1][c] = true;}// 添加地图内部的随机障碍物,需要有对称性因此枚举一半即可,另一半对称生成Random random = new Random();for (int i = 0; i < this.inner_walls_count / 2; i++) {for (int j = 0; j < 10000; j++) {int r = random.nextInt(this.rows); // 返回0~this.rows-1的随机整数int c = random.nextInt(this.cols);if (g[r][c] || g[this.rows - 1 - r][this.cols - 1 - c]) continue;if (r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) continue;g[r][c] = g[this.rows - 1 - r][this.cols - 1 - c] = true;break;}}return check_connectivity(this.rows - 2, 1, 1, this.cols - 2);}public void createMap() {for (int i = 0; i < 10000; i++) {if (drawMap()) {break;}}}private boolean nextStep() { // 等待两名玩家的下一步操作,在该方法中也会操作nextStep变量try {Thread.sleep(500); // 前端的蛇每秒走2格,因此走一格需要500ms,每次后端执行下一步时需要先sleep,否则快速的多次输入将会覆盖掉之前输入的信息} catch (InterruptedException e) {e.printStackTrace();}for (int i = 0; i < 50; i++) {try {Thread.sleep(100); // 每回合循环50次,每次睡眠100ms,即一回合等待用户输入的时间为5slock.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) { // 向两个Client发送消息WebSocketServer.users.get(playerA.getId()).sendMessage(message);WebSocketServer.users.get(playerB.getId()).sendMessage(message);}private void sendMove() { // 向两个Client发送移动消息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() { // 向两个Client公布结果JSONObject resp = new JSONObject();resp.put("event", "result");resp.put("loser", loser);sendAllMessage(resp.toJSONString());}@Overridepublic void run() {for (int i = 0; i < 1000; i++) { // 游戏最多走的步数不会超过1000if (nextStep()) { // 是否获取了两条蛇的下一步操作judge();if ("playing".equals(status)) { // 如果游戏还在进行中则需要将两名玩家的操作广播给两个ClientsendMove();} 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(); // 这一步结束后需要给两个Client发送消息break;}}}
}
然后前端 GameMap.js
中在移动时需要向后端通信,现在两名玩家的键盘输入操作就只需要 W/S/A/D
了:
import { AcGameObject } from "./AcGameObject";
import { Wall } from "./Wall";
import { Snake } from "./Snake";export class GameMap extends AcGameObject {...add_listening_events() {this.ctx.canvas.focus(); // 使Canvas聚焦this.ctx.canvas.addEventListener("keydown", e => {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 !== -1) {this.store.state.pk.socket.send(JSON.stringify({event: "move",direction: d,}));}});}...
}
WebSocketServer
对于每局游戏对局都会创建一个 Game
类,通过 start()
方法可以新开一个线程运行 Game
中的 run()
方法,由于我们需要在 Game
中使用 WebSocketServer
的 users
,还需要将 users
修改为 public
,然后需要接收前端的移动请求:
package com.kob.backend.consumer;import com.alibaba.fastjson2.JSONObject;
import com.kob.backend.consumer.utils.Game;
import com.kob.backend.consumer.utils.JwtAuthentication;
import com.kob.backend.mapper.UserMapper;
import com.kob.backend.pojo.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;import java.io.IOException;
import java.util.Iterator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {// ConcurrentHashMap是一个线程安全的哈希表,用于将用户ID映射到WS实例public static final ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();// CopyOnWriteArraySet也是线程安全的private static final CopyOnWriteArraySet<User> matchPool = new CopyOnWriteArraySet<>(); // 匹配池private User user;private Session session = null;private Game game = null;private static UserMapper userMapper;@Autowiredpublic void setUserMapper(UserMapper userMapper) {WebSocketServer.userMapper = userMapper;}@OnOpenpublic void onOpen(Session session, @PathParam("token") String token) throws IOException {this.session = session;System.out.println("Connected!");Integer userId = JwtAuthentication.getUserId(token);this.user = userMapper.selectById(userId);if (user != null) {users.put(userId, this);} else {this.session.close();}}@OnClosepublic void onClose() {System.out.println("Disconnected!");if (this.user != null) {users.remove(this.user.getId());matchPool.remove(this.user);}}@OnMessagepublic void onMessage(String message, Session session) { // 一般会把onMessage()当作路由System.out.println("Receive message!");JSONObject data = JSONObject.parseObject(message);String event = data.getString("event"); // 取出event的内容if ("start_match".equals(event)) { // 开始匹配this.startMatching();} else if ("stop_match".equals(event)) { // 取消匹配this.stopMatching();} else if ("move".equals(event)) { // 移动move(data.getInteger("direction"));}}@OnErrorpublic void onError(Session session, Throwable error) {error.printStackTrace();}public void sendMessage(String message) { // 从后端向当前链接发送消息synchronized (this.session) { // 由于是异步通信,需要加一个锁try {this.session.getBasicRemote().sendText(message);} catch (IOException e) {e.printStackTrace();}}}private void startMatching() {System.out.println("Start matching!");matchPool.add(this.user);while (matchPool.size() >= 2) { // 临时调试用的,未来要替换成微服务Iterator<User> it = matchPool.iterator();User a = it.next(), b = it.next();matchPool.remove(a);matchPool.remove(b);game = new Game(13, 14, 20, a.getId(), b.getId());game.createMap();users.get(a.getId()).game = game;users.get(b.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(); // 发送给A的信息respA.put("event", "match_success");respA.put("opponent_username", b.getUsername());respA.put("opponent_photo", b.getPhoto());respA.put("game", respGame);users.get(a.getId()).sendMessage(respA.toJSONString()); // A不一定是当前链接,因此要在users中获取JSONObject respB = new JSONObject(); // 发送给B的信息respB.put("event", "match_success");respB.put("opponent_username", a.getUsername());respB.put("opponent_photo", a.getPhoto());respB.put("game", respGame);users.get(b.getId()).sendMessage(respB.toJSONString());}}private void stopMatching() {System.out.println("Stop matching!");matchPool.remove(this.user);}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);}}
}
最后在 PKIndexView
中处理接收到后端发来的移动消息以及游戏结束消息:
<template><PlayGround v-if="$store.state.pk.status === 'playing'" /><MatchGround v-else />
</template><script>
import PlayGround from "@/components/PlayGround.vue";
import MatchGround from "@/components/MatchGround.vue";
import { onMounted, onUnmounted } from "vue";
import { useStore } from "vuex";export default {components: {PlayGround,MatchGround,},setup() {const store = useStore();let socket = null;let socket_url = `ws://localhost:3000/websocket/${store.state.user.jwt_token}/`;onMounted(() => {socket = new WebSocket(socket_url);store.commit("updateOpponent", {username: "我的对手",photo: "https://cdn.acwing.com/media/article/image/2022/08/09/1_1db2488f17-anonymous.png",});socket.onopen = () => { // 链接成功建立后会执行console.log("Connected!");store.commit("updateSocket", socket);};socket.onmessage = (msg) => { // 接收到后端消息时会执行const data = JSON.parse(msg.data); // Spring传过来的数据是放在消息的data中console.log(data);if (data.event === "match_success") { // 匹配成功store.commit("updateOpponent", { // 更新对手信息username: data.opponent_username,photo: data.opponent_photo,});store.commit("updateGame", data.game); // 更新游戏内容setTimeout(() => { // 3秒后再进入游戏地图界面store.commit("updateStatus", "playing");}, 3000);} else if (data.event === "move") { // 两名玩家的移动const gameObject = store.state.pk.gameObject;const [snake0, snake1] = gameObject.snakes;snake0.set_direction(data.a_direction);snake1.set_direction(data.b_direction);} else if (data.event === "result") { // 游戏结束const gameObject = store.state.pk.gameObject;const [snake0, snake1] = gameObject.snakes;if (data.loser === "all" || data.loser === "A") {snake0.status = "die";}if (data.loser === "all" || data.loser === "B") {snake1.status = "die";}}};socket.onclose = () => { // 关闭链接后会执行console.log("Disconnected!");store.commit("updateStatus", "matching"); // 进入游戏地图后玩家点击其他页面应该是默认退出游戏};});onUnmounted(() => {socket.close(); // 如果不断开链接每次切换页面都会创建新链接,就会导致有很多冗余链接});},
};
</script><style scoped></style>
2. 同步碰撞检测
现在还需要将碰撞检测放到后端进行判断,先将 Snake.js
中的碰撞检测判断代码删掉,并将死后变白的逻辑放到 render()
函数中:
...export class Snake extends AcGameObject {...next_step() { // 将蛇的状态变为走下一步...// if (!this.gamemap.check_next_valid(this.next_cell)) { // 下一步不合法// this.status = "die";// }}...render() {...ctx.fillStyle = this.color;if (this.status === "die") {ctx.fillStyle = "white";}...}
}
接下来需要实现后端中的 judge()
方法,在判断的时候需要知道当前蛇的身体有哪些,先在 comsumer.utils
包下创建 Cell
类表示身体的每一格:
package com.kob.backend.consumer.utils;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;@Data
@AllArgsConstructor
@NoArgsConstructor
public class Cell {int x;int y;
}
然后在 Player
类中创建一个方法能够根据玩家历史走过的路径找出当前这条蛇身体的每一格:
package com.kob.backend.consumer.utils;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;import java.util.ArrayList;
import java.util.List;@Data
@NoArgsConstructor
@AllArgsConstructor
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 <= 7) return true; // 前7回合每一回合长度都增加return step % 3 == 1; // 之后每3回合增加一次长度}public List<Cell> getCells() { // 返回蛇的身体,每次都根据蛇历史走的方向将其每一格找出来List<Cell> cells = new ArrayList<>();int[] dx = { -1, 0, 1, 0 }, dy = { 0, 1, 0, -1 };int x = sx, y = sy;int step = 0;cells.add(new Cell(x, y));for (int d: steps) {x += dx[d];y += dy[d];cells.add(new Cell(x, y));if (!check_tail_increasing(++step)) {cells.remove(0); // 删掉蛇尾,即第一个起始的位置}}return cells;}
}
最后即可在 Game
类中实现 judge()
方法:
...public class Game extends Thread {...private boolean check_valid(List<Cell> cellsA, List<Cell> cellsB) { // 判断A是否合法int n = cellsA.size();Cell headCellA = cellsA.get(n - 1); // A的头,也就是最后一个Cellif (g[headCellA.x][headCellA.y]) {return false;}for (int i = 0; i < n - 1; i++) { // 判断除了头以外的其他身体部分if (cellsA.get(i).x == headCellA.x && cellsA.get(i).y == headCellA.y) {return false;}if (cellsB.get(i).x == headCellA.x && cellsB.get(i).y == headCellA.y) {return false;}}return true;}private void judge() { // 判断两名玩家下一步操作是否合法List<Cell> cellsA = playerA.getCells();List<Cell> cellsB = playerB.getCells();boolean validA = check_valid(cellsA, cellsB);boolean validB = check_valid(cellsB, cellsA);if (!validA || !validB) {status = "finished";if (!validA && !validB) {loser = "all";} else if (!validA) {loser = "A";} else {loser = "B";}}}...
}
3. 实现游戏结束界面
首先我们需要将输的玩家记录到前端的全局变量中,在 store/pk.js
中添加 loser
变量:
export default {state: {...loser: "none", // none表示没人输,all表示平局,A/B表示A/B赢},getters: {},mutations: {...updateLoser(state, loser) {state.loser = loser;},},actions: {},modules: {},
};
然后在 PKIndexView
组件的游戏结束处理语句块中添加更新 loser
的语句:
store.commit("updateLoser", data.loser);
游戏结束后需要给用户给用户展示谁赢谁输的界面,并提供一个重开按钮,在 components
目录下创建 ResultBoard.vue
:
<template><div class="card text-bg-secondary text-center"><div class="card-header" style="font-size: 26px;">游戏结束</div><div class="card-body" style="background-color: rgba(255, 255, 255, 0.4);"><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.toString() === $store.state.user.id">Lose</div><div class="result_board_text" v-else-if="$store.state.pk.loser === 'B' && $store.state.pk.b_id.toString() === $store.state.user.id">Lose</div><div class="result_board_text" v-else>Win</div><div class="result_board_btn"><button @click="returnHome" type="button" class="btn btn-info btn-lg">返回主页</button></div></div></div>
</template><script>
import { useStore } from "vuex";export default {setup() {const store = useStore();const returnHome = () => { // 需要复原一些全局变量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 {returnHome,};},
};
</script><style scoped>
.card {width: 30vw;position: absolute;top: 25vh;left: 35vw;
}.result_board_text {color: white;font-size: 50px;font-weight: bold;font-style: italic;padding: 5vh 0;
}.result_board_btn {padding: 3vh 0;
}
</style>
4. 持久化游戏状态
4.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
创建该数据库表的 SQL 语句如下:
CREATE TABLE `kob`.`record` (`id` int NOT NULL AUTO_INCREMENT,`a_id` int NULL,`a_sx` int NULL,`a_sy` int NULL,`b_id` int NULL,`b_sx` int NULL,`b_sy` int NULL,`a_steps` varchar(1000) NULL,`b_steps` varchar(1000) NULL,`map` varchar(1000) NULL,`loser` varchar(10) NULL,`createtime` datetime NULL,PRIMARY KEY (`id`)
);
在 pojo
包下创建 Record
类如下:
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
@NoArgsConstructor
@AllArgsConstructor
public class Record {@TableId(value = "id", type = IdType.AUTO)private Integer id;private Integer aId;private Integer aSx; // 注意别忘了驼峰命名private Integer aSy;private Integer bId;private Integer bSx;private Integer bSy;private String aSteps;private String bSteps;private String map;private String loser;@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")private Date createtime;
}
在 mapper
包下创建 RecordMapper
类如下:
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> {
}
4.2 保存游戏对局信息
可以在向前端发送游戏结果消息之前将对局信息存下来,首先需要在 WebSocketServer
中将 RecordMapper
创建出来:
...@Component
@ServerEndpoint("/websocket/{token}") // 注意不要以'/'结尾
public class WebSocketServer {...public static RecordMapper recordMapper; // 要在Game中调用@Autowiredpublic void setRecordMapper(RecordMapper recordMapper) {WebSocketServer.recordMapper = recordMapper;}...
}
然后在 Player
中创建辅助函数用来返回 steps
的字符串形式:
...@Data
@NoArgsConstructor
@AllArgsConstructor
public class Player {...private List<Integer> steps; // 记录历史走过的每一步方向...public String getStringSteps() { // 将steps转换成字符串StringBuilder res = new StringBuilder();for (int d: steps) {res.append(d);}return res.toString();}
}
最后就可以在 Game
中将游戏记录保存至数据库中:
...public class Game extends Thread {...private String getStringMap() { // 将g转换成01字符串StringBuilder res = new StringBuilder();for (int i = 0; i < rows; i++) {for (int j = 0; j < cols; j++) {res.append(g[i][j] ? 1 : 0);}}return res.toString();}private void saveRecord() { // 将对局信息存到数据库中Record record = new Record(null,playerA.getId(),playerA.getSx(),playerA.getSy(),playerB.getId(),playerB.getSx(),playerB.getSy(),playerA.getStringSteps(),playerB.getStringSteps(),getStringMap(),loser,new Date());WebSocketServer.recordMapper.insert(record);}private void sendResult() { // 向两个Client公布结果JSONObject resp = new JSONObject();resp.put("event", "result");resp.put("loser", loser);saveRecord(); // 在发送结束消息给前端之前先将游戏记录存下来sendAllMessage(resp.toJSONString());}...
}
相关文章:
SpringBoot学习笔记-实现微服务:匹配系统(中)
笔记内容转载自 AcWing 的 SpringBoot 框架课讲义,课程链接:AcWing SpringBoot 框架课。 CONTENTS 1. 同步玩家位置1.1 游戏信息的记录1.2 实现多线程同步移动 2. 同步碰撞检测3. 实现游戏结束界面4. 持久化游戏状态4.1 创建数据库表4.2 保存游戏对局信息…...
【复杂网络建模】——基于代理的社会网络建模(Agent-Based Modeling,ABM)[Python实现]
目录 一、复杂网络建模方法 二、基于代理的社会网络建模实现及Python实现代码 一、复杂网络建模方法 复杂网络是一种由大量相互连接的元素(节点或顶点)组成的网络结构,这些连接通常是非常复杂和动态的。这些网络可以在各种领域中发现,包括社交网络、生物学系统、信息技术…...
RSA实现中弱密钥漏洞分析(Analyzing Weak Key Vulnerabilities in RSA Implementation)
点我完整下载:《RSA实现中弱密钥漏洞分析》本科毕业论文一万字.doc RSA实现中弱密钥漏洞分析 "Analyzing Weak Key Vulnerabilities in RSA Implementation" 目录 目录 2 摘要 3 关键词 4 第一章 引言 4 1.1 研究背景 4 1.2 研究目的 5 1.3 研究意义 6 第…...
【管理运筹学】背诵手册(六)| 图与网络分析(最大流问题,最小费用最大流问题)
六、图与网络分析 最大流问题 最大流问题的数学规划模型为: max v f 12 f 13 { f 12 f 13 − f 57 − f 67 0 f 13 f 23 f 34 f 35 . . . 0 ≤ f i j ≤ c i j \max vf_{12}f_{13}\\ \begin{cases} f_{12}f_{13}-f_{57}-f_{67}0 \\ f_{13}f_{23}f_{34}f…...

C语言之结构体详解
C语言之结构体详解 文章目录 C语言之结构体详解1. 结构体类型的声明2. 结构体变量的创建和初始化3. 结构体的特殊声明4. 结构体的自引用结构体的自引用匿名结构体的自引用 5. 结构体内存对齐5.1 练习一5.2 练习三 6. 为什么存在内存对⻬? 1. 结构体类型的声明 struct tag {me…...
学习canvas
Canvas是一个基于像素的渲染引擎,它使用JavaScript API在画布上绘制图像。以下是它的一些优点和缺点: 优点: Canvas的渲染速度快,适合处理大量图像和高度动态的图像。 可以直接操作像素,从而能够创建出高质量、流畅的…...
浏览器的渲染原理
以下内容来源于渡一前端大师课,仅作个人学习记录。 渲染的第一步是 解析HTML 解析过程中遇到CSS解析CSS,遇到JS执行JS。为了提高解析效率,浏览器在开始解析之前,会启动一个预解析的线程,率先下载HTML中的外部CSS文件和…...
从 JSON 转 Java 实体的多种方法详解
将 JSON 数据转换为 Java 对象是现代应用程序开发中常见的任务。在 Java 中,有多种方法可以实现这一目标。本文将详细介绍几种常见的方法,以及它们的优缺点。 1. 手动映射(Manual Mapping) 手动映射是最基础的方法之一ÿ…...

数据库的多表查询(MYSQL)表表联立
根据以上三张表格,对三张表格进行不同的联立,查询并显示符合条件的内容。 1. 查出至少有一个员工的部门。显示部门编号、部门名称、部门位置、部门人数。 mysql> SELECT d.deptno AS 部门编号, d.dname as 部门名称, d.loc as 部门位置, COUNT(e.emp…...
P8650 [蓝桥杯 2017 省 A] 正则问题(dfs )
多重括号,利用回溯来对上一层括号中的内容进行反馈 实现: 若为 x 长度加一 若为 ( 进入递归计算 (计算相当于子表达式) 若为 ) 结束当前递归 若为 | …...

【ESP32】手势识别实现笔记:红外温度阵列 | 双三次插值 | 神经网络 | TensorFlow | ESP-DL
目录 一、开发环境搭建与新建工程模板1.1、开发环境搭建与卸载1.2、新建工程目录1.3、自定义组件 二、驱动移植与应用开发2.1、I2C驱动移植与AMG8833应用开发2.2、SPI驱动移植与LCD应用开发2.3、绘制温度云图2.4、启用PSRAM(可选)2.5、画面动静和距离检测…...

No matching version found for @babel/compat-data@^7.23.5 处理
npm ERR! notarget No matching version found for babel/compat-data^7.23.5 处理 报错信息 npm WARN ERESOLVE overriding peer dependency npm ERR! code ETARGET npm ERR! notarget No matching version found for babel/compat-data^7.23.5. npm ERR! notarget In most …...

手持机|三防智能手机_4寸/5寸/6寸安卓系统三防手机PDA手持终端方案
随着科技的不断发展,三防手持机作为一种多功能设备,正逐渐在各行业得到广泛应用。这款手持机采用高性能处理器,支持高精度北斗定位和工业本安防爆功能,并具备IP67级防水防尘性能和1.5米防跌落能力。因此,它在仓储管理、…...

蓝桥杯算法心得——仙界诅咒(dfs)
大家好,我是晴天学长,搜索型的dfs,差点开二维矩阵了,仔细一想,没那么夸张啊,哈哈哈,需要的小伙伴可以关注支持一下哦!后续会继续更新的。💪💪💪 1…...

List集合,遍历,数据结构
一.List常见的方法: 二. List集合的遍历方式 除了 迭代器遍历 增强for遍历 Lambda表达式遍历,还有自己独有的普通for遍历,列表迭代器遍历 1.迭代器遍历 2.增强for遍历 3.Lambda表达式遍历 4.普通for遍历 5.列表迭代器遍历 列表迭代器相对于…...

2的幂运算
2的幂 描述 : 给你一个整数 n,请你判断该整数是否是 2 的幂次方。如果是,返回 true ;否则,返回 false 。 如果存在一个整数 x 使得 n 2x ,则认为 n 是 2 的幂次方。 题目 : LeetCode 231.2的幂 : 231. 2 的幂 分…...
优先队列经典例题leetcode思路代码详解
目录 leetcode215题.数组中的第k个最大元素 leetcode347题.前k个高频元素 leetcode295题.数据流的中位数 对优先队列感兴趣的朋友可以去看我上一篇文章。 优先队列基础讲解-CSDN博客 leetcode215题.数组中的第k个最大元素 215. 数组中的第K个最大元素 - 力扣(…...
新型Python环境与依赖管理工具——pipenv
文章目录 pipenv介绍pipenv安装pipenv使用创建虚拟环境删除虚拟环境安装依赖查看包之间的依赖图卸载依赖在虚拟环境中执行命令shell环境下通过requirements.txt安装依赖导出requirements.txt文件查看虚拟环境的路径 pipenv介绍 pipenv可以看做是pip和virtualenv的组合体&#…...

FastDFS+Nginx - 本地搭建文件服务器同时实现在外远程访问「内网穿透」
文章目录 前言1. 本地搭建FastDFS文件系统1.1 环境安装1.2 安装libfastcommon1.3 安装FastDFS1.4 配置Tracker1.5 配置Storage1.6 测试上传下载1.7 与Nginx整合1.8 安装Nginx1.9 配置Nginx 2. 局域网测试访问FastDFS3. 安装cpolar内网穿透4. 配置公网访问地址5. 固定公网地址5.…...
kendo-splitter动态分配分隔框大小
通过size方法,动态改变框大小,参考链接:https://docs.telerik.com/kendo-ui/api/javascript/ui/splitter/methods/size vue画面 <kendo-button type"primary" click"changePane">button</kendo-button><…...
KubeSphere 容器平台高可用:环境搭建与可视化操作指南
Linux_k8s篇 欢迎来到Linux的世界,看笔记好好学多敲多打,每个人都是大神! 题目:KubeSphere 容器平台高可用:环境搭建与可视化操作指南 版本号: 1.0,0 作者: 老王要学习 日期: 2025.06.05 适用环境: Ubuntu22 文档说…...

Linux应用开发之网络套接字编程(实例篇)
服务端与客户端单连接 服务端代码 #include <sys/socket.h> #include <sys/types.h> #include <netinet/in.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <arpa/inet.h> #include <pthread.h> …...

智慧医疗能源事业线深度画像分析(上)
引言 医疗行业作为现代社会的关键基础设施,其能源消耗与环境影响正日益受到关注。随着全球"双碳"目标的推进和可持续发展理念的深入,智慧医疗能源事业线应运而生,致力于通过创新技术与管理方案,重构医疗领域的能源使用模式。这一事业线融合了能源管理、可持续发…...
解锁数据库简洁之道:FastAPI与SQLModel实战指南
在构建现代Web应用程序时,与数据库的交互无疑是核心环节。虽然传统的数据库操作方式(如直接编写SQL语句与psycopg2交互)赋予了我们精细的控制权,但在面对日益复杂的业务逻辑和快速迭代的需求时,这种方式的开发效率和可…...

基于Docker Compose部署Java微服务项目
一. 创建根项目 根项目(父项目)主要用于依赖管理 一些需要注意的点: 打包方式需要为 pom<modules>里需要注册子模块不要引入maven的打包插件,否则打包时会出问题 <?xml version"1.0" encoding"UTF-8…...
JVM暂停(Stop-The-World,STW)的原因分类及对应排查方案
JVM暂停(Stop-The-World,STW)的完整原因分类及对应排查方案,结合JVM运行机制和常见故障场景整理而成: 一、GC相关暂停 1. 安全点(Safepoint)阻塞 现象:JVM暂停但无GC日志,日志显示No GCs detected。原因:JVM等待所有线程进入安全点(如…...

九天毕昇深度学习平台 | 如何安装库?
pip install 库名 -i https://pypi.tuna.tsinghua.edu.cn/simple --user 举个例子: 报错 ModuleNotFoundError: No module named torch 那么我需要安装 torch pip install torch -i https://pypi.tuna.tsinghua.edu.cn/simple --user pip install 库名&#x…...

中医有效性探讨
文章目录 西医是如何发展到以生物化学为药理基础的现代医学?传统医学奠基期(远古 - 17 世纪)近代医学转型期(17 世纪 - 19 世纪末)现代医学成熟期(20世纪至今) 中医的源远流长和一脉相承远古至…...
CSS | transition 和 transform的用处和区别
省流总结: transform用于变换/变形,transition是动画控制器 transform 用来对元素进行变形,常见的操作如下,它是立即生效的样式变形属性。 旋转 rotate(角度deg)、平移 translateX(像素px)、缩放 scale(倍数)、倾斜 skewX(角度…...

(一)单例模式
一、前言 单例模式属于六大创建型模式,即在软件设计过程中,主要关注创建对象的结果,并不关心创建对象的过程及细节。创建型设计模式将类对象的实例化过程进行抽象化接口设计,从而隐藏了类对象的实例是如何被创建的,封装了软件系统使用的具体对象类型。 六大创建型模式包括…...