第六章--- 实现微服务:匹配系统(中)

完善上一part的匹配系统

对于之前的优化


由于要区分玩家,所以要在之前Game.java添加一个Player类存储玩家信息,

包括:
玩家Id
玩家起始位置(sx,sy)
记录每个玩家走过的路径steps,即每个玩家历史上执行过的操作序列,用List

consumer/utils/Player.java

package com.popgame.backend.consumer.utils;import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;import java.util.List;@Data
@AllArgsConstructor
@NoArgsConstructorpublic class Player {private Integer id;private Integer sx;private Integer sy;private List<Integer> steps;
}

consumer/utils/Game.java里添加Player类,playerA表示左下角的玩家,playerB表示右上角的玩家,同时添加获取A,B player的函数,方便外部调用。

  private 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.mark = new boolean[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;}

注意在consumer/WebSocketServer.java里传参的时候也要修改

...
Game game = new Game(13, 14, 36,a.getId(),b.getId());
...

为了方便,我们可以把与游戏相关的信息封装成一个JSONObject
consumer/WebSocketServer.java

		    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.getMark());...//直接传游戏信息给玩家A和玩家BrespA.put("game", respGame);...respB.put("game", respGame);

修改前端

store/pk.js

state: {socket: null, //ws链接opponent_username: "",opponent_photo: "",status: "matching", //matching表示匹配界面,playing表示对战界面game_map: 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.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;},...},

PKindex.vue里面直接把整个数据传进去就好了

store.commit("updateGame",data.game);

这样我们就解决了上一part遗留下来的问题,使其全部实现前后端交互了。

实现游戏同步

实际上我们在游戏对战的时候存在三个棋盘,两个是对战双方客户端里存在的棋盘,一个是云端存在的棋盘,我们要求实现云端与两个客户端之间的同步。

实现方法

玩家每一次操作都会上传至云端服务器,当服务器接收到两个玩家的操作后,就会将两个玩家的蛇的移动信息同步给两个玩家。

游戏总流程示意图:
在这里插入图片描述

引入线程

为了优化游戏体验度,我们的Game不能作为单线程去处理,每一个Game要另起一个新线程来做。
Next Step开始的操作可以当成一个线程,获取用户操作可以当成另一个线程。
这里我们涉及到两个线程之间进行通信的问题,以及线程开锁解锁的问题。

每一局单独的游戏都会new 一个新的Game类,都是一个单独的线程。

将类改成多线程

继承一个 Thread类,并且ALT + INS重写run()方法
我们开始进行线程的执行的时候,线程的入口函数就是这个run()函数

consumer/utils/Game.java

public class Game extends Thread{...@Overridepublic void run() {super.run();}
}

consumer/WebSocketServer.java 里面通过start()开始执行(是 Thread类的一个API)

   			game.createMap();users.get(a.getId()).game = game; //需要在前面新建一个game属性users.get(b.getId()).game = game;game.start();

将用户的操作nextStep存起来,方便外面的线程调用,
Game线程里面会读取两个玩家的操作nextStepA/B的值,
在外面Client线程里面则会修改这两个变量的值,
这里涉及到了线程的读写同步问题!
需要加上进程同步锁
一般来说就是先上锁再读写,后解锁
try{} finally {lock.unlock();}可以保证报异常的情况下也可以解锁而不会产生死锁
简单总结一下就是:先上锁再操作,具体可以参考OS相关的内容o(╯□╰)o
所以以下涉及到nextStepAnextStepB 的,不管是读还是写,只要出现了的话就要考虑到上锁和解锁方面的问题了,Be careful~~
consumer/utils/Game.java

//两名玩家的下一步操作,0123表示上右下左(与前端一致)private Integer nextStepA = null;private Integer nextStepB = null;//进程同步锁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() {//等待玩家的下一步操作}

实现接受客户端玩家输入的操作

后端接受前端两名玩家输入的操作后,才开始进行下一步操作。为了游戏的流畅性,提高玩家的游戏体验感,我们规定,如果超过一定的时间后,另一名玩家仍然未能给予操作,我们就判定这个玩家lose了。

可以用sleep函数来实现等待效果,定最长等待时间为5s。
这里可以按照自己的情况合理地规定等待时间,可以通过增加循环次数,减少sleep时间优化玩家操作手感,以牺牲服务器的计算量换取玩家的操作的流畅性。
**tips:**要在循环里面上锁,在外面上锁会死锁!
还需要注意的是,我们前端设置1s走5步,200ms走一步,所以为了操作顺利,不会因为操作太快而读入多个操作,我们每一次读取前都要先sleep 200ms,规范一下。

因为后面要在外面调用每名玩家操作对应的ws链接,且需要向前端传递信息,需要先将下面两段代码改成
public
consumer/WebSocketServer.java

 final public static ConcurrentHashMap<Integer, WebSocketServer> users = new ConcurrentHashMap<>();public void sendMessage(String message) {//异步通信要加上锁synchronized (this.session) {try {this.session.getBasicRemote().sendText(message);} catch (IOException e) {e.printStackTrace();}}}

编写后端逻辑
consumer/utils/Game.java

 private boolean nextStep() {//等待玩家的下一步操作try {Thread.sleep(200); //前端1s走5步,200ms走一步,因此为了操作顺利,每一次读取都要先sleep 200ms} catch (InterruptedException e) {throw new RuntimeException(e);}for (int i = 0; i < 5; i++) {try {Thread.sleep(1000);lock.lock();try {if (nextStepA != null && nextStepB != null) {playerA.getSteps().add(nextStepA);playerB.getSteps().add(nextStepB);return true;}} finally {lock.unlock();}} catch (InterruptedException e) {e.printStackTrace();}}return false;}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() {//向两个Client传递移动信息lock.lock();try {JSONObject resp = new JSONObject();resp.put("event", "move");resp.put("a_direction", nextStepA);resp.put("b_direction", nextStepB);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()); //将JSON转化为字符串}@Overridepublic void run() {for (int i = 0; i < 1000; i++) {// 最多循环1000步if (nextStep()) {  //是否都获取到了两条蛇的操作judge();if ("playing".equals(status)) {//将对手玩家的输入广播给ClientsendMove();} else {sendResult();break;}} else {status = "over";lock.lock();try {if (nextStepA == null && nextStepB == null) {loser = "all";} else if (nextStepA == null) {loser = "A";} else {//nextStep() = false会有卡超时边界依然输入的情况,但是为了规则合理性,在这里全部判输了loser = "B";}} finally {lock.unlock();}sendResult();break;}}}

修改前端

scripts/GameMap.js

add_events() {this.ctx.canvas.focus();const [snake0, snake1] = this.snakes;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 >= 0) { //一个合法的操作//前端向后端发消息: 前端 -> 后端this.store.state.pk.socket.send(JSON.stringify({event: "move",direction: d,}));}});}

同时在后端接受消息,并编写移动函数move()
consumer/WebSocketServer.java

public void move(int direction) {if (game.getPlayerA().getId().equals(user.getId())) {//蛇Agame.setNextStepA(direction);} else if (game.getPlayerB().getId().equals(user.getId())) { //蛇Bgame.setNextStepB(direction);}}@OnMessagepublic void onMessage(String message, Session session) {// 从Client接收消息System.out.println("receive message!");JSONObject data = JSONObject.parseObject(message);String event = data.getString("event");if ("start matching".equals(event)) {startMatching();} else if ("stop matching".equals(event)) {stopMatching();} else if ("move".equals(event)) {int d = data.getInteger("direction");move(d);}}

在前端编写moveresult的逻辑函数,让小蛇动起来(✺ω✺)
同时,为了分别取出两条蛇可以将GameObjectstore/pk.js里先存下来,记得写对应的update函数哦!

然后我们再在components/GameMap.vue里修改
components/GameMap.vue

 onMounted(() => {store.commit("updateGameObject",new GameMap(canvas.value.getContext('2d'),parent.value,store));});

蛇的去世判断要从前端搬到后端判断

先在前端写好情况分支选择
views/pk/PKindex.vue

 onMounted(() => { //当当前页面打开时调用...socket.onmessage = msg => { //前端接收到信息时调用的函数...} 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 = "dead";}if (data.loser === "all" || data.loser === "B") {snake1.status = "dead";}}}...});

在后端写judge逻辑
注意:要先添加一个Cell类存储蛇的全部身体部分,在Player类里面把蛇的身体都存储下来,
然后在Game类里判断的时候再循环一遍两个Player,各自取出自己的每一节cell逐个判断。
判断逻辑包括:撞墙、撞到自己、撞到他人,这些都会导致自己lose掉比赛

consumer/utils/Player.java

...private boolean check_tail_increasing(int step) { //检测当前回合蛇的长度是否增加if (step <= 10) return true;else {return step % 3 == 1;}}public List<Cell> getCells() {List<Cell> res = new ArrayList<>(); //存放蛇的身体int[][] fx = {{-1, 0}, {0, 1}, {1, 0}, {0, -1}};int x = sx, y = sy;res.add(new Cell(x, y));int step = 0; //回合数for (int d : steps) {x += fx[d][0];y += fx[d][1];res.add(new Cell(x, y));if (!check_tail_increasing(++step)) {res.remove(0);}}return res;}...

consumer/views/Game.java

...private boolean check_valid(List<Cell> cellsA, List<Cell> cellsB) {int n = cellsA.size();Cell cell = cellsA.get(n - 1); //取出最后一位(蛇头)if (mark[cell.x][cell.y]) { //如果最后一位是墙的话return false;}for (int i = 0; i < n - 1; i++) {if (cellsA.get(i).x == cell.x && cellsA.get(i).y == cell.y) { //如果自己碰到自己就算输return false;}}for (int i = 0; i < n - 1; i++) {if (cellsB.get(i).x == cell.x && cellsB.get(i).y == cell.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 = "over";if (!validA && !validB) {loser = "all";} else if (!validA) {loser = "A";} else {loser = "B";}}}
...

至此游戏的大部分逻辑已经写完了

写个游戏结果画面

首先在views/pk/PKindex.vue里面添加游戏胜负显示逻辑

...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 = "dead";}if (data.loser === "all" || data.loser === "B") {snake1.status = "dead";}store.commit("updateLoser",data.loser);}
...

在前端写一个组件components/ResultBoard.vue 这就是游戏结束后显示的结果版面,把谁是loser存在store里面就可以全局调用来判断了

components/ResultBoard.vue

<template><div class="result-board"><div class="result-board-text draw" v-if="$store.state.pk.loser == 'all'" >Draw</div><div class="result-board-text lose" v-else-if="$store.state.pk.loser =='A' && $store.state.pk.a_id == $store.state.user.id" >Lose</div><div class="result-board-text lose" v-else-if="$store.state.pk.loser =='B' && $store.state.pk.b_id == $store.state.user.id" >Lose</div><div class="result-board-text win" v-else >WIN</div><div class="result-board-btn"><button type="button" class="btn">Try again</button></div></div>
</template>

CSS 样式自己设计一下就好了

实现Try again逻辑

接下来我们把Try again按钮实现一下,玩家可以在游戏结束后点击这个按钮再来一局游戏。
实现逻辑也比较简单,每次点击按钮,把游戏页面展示状态statusplaying 改成 matching即可,这样整个游戏页面就返回到匹配页面了。
不要忘记了要updateLoser改成none,即重新开始游戏前还没有loser
还有把对手头像upd ateOpponent成默认的灰头像。

设计录像数据库

为了后期存储对战录像,我们需要先设计一个存储对象的数据库。
数据库内容包括

在这里插入图片描述
然后像前面一样,建立相应的pojo,mapper

准备工作完成后,我们就可以开始写将数据写入数据库的逻辑了
consumer/utils/Game.java

private String getMapString() {StringBuilder res = new StringBuilder();for (int i = 0; i < rows; i++) {for (int j = 0; j < cols; j++) {if (mark[i][j]) res.append(1);else res.append(0);}}return res.toString();}private void saveRecord() {Record record = new Record(null, //因为之前创建数据库时是把id定义为自动递增,所以这里不用手动传idplayerA.getId(),playerA.getSx(),playerA.getSy(),playerB.getId(),playerB.getSx(),playerB.getSy(),playerA.getStepsString(),playerB.getStepsString(),getMapString(),loser,new Date());WebSocketServer.recordMapper.insert(record); //ws里数据库的注入}

END
至此,我们就完成了联机匹配和存储游戏对局数据的大部分内容了,辛苦大家了QAQ