第六章---匹配系统(中)

1.玩家位置同步

1.1后端修改

玩家的位置也要在服务端确定,确定完之后将每个玩家的位置传到前端。

添加一个玩家类

consumer.utils.Game.java

java 复制代码
import java.util.List;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Player {
    private Integer id;
    private Integer sx;//起始x坐标
    private Integer sy;//起始y坐标
    private List<Integer> steps;//保存每一步操作---决定了蛇当前的形状
}

在初始化Game的时候,实例化两个Player对象

WebSocketServer.java中,为了方便管理,将与Game相关的信息,封装成一个JSON

这样后端就可以将两名玩家的信息(包括生成的地图)传送给前端

1.2前端修改

src\store\pk.js中添加玩家信息的变量和更新函数

js 复制代码
export default ({
    state: {
       status:"matching",//matching表示匹配界面 playing表示对战界面
       socket:null,//存储前后端建立的connection
       opponent_username:"",//对手名
       opponent_photo:"",//对手头像
       gamemap:null,
       a_id:0,
       a_sx:0,
       a_sy:0,
       b_id:0,
       b_sx:0,
       b_sy:0,
    },
    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.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;
        state.gamemap = game.map;
      },
    },
    actions: {
       
    },
    modules: {
    }
  })
  

src\views\pk\PkIndexView.vue中,在onmessage中,调用updateGame函数

js 复制代码
<script>
import PlayGround from '../../components/PlayGround.vue'
import MatchGround from '../../components/MatchGround.vue'
import { onMounted } from 'vue'
import { onUnmounted } from 'vue'
import { useStore } from 'vuex'
export default {
    components: {
        PlayGround,
        MatchGround
    },
    setup() {
        const store = useStore();
        const socketUrl = `ws://127.0.0.1:3000/websocket/${store.state.user.token}`;
        let socket = null;
        onMounted(() => {
            ....//省略
            socket.onmessage = msg => {
                const data = JSON.parse(msg.data);
                console.log(data);
                if (data.event === "start-matching") {
                    store.commit("updateOpponent", {
                        username: data.opponent_username,
                        photo: data.opponent_photo
                    });
                    //匹配成功后,延时2秒,进入对战页面
                    setTimeout(() => {
                        store.commit("updateStatus", "playing")
                    }, 2000);
                    store.commit("updateGame",data.game)//更新Game:包括玩家信息和地图
                }
            }
            socket.onclose = () => {
                console.log("disconnected!");
            }
        });

        onUnmounted(() => {
            socket.close();
            store.commit("updateStatus", "matching");
        })
    }
}
</script>

运行项目,使用用户名sun和用户名hong的登录,两个浏览器控制台console.log(data.game)的输出内容一致,均为,同步成功

2.游戏同步:多线程

2.1分析过程

之前只是两个棋盘,在浏览器本地通过wsad和上下左右来控制移动。

现在三个棋盘,两个client和一个server,需要实现三个棋盘的同步

再来梳理一下之前的游戏流程

对于从等待用户orBot输入到判别系统这一过程是独立的,

但是一般代码的执行是单线程,也就是按照顺序执行,例如如果在当前线程执行操作,当等待用户输入的时候,线程就会卡死,需要我们这样一个线程中有多个游戏在运行,只有Game1结束之后才能跑Game2,这样在第二个对局中,玩家就会漫长的等待。

因此,Game不能作为一个单线程来处理,因此,需要另起一个新的线程来做。

也就是将Game变成一个支持多线程的类

2.2多线程

首先为WebSocketServer增加一个成员变量,用于记录链接中的Game实例

在确定两名匹配的玩家之后,更新两名玩家的WebSocketServer连接上的Game实例值。

然后回到Game.java,将Game变成一个支持多线程的类,只需将Game继承Thread类,就可以支持多线程

java 复制代码
public class Game extends Thread

然后重写多线程的入口函数run()

在开启一个新线程执行game.start()的时候,新线程中的入口函数,就是run()

初始化两个成员变量,用于表示两名玩家的下一步操作

java 复制代码
private Integer nextStepA;
private Integer nextStepB;
public void setNextStepA(Integer nextStepA) {
    this.nextStepA = nextStepA;
}
public void setNextStepB(Integer nextStepB) {
    this.nextStepB = nextStepB;
}

未来会在WebSocketServer.java中,接收到输入的时候,调用这两个函数

也就是在蓝色的线程里面修改nextStepAnextStepB的值,而在红色的线程里面,会读取这两个线程的值

这就涉及到两个线程会同时读写一个变量,可能会产生读写冲突,需要枷锁

定义一个锁

java 复制代码
private ReentrantLock lock = new ReentrantLock();

之后在setNextStepAsetNextStepB

对两个变量进行更新之前,先锁上,操作完之后,解锁(不管有没有报异常)

java 复制代码
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();
    }
}

nextStep()函数中,负责等待两名玩家的输入,如果都在指定时间内输入了,就返回true

java 复制代码
private boolean nextStep(){//等待两名玩家的下一步操作
    //由于前端动画200ms才能画一个格子
    //如果在此期间接收到的输入多于一步 只会留最后一步 多余的会被覆盖
    //因此在每一个下一步都要先休息200ms
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }

    //如果5秒内有玩家没有输入 就返回false
    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;
}

如果其中一个超时没有输入,游戏就终止,并且分出胜负。

因此还需要定义一个游戏状态status和谁输了loser

java 复制代码
private String status = "playing";//游戏状态 playing-->finished
private String loser = "";//all:平; A:A输; B:B输了

最后,在线程的入口run()中初始逻辑如下

java 复制代码
@Override
public void run() {
    for (int i = 0; i < 1000; i++) {//1000步之内游戏肯定结束
        if(nextStep()){
            //如果获取两个玩家的下一步操作
        }else {
            status = "finished";
            if(nextStepA == null && nextStepB == null){
                loser = "all";
            } else if (nextStepA == null) {
                loser = "A";
            } else{
                loser = "B";
            }
        }
    }
}

但是上面这段逻辑有个问题,如果两名玩家在五秒内没有给出操作,就会进入else判断,此时本应该是平均,也就是loser = "all",但如果下面这段代码执行时,用户给出了输入,结果就会不符合预期。

java 复制代码
if(nextStepA == null && nextStepB == null){
       loser = "all";
} else if (nextStepA == null) {
       loser = "A";
} else{
       loser = "B";
}

所以,由于这里涉及到变量的读操作,为了在读的过程中被修改,因此也需要加锁。读完之后再解锁。

java 复制代码
if(nextStep()){
    //如果获取两个玩家的下一步操作
    System.out.println();
}else {
    status = "finished";
    lock.lock();
    try {
        if(nextStepA == null && nextStepB == null){
            loser = "all";
        } else if (nextStepA == null) {
            loser = "A";
        } else{
            loser = "B";
        }
    }finally {
        lock.lock();
    }
}

然后来看if (nextStep())判断,如果获取两个玩家的下一步操作

需要先进行judge(),来判断输入是否合法

并且,虽然A和B都知道自己的操作,但是看不到对方的操作,因此需要中心服务器以广播的形式来告知。

java 复制代码
@Override
public void run() {
    for (int i = 0; i < 1000; i++) {//1000步之内游戏肯定结束
        if (nextStep()) {
            //如果获取两个玩家的下一步操作
            judge();
            if(status.equals("playing")){
                sentMove();
            }else {
                sentResult();
                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.lock();
            }
            sentResult();
            break;
        }
    }
}

而其中暂时不实现judge的逻辑,其他辅助函数的逻辑如下

java 复制代码
private void sentAllmessage(String message){//工具函数:向两名玩家广播信息
    WebSocketServer.userConnectionInfo.get(playerA.getId()).sendMessage(message);
    WebSocketServer.userConnectionInfo.get(playerB.getId()).sendMessage(message);
}
private void sentMove() {//向两个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;//清空操作
        sentAllmessage(resp.toJSONString());
    }finally {
        lock.unlock();
    }
}

private void sentResult() {//向两个client公布结果信息
    JSONObject resp = new JSONObject();
    resp.put("event","result");//定义事件
    resp.put("loser",loser);
    sentAllmessage(resp.toJSONString());
}

这样后端基本逻辑完成,接下来是前端与后端的通信,前端要将用户的操作发送过来,以及接收并处理中心服务器的广播

2.3前后端通信

此前判断蛇的移动,在scripts\GameMap.js

js 复制代码
 add_listening_events(){
        this.ctx.canvas.focus();//聚焦
        const [snake0, snake1] = this.snakes;
        this.ctx.canvas.addEventListener("keydown",e=>{
            console.log(e.key);
            //wasd控制左下角球 上下左右控制右上角球
            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);
        });
    }

这里,由于一个client负责一个玩家,只处理wsad即可。

修改如下,将玩家的操作操作传送到后端

js 复制代码
 add_listening_events(){
        this.ctx.canvas.focus();//聚焦
        const [snake0, snake1] = this.snakes;
        this.ctx.canvas.addEventListener("keydown",e=>{
            console.log(e.key);
            //wasd控制移动
            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.sent(JSON.stringify({//将JSON转换为字符串
                    event:"move",
                    direction:d,
                }))
            }
        });
    }

后端接收并分配给专门的路由来进行处理

java 复制代码
private void move(Integer direction) {
    //判断是A玩家还是B玩家在操作
    if(game.getPlayerA().getId().equals(user.getId())){
        game.setNextStepA(direction);
    }else if (game.getPlayerB().getId().equals(user.getId())) {
        game.setNextStepB(direction);
    } else {
        Exception e = new Exception("Error");
        e.printStackTrace();
    }
}
@OnMessage
public void onMessage(String message, Session session) {//当做路由 分配任务
    // Server从Client接收消息时触发
    System.out.println("Receive message!");
    JSONObject data = JSONObject.parseObject(message);//将字符串解析成JSON
    String event = data.getString("event");
    if("start-matching".equals(event)){//防止event为空的异常
        startMatching();
    } else if ("stop-matching".equals(event)) {
        stopMatching();
    } else if ("move".equals(event)) {
       	Integer direction = data.getInteger("direction");
        System.out.println(direction);
        move(direction);
    }
}

此时,client端用户输入WSAD的时候,后端就能准确接收到信息。

同时,前端也要接收后端的广播来的信息,具体有两种event,分别是moveresult

2.4(1) event == move

对操作进行更新需要用到Snack.js中的set_direction方法

两个玩家控制的snack对象在保存在GameMap对象中。

为了取到,需要将GameMap对象,作为游戏对象,保存为全局变量

先在src\store\pk.js中将gameObject存入全局变量,并写好更新函数

这样就能获取到游戏对象,并且更新两个玩家控制的snack的方向

此时,两个玩家都能够控制蛇正常移动

但是每次输入之后都会感觉到一些延迟,是因为输入之后可能线程还处于睡眠状态

调整为:

2.5(2) event == result

之前判断玩家输赢(蛇的状态)的逻辑在前端

js 复制代码
//如果下一步操作撞了 蛇瞬间去世
if(!this.gamemap.check_valid(this.next_cell)){
this.status = "die";
}

将这段代码去掉。现在要交由后端来播报结果。

判断输赢有两部分逻辑:撞墙和超时,超时的逻辑已经写好,现在写判断撞墙的逻辑

参考前端GameMap.js中的check_valid(cell)函数

后端逻辑如下:

1)首先需要将两名玩家所控制的蛇取到:

新建Cell类代表蛇的单元

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Cell {
    private Integer x;
    private Integer y;
}

Player.java中,将蛇的身体返回

0、1、2、3位置表示表示上右下左

对于四种操作0(w), 1(d), 2(s), 3(a)分别在行和列方向上的偏移量

java 复制代码
int[] dx = {-1, 0, 1, 0};//行方向的偏移量
int[] dy = {0, 1, 0, -1}; //列方向的偏移量

所以Player.java的逻辑更新为

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Player {
    private Integer id;
    private Integer sx;//起始x坐标
    private Integer sy;//起始y坐标
    private List<Integer> steps;//保存每一步操作---决定了蛇当前的形状
    //检验当前回合 蛇的长度是否增加
    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<>();
        //对于四种操作0(w), 1(d), 2(s), 3(a)
        // 在行和列方向上的计算偏移量
        int[] dx = {-1, 0, 1, 0};
        int[] dy = {0, 1, 0, -1};
        int x = sx;
        int y = sy;
        int step = 0;//回合数
        res.add(new Cell(x,y));//添加起点
        //不断根据steps计算出整个蛇身体
        for (Integer d : steps) {
            x += dx[d];
            y += dy[d];
            res.add(new Cell(x,y));
            if(!check_tail_increasing(++step)){
                //如果蛇尾不增加 就删掉蛇尾
                res.remove(0);//O(N)
            }
        }
        return res;
    }
}

2)判断两名玩家最后一步操作是否合法

  • 没有撞到障碍物
  • 没有撞到两条蛇的身体
    • 没有撞到自己:最后一步与之前n-1个Cell是否重合
    • 没有撞到别人:最后一步与之前n-1个Cell是否重合
      • 由于A和B不可能走到同一个格子 因此不用判断最后一个格子是否重合

只需要判断最后一步,也就是蛇的最后一个Cell是否符合上面三种原则即可。

java 复制代码
private boolean check_valid(List<Cell> cellsA, List<Cell> cellsB) {
    int n = cellsA.size();
    Cell cell = cellsA.get(n - 1);//取到A的最后一步
    //三种不合法操作: A撞墙、A撞A、A撞B
    //A撞墙
    if(g[cell.getX()][cell.getY()] == 1)
        return false;
    //A撞A
    for (int i = 0; i < n - 1; i++) {
        if(cellsA.get(i).getX().equals(cell.getX())
                && cellsA.get(i).getY().equals(cell.getY())){
            return false;
        }
    }
    //A撞B
    for (int i = 0; i < n - 1; i++) { 
        if(cellsB.get(i).getX().equals(cell.getX())
                && cellsB.get(i).getY().equals((cell.getY()))){
            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){
            loser = "B";
        } else if (validB) {
            loser = "A";
        } else {
            loser = "all";
        }
    }
}

此时就能正常的进行合法性判断。

2.6游戏结果展示

最后,还需要将游戏的结果在前端展示,并且,设置一个重启按钮,点击重启之后,重新开始一局。

pk.js中新增变量,方便用于展示谁赢谁输

新增一个组件ResultBoard.vue用于展示结果

核心代码如下:

然后在对战页面PkIndexView.vue导入组件,使其在loser!=none时展示出来

并且在收到后端播报结果时,更新全局变量中的loser

最终的结果如下,成功的实现了结果展示和重来一局。

点击重启

此时,再匹配的用户,又可以开始新的一轮对战。

这样,游戏同步功能就全部完成。

3.对局记录

接下来来实现另外一功能,就是将对局记录保存在数据库中。

3.1创建record

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

2)创建Pojo

注意,数据库中如果用下划线,则在pojo中要使用驼峰命名法

java 复制代码
@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;
}

3)创建Mapper

java 复制代码
@Mapper
public interface RecordMapper extends BaseMapper<Record> {
}

写入数据库

首先将RecordMapper实例注入到WebSocketServer

Game.java中,在每次向client播报结果之前,将记录保存到数据库

这样在每局游戏结束时,记录就被保存下来

后续就可以根据记录来复原游戏画面

相关推荐
e***98571 天前
SpringMVC的工作流程
状态模式
q***08742 天前
SpringMVC的工作流程
状态模式
g***78912 天前
SpringBoot中使用TraceId进行日志追踪
spring boot·后端·状态模式
shuxiaohua4 天前
使用HttpURLConnection调用SSE采坑记录
状态模式
崎岖Qiu4 天前
状态模式与策略模式的快速区分与应用
笔记·设计模式·状态模式·策略模式·开闭原则
Jonathan Star5 天前
前端需要做单元测试吗?哪些适合做?
前端·单元测试·状态模式
一水鉴天6 天前
整体设计 全面梳理复盘 之40 M3 统摄三层 AI 的动态运营社区(Homepage)设计
架构·transformer·状态模式·公共逻辑
前端玖耀里9 天前
Vue + Axios + Node.js(Express)如何实现无感刷新Token?
状态模式
将编程培养成爱好9 天前
C++ 设计模式《外卖骑手状态系统》
c++·ui·设计模式·状态模式
向葭奔赴♡10 天前
Spring Boot参数校验全流程解析
状态模式