• Spring Boot 6.2 实现后端与两个客户端之间的同步(逻辑)&&多线程&&读写锁


    第一步:传递Player的位置

    Game.java前创建Player
    在这里插入图片描述

    consumer/utils/Player.java

    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;//存方向0123
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

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

    private final 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;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

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

    ...
    Game game = new Game(13, 14, 36,a.getId(),b.getId());
    ...
    
    • 1
    • 2
    • 3

    为了方便,我们可以把与游戏相关的信息封装成一个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);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    修改前端:store/pk.js

    export default {
      state: {
        status:"matching",//matching匹配页面 playing对战页面
        socket:null,
        opponent_username:"",
        opponent_photo:"",
        gamemap:null,
        //add
        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;
        },
        updateGamemap(state,game){
       		 //add
            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.gamemap ;
        }
      },
      actions: {
      },
      modules: {
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

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

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

    第二步:实现游戏同步(实现云端与两个客户端之间的同步)

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

    实现方法

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

    流程
    在这里插入图片描述

    引入线程

    为了优化游戏体验度,我们的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();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

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

    game.createMap();
    //a,b共同的地图==>将地图赋给a,b对应的连接
    users.get(a.getId()).game = game; 
    users.get(b.getId()).game = game;
    game.start();
    
    • 1
    • 2
    • 3
    • 4
    • 5

    读写锁的问题

    在这里插入图片描述
    consumer/utils/Game.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();
           }
       }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    实现接受Client端输入的操作

    在这里插入图片描述
    consumer/WebSocketServer.java

    public final static ConcurrentHashMap<Integer,WebSocketServer> users = new ConcurrentHashMap<>();
    
    • 1

    consumer/utils/Game.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();
        }
    }
    
    private boolean nextStep(){//两名玩家的下一步
       try {
           Thread.sleep(200);//因为前端走一格200ms
       } catch (InterruptedException e) {
           e.printStackTrace();
       }
    
       //超时5s判断
       for(int i = 1; i <= 5;i ++)
       {
           try {
               Thread.sleep(1000);
               lock.lock();
               try{
                   if(this.nextStepA != null && this.nextStepB != null) {
                       //记录方向
                       playerA.getSteps().add(nextStepA);
                       playerB.getSteps().add(nextStepB);
                       return true;
                   }
               } finally {
                   lock.unlock();
               }
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
    
       }
       return false ;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    编写后端逻辑

    consumer/utils/Game.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();
        }
    }
    
    
    
    private boolean nextStep(){//两名玩家的下一步
        try {
            Thread.sleep(200);//因为前端走一格200ms
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    
        //超时5s判断
        for(int i = 1; i <= 5;i ++)
        {
            try {
                Thread.sleep(1000);
                lock.lock();
                try{
                    if(this.nextStepA != null && this.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 sendResult(){//向两个client端公布结果
        JSONObject resp = new JSONObject();
        resp.put("event","result");
        resp.put("loser",loser);
        sendAllMessage(resp.toJSONString());
    }
    
    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());
            this.nextStepA = this.nextStepB = null;//清空下一步
        } finally {
            lock.unlock();
        }
    }
    
    public void sendAllMessage(String message){
        WebSocketServer.users.get(playerA.getId()).sendMessage(message);
        WebSocketServer.users.get(playerB.getId()).sendMessage(message);
    }
    
    //线程的入口
    @Override
    public void run() {
       for(int i = 0;i <1000;i++){
    
           if(nextStep()){//获取了两条蛇的下一步
               judge();//是否合法
               if(this.status.equals("playing")){
                   sendMove();
               } else if(this.status.equals("finished")){
                   sendResult();
                   break;//结束
               }
           } else {
               this.status = "finished" ;//完成:结束
               lock.lock();
               try {
                   if (nextStepA == null && nextStepB == null) {//平局
                       this.loser = "all";
                   } else if (nextStepA == null) {
                       this.loser = "A";
                   } else {
                       this.loser = "B";
                   }
               } finally {
                   lock.unlock();
               }
               sendResult();//向前端发送对战结果
               break;
           }
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113

    consumer/WebSocketServer.java:接受前端信息:方向键等

        @OnMessage
        public void onMessage(String message, Session session) {
            // 接收前端信息
            System.out.println("receive message!");
            JSONObject data = JSONObject.parseObject(message);//解析message
            String event = data.getString("event");//类似map
            if("start-matching".equals(event)){
                startMatching();
            } else if("stop-matching".equals(event)){
                stopMatching();
            } else if("move".equals(event)){
                move(data.getInteger("direction"));
            }
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    修改前端

    scripts/GameMap.js

    //获取键盘输入
    add_listening_events(){
        this.ctx.canvas.focus();//cts[DOM] canvas[画板]
        
        //为画板绑定keydown事件
        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,
                }))
            }       
        })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

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

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

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

    蛇的去世判断要从前端搬到后端判断
    先在前端写好情况分支选择
    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 = "die";
                    }
                    if (data.loser === "all" || data.loser === "B") {
                        snake1.status = "die"; 
                    }
                }
            }
    
            ...
    
        });
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    在后端写judge逻辑

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

    consumer/utils/Player.java

    //检查这一步是否合法
    //判断cellsA是否合法的,判断cellsB的合法直接调用处反着写即可
    private boolean check_valid(List<Cell> cellsA,List<Cell> cellsB)
    {
        int n = cellsA.size();
        Cell cell = cellsA.get(n-1);//取出最后一步
    
        if(g[cell.x][cell.y] == 1 ) return false ;
    
    
        for(int i = 0; i < n-1 ;i++ ){
            if(cell.x == cellsA.get(i).x && cell.y == cellsA.get(i).y){//撞自己
                return false ;
            }
        }
    
        for(int i = 0; i < n-1; i++) {
            if (cell.x == cellsB.get(i).x && cell.y == cellsB.get(i).y) {//撞另一条蛇
                return false;
            }
        }
    
        return true;
    }
    
    //判断loser
    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)//结束游戏
        {
            this.status = "finished" ;
            if(!validA&&!validB){
                this.loser = "all" ;
            } else if(!validA){
                this.loser = "A" ;
            } else if(!validB){
                this.loser = "B" ;
            }
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

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

    写个游戏结果画面

    首先在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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

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

    实现再来逻辑

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

    <template>
      <div class="result-board">
    
        <!-- 为什么是==,而不是=== -->
        <!-- $store.state.pk.a_id(数字1)  $store.state.user.id(字符串1) -->
        <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 === parseInt($store.state.user.id)">
            Lose
        </div>
        <div class="result-board-text" v-else-if="$store.state.pk.loser === 'B' && $store.state.pk.b_id === parseInt($store.state.user.id)">
            Lose
        </div>
        <div class="result-board-text" v-else>
            Win
        </div>
        <div class="result-board-btn">
            <button @click="restart" type="button" class="btn btn-warning btn-lg">
                再来 !
            </button>
        </div>
      </div>
    </template>
    
    <script>
    import { useStore } from "vuex" 
    
    export default {
        setup(){
            const store = useStore();
    
            const restart = () => {
                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 {
                restart,
            }
        },
    }
    </script>
    
    <style scoped>
    div.result-board {
        height: 30vh;
        width: 30vw;
        background-color: rgba(50, 50, 100, 0.5);
        position:absolute;
        top:30vh;
        left:35vw;
    }
    div.result-board-text {
        text-align: center;
        color: white;
        font-size: 50px;
        font-weight: 600;
        font-style: italic;
        padding-top: 5vh;
    }
    div.result-board-btn {
        text-align: center;
        padding-top: 7vh;
    }
    
    </style>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71

    第三步:设计录像数据库(后期存储对战录像)

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

    id 自动递增、主键、唯一
    a_id
    a_sx
    a_sy
    b_id
    b_sx
    b_sy
    a_steps
    b_steps
    map
    loser
    create_time
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    consumer/utils/Player.java

        public String getStepsString() {
            StringBuilder res = new StringBuilder();
            for(int x:steps){
                res.append(x);
            }
            return res.toString();
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    consumer/utils/Game.java

    private void sendResult(){//向两个client端公布结果
        JSONObject resp = new JSONObject();
        resp.put("event","result");
        resp.put("loser",loser);
        saveToDatabase();//调用处
        sendAllMessage(resp.toJSONString());
    }
    
    private String getMapString() {
        StringBuilder res = new StringBuilder();
          for (int i = 0; i < rows; i++) {
              for (int j = 0; j < cols; j++) {
                  res.append(g[i][j]);
              }
          }
          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里数据库的注入
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
  • 相关阅读:
    【高等数学】常用函数的n阶导数
    CmakeLists.txt配置Eigen
    java-net-php-python-90儿童失踪登记网站计算机毕业设计程序
    detect.py和train.py的参数解释
    飞腾D2000+FPGA云终端,实现从硬件、操作系统到应用的完全国产、自主、可控
    spark踩坑记
    vue3使用vuex
    echarts图表 柱状图柱体颜色渐变
    小型时间继电器ST3PA-C DC24V 带插座PF085A 导轨安装 JOSEF约瑟
    1075 PAT Judge
  • 原文地址:https://blog.csdn.net/qq_52384627/article/details/126475923