iT邦幫忙

2022 iThome 鐵人賽

DAY 30
0

今日目標,顯示上一組牌、定義特殊牌型、檢驗出牌是合法的。

Debug

發現前幾天的 code 邏輯有點問題,所以稍微作一下修正,最近有點累... /images/emoticon/emoticon28.gif

  1. GameService,加入 sendMyHands(),這是為了待會方便調用、修改 play(),主要是 previousPlayer 從錯誤地方取得,原本是直接取 currentPlayer,但應該要從 gameStatus 取才對:
    public void sendMyHands(String name) {
        ArrayList<Card> myHands = this.getHandsByPlayerName(name);
        this.simpMessagingTemplate.convertAndSendToUser(name, "/queue/my-hands", myHands);
    }
    
    public void play(String roomId) {
        Player[] players = gameStatus.getPlayers(roomId);
        int index = 0;
        for (int i=0; i<4; i++) {
            if (players[i].getName().equals(this.gameStatus.getCurrentPlayer(roomId))) {
                index = i;
                break;
            }
        }
    
        String currentPlayer;
        String previousPlayer;
        GameTimer timer = new GameTimer();
        this.gameTimerList.add(roomId, timer);
        try {
            Thread.sleep(5000);
        }
        catch(Exception e) {
            System.out.println(e.getMessage());
        }
    
        while (true) {
            previousPlayer = this.gameStatus.getPreviousPlayer(roomId);
            currentPlayer = players[index].getName();
            this.gameStatus.setCurrentPlayer(roomId, currentPlayer);
            if (currentPlayer.equals(previousPlayer)) {
                this.gameStatus.setPreviousPlayer(roomId, null);
                this.gameStatus.setPreviousPlayedCards(roomId, null);
            }
    
            timer.init(20);
            String finalCurrentPlayer = currentPlayer;
            String finalPreviousPlayer = previousPlayer;
            timer.countDown((n) -> {
                Map<String, Object> status = new HashMap<>();
                status.put("currentPlayer", finalCurrentPlayer);
                PlayedCards tmp = this.gameStatus.getPreviousPlayedCards(roomId);
                if (tmp == null) {
                    status.put("previousPlayedCards", null);
                }
                else {
                    status.put("previousPlayedCards", tmp.get());
                }
                status.put("timer", n);
                roomService.sendMessageToRoom(roomId, "/queue/game", status);
            });
            timer.await(25);
    
            if (gameStatus.getHandsByPlayerName(currentPlayer).size() == 0) {
    
                // TODO: 定義結束遊戲的訊息
    
                this.gameStatus.remove(roomId);
                this.gameTimerList.remove(roomId);
                return;
            }
            index = (index + 1) % 4;
        }
    }
    
  2. GameWsController,修改 getMyHands(),其實這邊不是必要的,只是因為上面把發送手牌訊息函數化就順便改了:
    @MessageMapping("/my-hands")
    public void getMyHands(Principal principal) {
        String name = principal.getName();
        this.gameService.sendMyHands(name);
    }
    
  3. GameTimer,修改 countDown(),多一個錯誤捕獲的過程,避免被停止卻又剛好正在倒數:
    public void countDown(Callable printer) {
        try {
            Thread.sleep(1000);
            this.timer.scheduleAtFixedRate(()-> {
                printer.call(time.toString());
                time--;
                if (time <= 0) {
                    stop();
                }
            }, 1, 1, TimeUnit.SECONDS);
        }
        catch (Exception ignored) {
    
        }
    }
    
  4. game.jscompareByGeneral(),這樣排列應該才是正常人會用的:
    function compareByGeneral(a, b) {
        if (GENERAL_LEVEL[a.number] > GENERAL_LEVEL[b.number]) {
            return 1;
        }
        else if (GENERAL_LEVEL[a.number] < GENERAL_LEVEL[b.number]) {
            return -1;
        }
        else {
            return GENERAL_LEVEL[b.suit] - GENERAL_LEVEL[a.suit];
        }
    }
    
  5. game.jsgenerateMyHands(),要記得清除原先選擇的牌,不然會一直往上疊,最後什麼牌都出不了,修改後的函數完整內容為:
    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();
    
        // 清除選擇的
        selectedCards.clear();
    
        $(".my-hands .m-card").click((element) => {
            let id = element.currentTarget.id;
            if (selectedCards.has(id)) {
                unselectCard(id);
            }
            else {
                selectCard(id);
            }
        })
    }
    

顯示上一個打出的牌

小弟昨天忘記處理這邊了... /images/emoticon/emoticon70.gif

  1. game.js 加入 generatePlayedCards()
    function generatePlayedCards(playedCards) {
        $(".card-type").empty();
        if (playedCards === null) {
            return;
        }
    
        playedCards = playedCards.map(convertCard);
        for (let card of playedCards) {
            let tmp = card.split("-");
            let color = (tmp[0] == SUITS.SPADE || tmp[0] == SUITS.CLUB) ? "#000000" : "#FF0000";
            $(".card-type").append(`
            <div class="m-card text-center" style="color: ${color};">
                <div>
                    ${tmp[0]}
                    <br>
                    ${tmp[1]}
                </div>
            </div>
            `);
        }
        relocatePlayedCards();
    }
    
  2. 修改 subscribeGame(),加入剛才的 generatePlayedCards()
    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);
            }
    
            generatePlayedCards(response.previousPlayedCards);
        });
    }
    
  3. 修改 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">..</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>
    
        <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>
    

牌型

有玩過撲克牌的就知道,會有所謂的牌型,比如同花順、鐵支、葫蘆等,我們要先確定使用者打出的牌究竟是什麼,然後再根據上一個使用者打出的牌來檢驗能不能出這組牌。
不過各個地區的規則略有不同,所以我們這邊要先定義好牌型以及大小和規定:

  • 同花順最大,可以在任何時候出
  • 鐵支第二大,可以在同花順之外的任何時候出
  • 葫蘆、順子、一對、孤支都是一樣小的,而且不能在不同牌型間切換,比如前一個人出葫蘆,你就只能出葫蘆,不能出其他牌型(同花順、鐵支例外)
  • 當牌型的大小相同,
    • 同花順先比順子再比花色
    • 鐵支比 4 張相同的那個數字
    • 葫蘆比 3 張相同的那個數字
    • 順子的大到小的組合順序是 23456 > 10JQKA > 910JQK > 8910JQ > 78910J > 678910 > 56789 > 45678 > 34567 > 12345,當組合相同,比較最大的那個數字
    • 一對先比數字,若相同再比最大的花色,所以黑桃+梅花 > 紅心+方塊
    • 孤之先比數字再比花色
  1. 定義牌型,在 card package 底下建立一個 java enum,名稱為 Type,內容為:
    package com.example.card;
    
    public enum Type {
        STRAIGHT_FLUSH(3),  // 同花順
        FOUR_OF_A_KIND(2),  // 鐵支
        FULL_HOUSE(1),  // 葫蘆
        STRAIGHT(1), // 順子
        PAIR(1),  // 一對
        NONE(1); // 孤支
    
        private final int level;
    
        Type(int level) {
            this.level = level;
        }
    
        public boolean levelGreaterThan(Type other) {
            if(other == null) {
                return true;
            }
            return this.level > other.level;
        }
    
        public boolean levelLessThan(Type other) {
            if(other == null) {
                return false;
            }
            return this.level < other.level;
        }
    
        public boolean levelEquals(Type other) {
            if(other == null) {
                return false;
            }
            return this.level == other.level;
        }
    }
    
    • level 是用來比較他們的階級,較大的階級才能蓋過較小的階級,之後會用階級做出牌檢驗,當階級相同卻不是相同牌型則不合法
  2. 待會會需要將牌作排序,方便檢驗順子類的牌型,修改 Card,加入 getComparatorWithValue()
    public static Comparator<Card> getComparatorWithValue() {
        return new Comparator<Card>() {
            @Override
            public int compare(Card a, Card b) {
                return a.getNumber().getValue() - b.getNumber().getValue();
            }
        };
    }
    
  3. 我們排序時要根據實際的數字做排序,所以在 Number 加入取得他們實際代表的數字的方法 getValue()
    public int getValue() {
        return (this.level + 1) % 13 + 1;
    }
    
  4. 在 Type 加入檢驗牌型的各種方法,加入的程式碼為:
    public static boolean isStraightFlush(ArrayList<Card> cards) {
        if (cards.size() != 5) {
            return false;
        }
    
        // 同時滿足同花和順子的條件
        return Type.isStraight(cards) && Type.isFlush(cards);
    }
    
    public static boolean isFourOfAKing(ArrayList<Card> cards) {
        if (cards.size() != 5) {
            return false;
        }
        Number number = cards.get(0).getNumber();
    
        // 計算不同數字出現幾次
        Map<Number, Integer> map = new HashMap<>();
        for (Card card : cards) {
            if (map.containsKey(card.getNumber())) {
                int tmp = map.get(card.getNumber());
                map.put(card.getNumber(), tmp + 1);
            }
            else {
                map.put(card.getNumber(), 1);
            }
        }
    
        // 只有兩種數字
        boolean onlyContainTwoNumbers = map.keySet().size() == 2;
    
        // 數字的數量不是 1 張就是 4 張
        boolean isCombinedByOneAndFourNumbers = map.get(number) == 1 || map.get(number) == 4;
    
        // 同時滿足才是鐵支
        return onlyContainTwoNumbers && isCombinedByOneAndFourNumbers;
    }
    
    public static boolean isFullHouse(ArrayList<Card> cards) {
        if (cards.size() != 5) {
            return false;
        }
        Number number = cards.get(0).getNumber();
        Map<Number, Integer> map = new HashMap<>();
        for (Card card : cards) {
            if (map.containsKey(card.getNumber())) {
                int tmp = map.get(card.getNumber());
                map.put(card.getNumber(), tmp + 1);
            }
            else {
                map.put(card.getNumber(), 1);
            }
        }
    
        // 只有兩種數字
        boolean onlyContainTwoNumbers = map.keySet().size() == 2;
        // 數字的數量不是 2 張就是 3 張
        boolean isCombinedByTwoAndThreeNumbers = map.get(number) == 2 || map.get(number) == 3;
    
        // 同時符合才是葫蘆
        return onlyContainTwoNumbers && isCombinedByTwoAndThreeNumbers;
    }
    
    public static boolean isStraight(ArrayList<Card> cards) {
        if (cards.size() != 5) {
            return false;
        }
    
        // 先根據數字做排序
        cards.sort(Card.getComparatorWithValue());
        int previous = cards.get(0).getNumber().getValue();
    
        // 只要後一張數字是前一張數字 -1 就是連續的,連續 5 張就是順子
        for (int i=1; i<5; i++) {
            if (cards.get(i).getNumber().getValue() - previous != 1) {
                return false;
            }
            previous = cards.get(i).getNumber().getValue();
        }
        return true;
    }
    
    public static boolean isFlush(ArrayList<Card> cards) {
        if (cards.size() != 5) {
            return false;
        }
        Suit suit = cards.get(0).getSuit();
    
        // 全部花色相同
        for (Card card : cards) {
            if (!card.getSuit().equals(suit)) {
                return false;
            }
        }
        return true;
    }
    
    public static boolean isPair(ArrayList<Card> cards) {
        if (cards.size() != 2) {
            return false;
        }
    
        // 兩張數字一致
        return cards.get(0).getNumber().equals(cards.get(1).getNumber());
    }
    
  5. 再來,我們還需要辨別目前這組牌是什麼牌型,所以在 Type 加入 getType()
    public static Type getType(ArrayList<Card> cards) {
        int size = cards.size();
        if (size == 5) {
            if (isStraightFlush(cards)) {
                return Type.STRAIGHT_FLUSH;
            }
            else if (isFourOfAKing(cards)) {
                return Type.FOUR_OF_A_KIND;
            }
            else if(isFullHouse(cards)) {
                return Type.FULL_HOUSE;
            }
            else if (isStraight(cards)) {
                return Type.STRAIGHT;
            }
        }
        else if (size == 2) {
            if (isPair(cards)) {
                return Type.PAIR;
            }
        }
        else if (size == 1) {
            return Type.NONE;
        }
        return null;
    }
    

檢驗

再來,我們要檢驗玩家打出的這組牌是合法的,也就是辨別的出是哪種牌型,而且比上一組牌來的更大。

  1. 檢驗是某一個 Type 的,在 PlayedCards 加入 isValid()
    public boolean isValid() {
        return Type.getType(this.get()) != null;
    }
    
  2. 當兩組牌型相同,而且組合相同,就要比較最大的那張,為了方便就將牌再次排序,但這次是根據牌的 level,在 Card 加入 greaterThan() 比較兩張牌的大小關係、getComparatorWithLevel() 根據 level 排序:
    public boolean greaterThan(Card other) {
        if (this.number.greaterThan(other.number)) {
            return true;
        }
        if (this.number.lessThan(other.number)) {
            return false;
        }
    
        return this.suit.greaterThan(other.suit);
    }
    
    public static Comparator<Card> getComparatorWithLevel() {
        return new Comparator<Card>() {
            @Override
            public int compare(Card a, Card b) {
                if (a.greaterThan(b)) {
                    return 1;
                }
                return -1;
            }
        };
    }
    
  3. 然後比較牌型,在過程中會需要找出最大的那張牌,先把牌根據 level 作排序,最後一張一定是最大的那張,在 PlayedCards 加入 getHighestLevelCard()compare()
    public Card getHighestLevelCard() {
        this.cards.sort(Card.getComparatorWithLevel());
        return this.cards.get(this.cards.size() - 1);
    }
    
    public static int compare(PlayedCards a, PlayedCards b) {
        if (a == null) {
            return -1;
        }
        if (b == null) {
            return 1;
        }
        Type aType = Type.getType(a.get());
        Type bType = Type.getType(b.get());
        if (bType.levelGreaterThan(aType)) {
            return -1;
        }
        else if (bType.levelEquals(aType)) {
            if (bType == aType) {
                if (aType == Type.STRAIGHT || aType == Type.STRAIGHT_FLUSH) {
                    boolean aIsAceToFive = a.get().get(3).getNumber().equals(Number.ACE);
                    boolean bIsAceToFive = b.get().get(3).getNumber().equals(Number.ACE);
                    if (aIsAceToFive && bIsAceToFive) {
                        if (a.getHighestLevelCard().greaterThan(b.getHighestLevelCard())) {
                            return 1;
                        }
                        else {
                            return -1;
                        }
                    }
                    else {
                        if (aIsAceToFive) {
                            return -1;
                        }
                        else if (bIsAceToFive) {
                            return 1;
                        }
                        else {
                            if (a.getHighestLevelCard().greaterThan(b.getHighestLevelCard())) {
                                return 1;
                            }
                            else {
                                return -1;
                            }
                        }
                    }
                }
                else if (aType == Type.FULL_HOUSE || aType == Type.FOUR_OF_A_KIND) {
                    if (a.getHighestLevelCard().greaterThan(b.getHighestLevelCard())) {
                        return 1;
                    }
                    else {
                        return -1;
                    }
                }
                else {
                    if (a.getHighestLevelCard().greaterThan(b.getHighestLevelCard())) {
                        return 1;
                    }
                    else {
                        return -1;
                    }
                }
            }
            else {
                return 0;
            }
        }
        return 0;
    }
    
  4. 再來就是檢驗到底能不能打出了,在 PlayedCards 加入 canBePlayedOn()
    public boolean canBePlayedOn(PlayedCards previous) {
        return PlayedCards.compare(this, previous) == 1;
    }
    
  5. 在 GameWsController 的 playGame() 加入檢驗的過程,然後我們改一下函數名稱(改成 playCards()),昨天取得不好...:
    @MessageMapping("/play")
    public void playCards(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);
    
            // 檢驗它
            if (!currentPlayedCards.isValid() || !currentPlayedCards.canBePlayedOn(previousPlayedCards)) {
                response.put("status", "fail");
                simpMessagingTemplate.convertAndSendToUser(playerName, "/queue/play", response);
                return;
            }
    
            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();
    }
    

自動出牌

如果梅花 3 的那個人一直不出,那其實有違遊戲規則的,或者是大家都 pass 一輪完,結果原本那個人不出牌,這樣沒辦法玩下去,所以還需要自動出牌的手段,讓整個遊戲可以正常進行。

  1. 在 GameService 加入 autoPlay()
    public void autoPlay(String roomId, String playerName) {
        Player player = this.findPlayer(roomId, playerName);
        ArrayList<Card> cards = new ArrayList<>(player.getHands().subList(0, 1));
        player.play(cards);
        this.gameStatus.setPreviousPlayer(roomId, playerName);
        this.gameStatus.setPreviousPlayedCards(roomId, new PlayedCards(cards));
    }
    
    public void autoPlay(String roomId, String playerName, ArrayList<Card> cards) {
        Player player = this.findPlayer(roomId, playerName);
        player.play(cards);
        this.gameStatus.setPreviousPlayer(roomId, playerName);
        this.gameStatus.setPreviousPlayedCards(roomId, new PlayedCards(cards));
    }
    
  2. 然後在 play() 加入自動打出的動作,目前 play 完整內容為:
    public void play(String roomId) {
        Player[] players = gameStatus.getPlayers(roomId);
        int index = 0;
        for (int i=0; i<4; i++) {
            if (players[i].getName().equals(this.gameStatus.getCurrentPlayer(roomId))) {
                index = i;
                break;
            }
        }
    
        String currentPlayer;
        String previousPlayer;
        GameTimer timer = new GameTimer();
        this.gameTimerList.add(roomId, timer);
        try {
            Thread.sleep(5000);
        }
        catch(Exception e) {
            System.out.println(e.getMessage());
        }
    
        while (true) {
            previousPlayer = this.gameStatus.getPreviousPlayer(roomId);
            currentPlayer = players[index].getName();
            this.gameStatus.setCurrentPlayer(roomId, currentPlayer);
            if (currentPlayer.equals(previousPlayer)) {
                this.gameStatus.setPreviousPlayer(roomId, null);
                this.gameStatus.setPreviousPlayedCards(roomId, null);
            }
    
            timer.init(20);
            String finalCurrentPlayer = currentPlayer;
            String finalPreviousPlayer = previousPlayer;
            timer.countDown((n) -> {
                Map<String, Object> status = new HashMap<>();
                status.put("currentPlayer", finalCurrentPlayer);
                PlayedCards tmp = this.gameStatus.getPreviousPlayedCards(roomId);
                if (tmp == null) {
                    status.put("previousPlayedCards", null);
                }
                else {
                    status.put("previousPlayedCards", tmp.get());
                }
                status.put("timer", n);
                roomService.sendMessageToRoom(roomId, "/queue/game", status);
    
                // 數到 1,就自動出牌了
                if (n.equals("1")) {
                    boolean isFirstPlayer = this.findFirstPlayer(roomId) != null;
                    // 如果是第一個出牌的玩家
                    if (isFirstPlayer) {
                        ArrayList<Card> cards = new ArrayList<>();
                        cards.add(new Card(Suit.CLUB, Number.THREE));
                        this.autoPlay(roomId, finalCurrentPlayer, cards);
                        sendMyHands(finalCurrentPlayer);
                        sendHandsInfo(roomId);
                        return;
                    }
    
                    // 如果都 pass 一輪了
                    if (finalCurrentPlayer.equals(finalPreviousPlayer)) {
                        this.autoPlay(roomId, finalCurrentPlayer);
                        sendMyHands(finalCurrentPlayer);
                        sendHandsInfo(roomId);
                    }
                }
            });
            timer.await(25);
    
            if (gameStatus.getHandsByPlayerName(currentPlayer).size() == 0) {
    
                // TODO: 定義結束遊戲的訊息
    
                this.gameStatus.remove(roomId);
                this.gameTimerList.remove(roomId);
                return;
            }
            index = (index + 1) % 4;
        }
    }
    

稍微修了一下之前的 bug,今天玩了好幾局才發現前幾天寫錯了... /images/emoticon/emoticon10.gif
雖然鐵人賽的 30 天已經結束了,但小弟的遊戲明天才是最後一天~~ 有發現標題是 Day 29 嗎 /images/emoticon/emoticon07.gif


上一篇
Day 28 - 出牌
下一篇
Day 30 - 結算
系列文
Spring Boot... 深不可測31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言