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

2023-12-13 23:57:11

完善上一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
@NoArgsConstructor

public 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和玩家B
            respA.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{
    ...
    @Override
    public 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转化为字符串
    }

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            // 最多循环1000步
            if (nextStep()) {  //是否都获取到了两条蛇的操作
                judge();

                if ("playing".equals(status)) {
                    //将对手玩家的输入广播给Client
                    sendMove();
                } 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())) {
            //蛇A
            game.setNextStepA(direction);
        } else if (game.getPlayerB().getId().equals(user.getId())) { //蛇B
            game.setNextStepB(direction);
        }

    }

    @OnMessage
    public 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定义为自动递增,所以这里不用手动传id
                playerA.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

文章来源:https://blog.csdn.net/m0_51366201/article/details/134983055
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。