iT邦幫忙

2022 iThome 鐵人賽

DAY 29
0

今日目標,輪流出牌。

選擇

打出去之前要先選擇~~

  1. 修改 game.js,我們要先取得使用者點擊的牌,並且在第二次點擊時取消選擇,這邊用 Set 來決定目前選擇的牌是哪些,加入這行程式碼:
    var selectedCards = new Set();
    
  2. 再來定義選擇和取消選擇的函數(selectCard()unselectCard()),加入片段程式碼:
    function selectCard(id) {
        // 加入剛才建立的 set
        selectedCards.add(id);
    
        // 新增被選擇時的特效
        $(`#${id}`).addClass("card-selected rounded");
    }
    function unselectCard(id) {
        // 從 set 移除
        selectedCards.delete(id);
    
        // 移除特效
        $(`#${id}`).removeClass("card-selected rounded");
    }
    
  3. game.css 要新增這段:
    .card-selected {
        box-shadow:  0 0 .3rem rgb(0, 204, 255)!important;
    }
    .m-card:focus {
        outline: none;
    }
    
  4. 再來,我們要在手牌產生時,對手牌綁定 click 的觸發行為,修改 generateMyHands()函數,該函數完整內容為:
    function generateMyHands(myHands) {
        $(".my-hands").empty();
        let index = 0;
        for (let cards of myHands) {
            let tmp = cards.split("-");
            let color = (tmp[0] == SUITS.SPADE || tmp[0] == SUITS.CLUB) ? "#000000" : "#FF0000";
            $(".my-hands").append(`
                <div class="m-card text-center" style="color: ${color}" id="card-${tmp[0]}-${tmp[1]}" tabindex="${index++}">
                    <div>
                    ${tmp[0]}
                    <br>
                    ${tmp[1]}
                    </div>
                </div>
            `);
        }
        relocateMyHands();
    
        // 綁定 click 事件
        $(".my-hands .m-card").click((element) => {
            let id = element.currentTarget.id;
    
            // 在 set 裡面,表示原本就被選中了,所以要取消選擇
            // 否則就選中那張牌
            if (selectedCards.has(id)) {
                unselectCard(id);
            }
            else {
                selectCard(id);
            }
        })
    }
    
  5. 我們處理好「選擇」,再來就是用 WebSocket 將其送給 Server。這邊要特別注意,我們的牌現在是符號和數字組成,但原先 Server 我們是用 Enum 定義,所以要再轉回原先的全大寫格式,加入以下程式碼:
    // 反著查回去
    const SYMBOLS = {
        "♠️" : "SPADE",
        "♥" : "HEART",
        "♦" : "DIAMOND",
        "♣" : "CLUB",
        "1" : "ACE",
        "2" : "TWO",
        "3" : "THREE",
        "4" : "FOUR",
        "5" : "FIVE",
        "6" : "SIX",
        "7" : "SEVEN",
        "8" : "EIGHT",
        "9" : "NINE",
        "10" : "TEN",
        "J" : "JACK",
        "Q" : "QUEEN",
        "K" : "KING",
    };
    
    function play() {
        let cards = Array.from(selectedCards);
        cards = cards.map((val) => {
            let tmp = val.split("-");
            let card = {
                suit : SYMBOLS[tmp[1]],
                number : SYMBOLS[tmp[2]],
            };
            return card;
        })
    
        websocket.send("/play", {
            action : "play",
            playedCards : cards,
        });
    }
    function pass() {
        websocket.send("/play", {
            action : "pass",
        });
    }
    
  6. 做到這邊,還不能玩喔~~ 別忘記去訂閱 play,加入 subscribePlay()
    function subscribePlay() {
        websocket.subscribe("/user/queue/play", (response) => {
            response = JSON.parse(response.body);
            if (response.message === "success") {
                // 成功的話,更新手牌,並且更新其他人的牌數資訊
                websocket.send("/my-hands", {});
                websocket.send("/hands-info", {});
            }
            else {
                // 如果失敗要顯示錯誤訊息,這邊留給讀者發揮~~
            }
        });
    }
    
  7. 不要忘記修改 $(document).ready(),修改後為:
    $(document).ready(() => {
        websocket.connect('/connect', () => {
            subscribeMyHands();
            subscribeHandsInfo();
            subscribePlay();
            websocket.send("/my-hands", {});
            websocket.send("/hands-info", {});
        });
    })
    
  8. 最後,要修改 game.html,我們還需要綁定按鈕,修改後的完整內容為:
    <!DOCTYPE html>
    <html lang="en" xmlns="http://www.w3.org/1999/xhtml"
        xmlns:th="http://www.thymeleaf.org"
        xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
        layout:decorate="~{layout.html}"
    >
    <head>
        <meta charset="UTF-8">
        <title>Title</title>
    </head>
    <body>
    <div layout:fragment="content" class="card game-window">
        <input id="my-username" type="hidden" th:value="${username}">
    
        <div class="timer text-center">15</div>
    
        <div class="other-hands-90 other-hands-left" id="user-3"></div>
        <div class="other-hands" id="user-4"></div>
        <div class="my-hands" id="user-1">
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
            <div class="m-card text-center"></div>
        </div>
        <div class="other-hands-90 other-hands-right" id="user-2"></div>
    
        <div class="card-type">
            <div class="m-card text-center">
                <div>
                    ♠️
                    <br>
                    10
                </div>
            </div>
            <div class="m-card text-center">
                <div>
                    ♠️
                    <br>
                    J
                </div>
            </div>
            <div class="m-card text-center">
                <div>
                    ♠️
                    <br>
                    Q
                </div>
            </div>
            <div class="m-card text-center">
                <div>
                    ♠️
                    <br>
                    K
                </div>
            </div>
            <div class="m-card text-center">
                <div>
                    ♠️
                    <br>
                    A
                </div>
            </div>
        </div>
    
        <!-- 綁定按鈕的動作 -->
        <div class="action">
            <button type="button" class="btn btn-outline-success btn-lg" id="button-play" onclick="play()" disabled>出牌</button>
            <button type="button" class="btn btn-outline-danger btn-lg" id="button-pass" onclick="pass()" disabled>PASS</button>
        </div>
    </div>
    
    <div layout:fragment="js-and-css">
        <!-- websockets -->
        <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.js"
                integrity="sha512-tL4PIUsPy+Rks1go4kQG8M8/ItpRMvKnbBjQm4d2DQnFwgcBYRRN00QdyQnWSCwNMsoY/MfJY8nHp2CzlNdtZA=="
                crossorigin="anonymous"
                referrerpolicy="no-referrer"></script>
    
        <!-- custom -->
        <script type="text/javascript" th:src="@{/js/WebSockets.js}"></script>
        <script type="text/javascript" th:src="@{/js/game.js}"></script>
        <link th:href="@{/css/game.css}" rel="stylesheet">
    </div>
    </body>
    </html>
    
  9. 到這邊還只能夠「選擇」牌

出牌

出牌其實很容易,我們原先就定義了 Player 有個方法 play() 用來出牌,再來我們只要串接 WebSocket,取得前端選擇的牌並打出去即可。
但是要讓大家輪流打牌就稍微複雜點,因為還要決定現在輪到誰,尤其是選擇第一個出牌的玩家,會更麻煩,但不至於做不到!
/images/emoticon/emoticon13.gif

  1. 先定義找出第一個出牌的玩家,我們透過比對各個玩家的手牌找到「梅花 3」來找到玩家,在 GameService 增加 findFirstPlayer(),加入的程式碼為:
    public String findFirstPlayer(String roomId) {
        Player[] players = this.gameStatus.getPlayers(roomId);
        ArrayList<Card> hands;
        for (Player player : players) {
            hands = player.getHands();
            for (Card card : hands) {
                if (card.getSuit().equals(Suit.CLUB) && card.getNumber().equals(Number.THREE)) {
                    return player.getName();
                }
            }
        }
        return null;
    }
    
  2. 然後把這個動作加入原本 GameService 的 initializeGameStatus(),此函數修改後的完整內容為:
    public void initializeGameStatus(String roomId) {
            Room room = roomList.getRoomById(roomId);
            Deck deck = new Deck();
            deck.shuffle();
            ArrayList<ArrayList<Card>> hands = deck.deal();
            ArrayList<String> roomMembers = room.getAllMembers();
            Player[] players = new Player[4];
            for (int i=0; i<4; i++) {
                Player newPlayer = new Player(roomMembers.get(i));
                newPlayer.setHands(hands.get(i));
                players[i] = newPlayer;
            }
    
            gameStatus.add(roomId);
            gameStatus.setPlayers(roomId, players);
            gameStatus.setCurrentPlayer(roomId, this.findFirstPlayer(roomId));
        }
    
  3. 現在我們的 GameStatus 確實的存好了遊戲狀態(包含:所有玩家、上一個出牌的玩家、上一個玩家打出的牌、當前出牌的玩家),再來就要定義「玩」的過程
  4. 玩的過程會用到昨天定義的 GameTimer,而 GameTimer 是否停止,則是由 WebSocket 接受到玩家 pass 或 play 才停止,所以我們需要再一個實例儲存目前房間的 Timer,在 game package 底下建立一個 java class,名稱為 GameTimerList,內容為:
    package com.example.game;
    
    import org.springframework.stereotype.Component;
    
    import java.util.HashMap;
    import java.util.Map;
    
    @Component
    public class GameTimerList {
        Map<String, GameTimer> timerList = new HashMap<>();
    
        public GameTimer get(String roomId) {
            return this.timerList.get(roomId);
        }
    
        public void add(String roomId, GameTimer timer) {
            this.timerList.put(roomId, timer);
        }
    
        public void remove(String roomId) {
            this.timerList.remove(roomId);
        }
    }
    
    • get():取得該房間的 Timer
    • add():新增該房間的 Timer
    • remove():移除該房間的 Timer
  5. 最複雜的地方來了!來定義怎麼「玩」吧,在 GameService 加入 play(),內容為:
    public void play(String roomId) {
        Player[] players = gameStatus.getPlayers(roomId);
        int index = 0;
        // 找出目前出牌的玩家的 index
        for (int i=0; i<4; i++) {
            if (players[i].getName().equals(this.gameStatus.getCurrentPlayer(roomId))) {
                index = i;
                break;
            }
        }
    
        String currentPlayer;
        String previousPlayer = null;
        GameTimer timer = new GameTimer();
        this.gameTimerList.add(roomId, timer);
    
        // 延遲 5 秒再開始,這是為了等 Client 載入頁面、連接 webscoket 等動作
        try {
            Thread.sleep(5000);
        }
        catch(Exception e) {
            System.out.println(e.getMessage());
        }
    
        // 開始無窮迴圈,直到有人贏為止
        while (true) {
            currentPlayer = players[index].getName();
            this.gameStatus.setCurrentPlayer(roomId, currentPlayer);
    
            // 如果目前的玩家跟上一次出牌的玩家一樣,就說明其他玩家都 pass 了一輪,那這時候就可以自由出牌
            if (currentPlayer.equals(previousPlayer)) {
                this.gameStatus.setPreviousPlayer(roomId, null);
                this.gameStatus.setPreviousPlayedCards(roomId, null);
            }
    
            timer.init(20);
    
            // 開始定義計時器每次倒數 1 秒要做什麼事情
            timer.countDown((n) -> {
                Map<String, Object> status = new HashMap<>();
    
                // 取得當前出牌的玩家
                status.put("currentPlayer", this.gameStatus.getCurrentPlayer(roomId));
    
                // 取得上一玩家打出的牌
                PlayedCards tmp = this.gameStatus.getPreviousPlayedCards(roomId);
                if (tmp == null) {
                    status.put("previousPlayedCards", null);
                }
                else {
                    status.put("previousPlayedCards", tmp.get());
                }
    
                // 取得目前 timer 數到幾了
                status.put("timer", n);
    
                roomService.sendMessageToRoom(roomId, "/queue/game", status);
            });
            timer.await(25);
    
            // 如果有玩家的手牌沒了就結束了
            // 透過發送結束訊息給所有玩家,使他們結束遊戲,並顯示結算的畫面
            if (previousPlayer != null && gameStatus.getHandsByPlayerName(previousPlayer).size() == 0) {
    
                // TODO: 定義結束遊戲的訊息
    
                this.gameStatus.remove(roomId);
                this.gameTimerList.remove(roomId);
                return;
            }
    
            // 新的一輪
            previousPlayer = currentPlayer;
            gameStatus.setPreviousPlayer(roomId, previousPlayer);
            index = (index + 1) % 4;
        }
    
  6. 再來,我們要定義 WebSocket 接收到玩家的 play 或是 pass 的動作時,要中斷當前的 Timer 倒數,但記得要先定義接收訊息的格式~ 在 game package 底下建立一個 java class,名稱為 PlayerActionMessage,完整內容為:
    package com.example.game;
    
    import com.example.card.Card;
    import com.example.card.PlayedCards;
    import lombok.Getter;
    import lombok.Setter;
    
    import java.util.ArrayList;
    
    @Getter @Setter
    public class PlayerActionMessage {
        private String action;
        private PlayedCards playedCards;
    
        public void setPlayedCards(ArrayList<Card> cards) {
            this.playedCards = new PlayedCards(cards);
        }
    }
    
    • 這邊要特別注意 setPlayedCards() 是因為我們傳輸過來的格式是一張一張的 Card 組成的 Array,我們可以把他當成一個 ArrayList<Card>
  7. 再修改 GameWsController,加入 playGame() 加入的片段為:
    @MessageMapping("/play")
    public void playGame(PlayerActionMessage playerActionMessage, Principal principal) {
        String playerName = principal.getName();
        String roomId = userStatus.getUserRoomId(playerName);
        Player player = this.gameService.findPlayer(roomId, playerName);
        Map<String, Object> response = new HashMap<>();
        GameTimer timer = gameTimerList.get(roomId);
        String action = playerActionMessage.getAction();
        if (action.equals("play")) {
            PlayedCards currentPlayedCards = playerActionMessage.getPlayedCards();
            PlayedCards previousPlayedCards = this.gameStatus.getPreviousPlayedCards(roomId);
            // TODO: 檢驗玩家是否能出這組牌
    
            player.play(currentPlayedCards.get());
            this.gameStatus.setPreviousPlayer(roomId, playerName);
            this.gameStatus.setPreviousPlayedCards(roomId, currentPlayedCards);
        }
        response.put("message", "success");
        simpMessagingTemplate.convertAndSendToUser(playerName, "/queue/play", response);
        timer.stop();
    }
    
  8. 然後我們補上 findPlayer() 到 GameService:
    public Player findPlayer(String roomId, String name) {
        for (Player player : this.gameStatus.getPlayers(roomId)) {
            if (player.getName().equals(name)) {
                return player;
            }
        }
        return null;
    }
    
  9. 再來要回到 Client 端,我們還要訂閱 game,修改 game.js,加入 subscribeGame()
    function subscribeGame() {
        websocket.subscribe("/user/queue/game", (response) => {
            response = JSON.parse(response.body);
            $(".timer").text(response.timer);
    
            if (response.currentPlayer === myUsername) {
                $("#button-play").prop("disabled", false);
                $("#button-pass").prop("disabled", false);
            }
            else {
                $("#button-play").prop("disabled", true);
                $("#button-pass").prop("disabled", true);
            }
    
            // TODO: 更新上一個玩家打出的牌
        });
    }
    
  10. 不要忘記 $(document).ready()
    $(document).ready(() => {
        websocket.connect('/connect', () => {
            subscribeMyHands();
            subscribeHandsInfo();
            subscribeGame();
            subscribePlay()
            websocket.send("/my-hands", {});
            websocket.send("/hands-info", {});
        });
        relocateMyHands();
        relocatePlayedCards();
    })
    
  11. 再來就去試玩看看吧~~

其實還有些狀況要處理,比如:檢驗使用者打出的牌是合法的、更新上一個玩家打出的牌等,這些狀況我們明天再來處理~~ /images/emoticon/emoticon06.gif


上一篇
Day 27 - 倒數計時
下一篇
Day 29 - 檢驗出牌
系列文
Spring Boot... 深不可測31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言