笔记内容转载自 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());}...
}