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

1.玩家位置同步

1.1后端修改

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

添加一个玩家类

consumer.utils.Game.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对象
第六章---匹配系统(中)_第1张图片
第六章---匹配系统(中)_第2张图片
WebSocketServer.java中,为了方便管理,将与Game相关的信息,封装成一个JSON

第六章---匹配系统(中)_第3张图片
这样后端就可以将两名玩家的信息(包括生成的地图)传送给前端

1.2前端修改

src\store\pk.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函数

<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)的输出内容一致,均为,同步成功

第六章---匹配系统(中)_第4张图片

2.游戏同步:多线程

2.1分析过程

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

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

第六章---匹配系统(中)_第5张图片
再来梳理一下之前的游戏流程

第六章---匹配系统(中)_第6张图片
对于从等待用户orBot输入到判别系统这一过程是独立的,
第六章---匹配系统(中)_第7张图片
但是一般代码的执行是单线程,也就是按照顺序执行,例如如果在当前线程执行操作,当等待用户输入的时候,线程就会卡死,需要我们这样一个线程中有多个游戏在运行,只有Game1结束之后才能跑Game2,这样在第二个对局中,玩家就会漫长的等待。

第六章---匹配系统(中)_第8张图片

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

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

第六章---匹配系统(中)_第9张图片

2.2多线程

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

第六章---匹配系统(中)_第10张图片

在确定两名匹配的玩家之后,更新两名玩家的WebSocketServer连接上的Game实例值。
第六章---匹配系统(中)_第11张图片
然后回到Game.java,将Game变成一个支持多线程的类,只需将Game继承Thread类,就可以支持多线程

public class Game extends Thread

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

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

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

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的值,而在红色的线程里面,会读取这两个线程的值

第六章---匹配系统(中)_第12张图片
这就涉及到两个线程会同时读写一个变量,可能会产生读写冲突,需要枷锁

定义一个锁

private ReentrantLock lock = new ReentrantLock();

之后在setNextStepAsetNextStepB

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

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

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

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

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

@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",但如果下面这段代码执行时,用户给出了输入,结果就会不符合预期。

if(nextStepA == null && nextStepB == null){
       loser = "all";
} else if (nextStepA == null) {
       loser = "A";
} else{
       loser = "B";
}

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

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都知道自己的操作,但是看不到对方的操作,因此需要中心服务器以广播的形式来告知。

第六章---匹配系统(中)_第13张图片

@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的逻辑,其他辅助函数的逻辑如下

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

 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即可。

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

 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,
                }))
            }
        });
    }

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

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的时候,后端就能准确接收到信息。

第六章---匹配系统(中)_第14张图片
同时,前端也要接收后端的广播来的信息,具体有两种event,分别是moveresult

2.4(1) event == move

第六章---匹配系统(中)_第15张图片
对操作进行更新需要用到Snack.js中的set_direction方法

第六章---匹配系统(中)_第16张图片
两个玩家控制的snack对象在保存在GameMap对象中。

第六章---匹配系统(中)_第17张图片
为了取到,需要将GameMap对象,作为游戏对象,保存为全局变量

第六章---匹配系统(中)_第18张图片
先在src\store\pk.js中将gameObject存入全局变量,并写好更新函数

第六章---匹配系统(中)_第19张图片
这样就能获取到游戏对象,并且更新两个玩家控制的snack的方向
第六章---匹配系统(中)_第20张图片
此时,两个玩家都能够控制蛇正常移动


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

第六章---匹配系统(中)_第21张图片
调整为:

第六章---匹配系统(中)_第22张图片

2.5(2) event == result

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

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

第六章---匹配系统(中)_第23张图片
将这段代码去掉。现在要交由后端来播报结果。

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

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

第六章---匹配系统(中)_第24张图片
后端逻辑如下:

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

新建Cell类代表蛇的单元

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Cell {
    private Integer x;
    private Integer y;
}

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

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

第六章---匹配系统(中)_第25张图片
对于四种操作0(w), 1(d), 2(s), 3(a)分别在行和列方向上的偏移量

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

所以Player.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是否符合上面三种原则即可。

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中新增变量,方便用于展示谁赢谁输

第六章---匹配系统(中)_第26张图片新增一个组件ResultBoard.vue用于展示结果

第六章---匹配系统(中)_第27张图片

核心代码如下:

第六章---匹配系统(中)_第28张图片
然后在对战页面PkIndexView.vue导入组件,使其在loser!=none时展示出来
第六章---匹配系统(中)_第29张图片
并且在收到后端播报结果时,更新全局变量中的loser

第六章---匹配系统(中)_第30张图片
最终的结果如下,成功的实现了结果展示和重来一局。

第六章---匹配系统(中)_第31张图片
点击重启


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

第六章---匹配系统(中)_第32张图片
这样,游戏同步功能就全部完成。

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中要使用驼峰命名法

@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

@Mapper
public interface RecordMapper extends BaseMapper<Record> {
}

写入数据库
首先将RecordMapper实例注入到WebSocketServer

第六章---匹配系统(中)_第33张图片

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

第六章---匹配系统(中)_第34张图片
这样在每局游戏结束时,记录就被保存下来

第六章---匹配系统(中)_第35张图片
后续就可以根据记录来复原游戏画面

你可能感兴趣的:(SpringBoot,状态模式)