本章探討**二十一點(Blackjack,又稱「21」)**的物件導向設計。Blackjack 是熱門的撲克遊戲,目標是手牌點數總和盡可能接近 21 而不超過。它結合了策略(決定要 hit 或 stand)與運氣(拿到的牌),是賭場經典之作。

Blackjack Game

需求蒐集#

題目情境#

想像你坐在賭桌前準備玩一局 Blackjack。開局時,你與其他玩家下注,莊家發兩張牌給每位玩家(包括自己)。你評估手牌、決定 hit 或 stand。所有玩家行動完畢後,莊家亮牌並持續 hit 直到至少 17 點才停。背景中,遊戲管理牌組、追蹤玩家動作、確保發牌公平、更新餘額。請設計這樣一個系統。

Blackjack 規則繁多(soft 17、double down、splitting…)。本章聚焦於簡化版標準規則的技術設計。

需求釐清對話#

Candidate:要支援多人,還是僅單人對抗莊家?

Interviewer:多人。

Candidate:玩家行動後系統怎麼處理?

Interviewer:每位玩家 hit 或 stand 後,系統檢查所有玩家是否都已 stand 或 bust(超過 21)。當所有人完成後比較手牌、決定贏家、結算下注。

Candidate:莊家有特定的 hit/stand 規則嗎?

Interviewer:是的,莊家持續 hit 直到至少 17 點,然後 stand。

Candidate:下注怎麼處理?

Interviewer:玩家在發牌前下注。獲勝者得到 1:1 賠付(如下注 $10 贏 $10 並拿回原注),bust 者輸掉下注。

需求整理#

功能性需求#

  • 支援多位玩家與一位莊家
  • 開局每位玩家發兩張牌
  • 玩家可 hit(再抽一張)或 stand(保留現有手牌)
  • A 可作為 1 或 11,選擇對玩家最有利的值
  • 每位玩家行動後,系統檢查是否全部已 stand/bust。完成後莊家 hit 直到至少 17,再 stand。然後決定贏家並結算
  • 獲勝者得到 1:1 賠付,bust 者輸掉下注

非功能性需求#

  • 介面直觀,提供清楚的提示與遊戲狀態回饋

Activity Diagram#

理解遊戲流程對設計物件導向結構至關重要,特別是 Blackjack 包含順序步驟與決策點的混合。Activity diagram 視覺化了從發牌到判勝的所有路徑,包含 bust 與莊家 hit 至 17 等情境。

Activity Diagram of Blackjack Game

辨識核心物件#

  • BlackJackGame:管理整個流程的中心實體——發牌、追蹤動作、決定贏家
  • Player:代表參與者的介面,具體實作有:
    • RealPlayer:人類玩家,追蹤下注與餘額
    • DealerPlayer:莊家,不下注,必須 hit 至 17 以上
  • Hand:管理玩家手牌並計算所有可能的點數總和(特別是 A 可為 1 或 11)
  • Deck:管理遊戲所用的牌組,新局時洗牌、玩家 hit 時提供新牌
  • Card:由 RankSuit enum 定義

類別圖設計#

Card#

Card 是直接、不可變的基本元件,封裝 rank 與 suit。為純資料實體無行為邏輯,不可變確保意外變動不會破壞遊戲一致性。

不可變(immutable)意指一旦建立後 rank 與 suit 不可更動。

Card class diagram

Card 保持簡單,透過 getRankValues() 取值,把計算重擔交給 Hand。回傳值是整數陣列而非單一值,因為 A 有兩種可能(1 或 11)。

Design Choice:Card 設計為獨立實體,可在多副牌組或不同變體間重用。

Rank 與 Suit Enums#

用 enum 是理想選擇——型別安全、可讀、易維護:

  • Rank:2-10 為面值,J/Q/K 各為 10,A 可為 1 或 11
  • Suit:標準四種——Hearts、Diamonds、Clubs、Spades

Suit and Rank Enums

為什麼用 enum 而非其他方式?

  • 字串:彈性高但需額外驗證、轉換時麻煩、記憶體開銷較高
  • 整數:可簡單表示數值但易出錯(可能誤填 0 或 15),缺乏內在語意
  • enum:以具名常數提供清晰、型別安全的表示

Deck#

Deck 是 Blackjack 牌組管理的基石,處理標準 52 張牌組。它用 List<Card> 模擬實體牌組順序,提供:

  • 洗牌(Shuffling):隨機化
  • 抽牌(Drawing)
  • 計算剩餘牌數並檢查是否為空
  • 重置(Resetting):開新局時洗牌

Deck class diagram

Player#

Player 介面是所有參與者的藍圖:

  • RealPlayer:追蹤名稱、手牌、下注、餘額;提供下注、賠付、查詢方法
  • DealerPlayer:莊家(不下注、無餘額),依規則 hit 至 17 以上

Player interface with concrete classes

Design Choice:用介面抽象化人類玩家與莊家的共同行為,促進可擴展性。

Hand#

Hand 管理玩家或莊家的手牌,追蹤牌清單並計算所有可能的點數總和。它聰明地處理 A——可為 1 或 11,盡量讓手牌接近 21 而不超過。

提供方法:

  • 新增牌到手牌
  • 存取牌清單
  • 取得可能的點數(用 sorted set 避免重複)
  • 清空手牌
  • 透過 isBust() 判斷是否爆牌

Hand class diagram

Design Choice:將 Hand 與 Player 分離可保持設計乾淨——Hand 專注於牌與點數,Player 處理下注與餘額等其他細節。

BlackJackGame#

BlackJackGame 是中心實體,協調從發牌到回合結束的所有動作。它監督牌組、玩家、莊家,處理發牌、追蹤輪流、結算下注。

BlackjackGame class diagram

完整類別圖#

Class Diagram of Blackjack

程式實作#

Card#

public class Card {
    public final Rank rank;
    public final Suit suit;

    public Card(Rank rank, Suit suit) {
        this.rank = rank;
        this.suit = suit;
    }

    public int[] getRankValues() {
        return rank.getRankValues();
    }
}

Rank enum 定義牌值——A 為 1 或 11,數字牌依面值,J/Q/K 各為 10:

public enum Rank {
    ACE(new int[] {1, 11}),
    TWO(new int[] {2}),
    THREE(new int[] {3}),
    FOUR(new int[] {4}),
    FIVE(new int[] {5}),
    SIX(new int[] {6}),
    SEVEN(new int[] {7}),
    EIGHT(new int[] {8}),
    NINE(new int[] {9}),
    TEN(new int[] {10}),
    JACK(new int[] {10}),
    QUEEN(new int[] {10}),
    KING(new int[] {10});

    private final int[] rankValues;

    Rank(int[] rankValues) {
        this.rankValues = rankValues;
    }

    // Returns the possible values for the rank
    public int[] getRankValues() {
        return this.rankValues;
    }
}

A 從一開始就定義為 [1, 11],讓 Hand 能預先計算所有可能總和,這在處理多張 A 時很有用。

Suit 是簡單的標籤性 enum:

public enum Suit {
    HEARTS,
    SPADES,
    CLUBS,
    DIAMONDS
}

Deck#

public class Deck {
    int nextCardIndex = 0;
    List<Card> cards;

    // Constructor initializes the deck
    public Deck() {
        initializeDeck();
    }

    // Initializes the deck with all cards
    private void initializeDeck() {
        cards = new ArrayList<>();
        for (Suit suit : Suit.values()) {
            for (Rank rank : Rank.values()) {
                cards.add(new Card(rank, suit));
            }
        }
        nextCardIndex = 0; // Reset to start drawing from the first card
    }

    // Shuffles the deck using current time as seed
    public void shuffle() {
        Collections.shuffle(cards, new Random(System.currentTimeMillis()));
    }

    // Draws the next card from the deck
    public Card draw() {
        if (isEmpty() || nextCardIndex >= cards.size()) {
            throw new IllegalStateException("No more cards in deck");
        }
        Card drawCard = cards.get(nextCardIndex);
        nextCardIndex++;
        return drawCard;
    }

    // Returns the number of remaining cards in the deck
    public int getRemainingCardCount() {
        return cards.size() - nextCardIndex;
    }

    // Checks if the deck is empty
    public boolean isEmpty() {
        return getRemainingCardCount() == 0;
    }

    // Resets the deck to start drawing from the beginning
    public void reset() {
        nextCardIndex = 0;
    }
    // getter methods are omitted for brevity
}

Implementation Choice:抽牌時不從 list 移除,改用 nextCardIndex 追蹤下一張牌。這避免了 ArrayList 移除元素的 O(n) 成本(需移動其餘元素)。遞增索引讓抽牌變成 O(1)

Hand#

public class Hand {
    final List<Card> handCards = new ArrayList<>();

    // Sorted set of all possible hand values, accounting for Ace flexibility (1 or 11).
    final SortedSet<Integer> possibleValues = new TreeSet<>();

    public Hand() {}

    // Adds a card to the hand and updates the set of possible total values.
    // For Aces (1 or 11), computes all combinations with existing totals; for other cards, adds
    // their value to each total.
    public void addCard(Card card) {
        if (card == null) {
            throw new IllegalArgumentException("Cannot add null card to hand");
        }
        handCards.add(card);

        // card.getRankValues() returns [1, 11] for Aces or a single value (e.g., [10]) for others.
        if (possibleValues.isEmpty()) {
            // Initialize with the card's values
            for (int value : card.getRankValues()) {
                possibleValues.add(value);
            }
        } else {
            // Add all possible card values to each existing total
            SortedSet<Integer> newPossibleValue = new TreeSet<>();
            for (int value : possibleValues) {
                for (int cardValue : card.getRankValues()) {
                    newPossibleValue.add(value + cardValue);
                }
            }
            possibleValues.clear();
            possibleValues.addAll(newPossibleValue);
        }
    }

    // Returns an unmodifiable list of cards in the hand
    public List<Card> getCards() {
        return Collections.unmodifiableList(handCards);
    }

    // Returns an unmodifiable sorted set of possible hand values
    public SortedSet<Integer> getPossibleValues() {
        return Collections.unmodifiableSortedSet(possibleValues);
    }

    // Clears the hand and possible values
    public void clear() {
        handCards.clear();
        possibleValues.clear();
    }

    // Checks if the hand is bust (all possible values > 21)
    public boolean isBust() {
        // check if all possible value of the player's hand is busted
        if (possibleValues.isEmpty()) {
            return false;
        } else {
            return possibleValues.first() > 21;
        }
    }
}

isBust() 評估 possibleValues 中的最低值——若所有可能都超過 21 即為 bust。

Implementation Strategy:高效處理 A 是 Blackjack 手牌管理的最大挑戰。Hand 不在每次評估時動態重算,而是預先計算所有可能總和

資料結構選擇:用 SortedSetTreeSet 實作)維持有序總和,提供 O(log n) 插入與 O(1) 取得最小值。HashSet 雖然 O(1) 插入但無排序,找最小值需 O(n)。List 則需 O(n) 排序或搜尋。

Player#

public interface Player {
    void bet(int bet);

    void loseBet();

    void returnBet();

    void payout();

    boolean isBust();

    Hand getHand();

    int getBalance();

    String getName();

    int getBet();
}

RealPlayer 處理人類玩家的下注與餘額管理:

public class RealPlayer implements Player {
    private final String name;
    private final Hand hand;
    private int bet;
    private int balance;

    public RealPlayer(String name, int startBalance) {
        this.name = name;
        this.hand = new Hand();
        this.bet = 0;
        this.balance = startBalance;
    }

    // Places a bet for the player
    @Override
    public void bet(int bet) {
        if (bet > balance) {
            throw new IllegalArgumentException("Bet is greater than balance");
        }
        this.bet = bet;
        this.balance -= bet;
    }

    // Handles the player losing a bet
    @Override
    public void loseBet() {
        this.bet = 0;
    }

    // Handles returning the player's bet
    @Override
    public void returnBet() {
        this.balance += bet;
        this.bet = 0;
    }

    // Handles the player winning a payout
    @Override
    public void payout() {
        this.balance += bet * 2; // Return bet plus equal amount
        this.bet = 0;
    }

    // getter methods are omitted for brevity
}

DealerPlayer 代表莊家,遵循與人類玩家不同的預設規則。莊家不下注,因此 bet()loseBet() 等方法為空實作:

public class DealerPlayer implements Player {
    private final String name = "Dealer";
    private final Hand hand;

    public DealerPlayer() {
        this.hand = new Hand();
    }

    // Bet-handling methods for Dealer (bet, loseBet, returnBet) are implemented as empty functions.

    @Override
    public void payout() {
        // Dealer does not get a payout, so this method only prints the winning hand
    }
    // getter methods are omitted for brevity
}

BlackjackGame#

public class BlackJackGame {
    private final Deck deck = new Deck();
    private final List<Player> players = new ArrayList<>();
    protected final Player dealer = new DealerPlayer();
    private Player currentPlayer = null;

    // Tracks the current status of each player's turn (e.g., HIT or STAND)
    Map<Player, Action> playerTurnStatusMap = new HashMap<>();
    GamePhase currentPhase = GamePhase.STARTED;

    public BlackJackGame(List<Player> players) {
        for (Player player : players) {
            if (player == null) throw new IllegalArgumentException();
            this.players.add(player);
            this.playerTurnStatusMap.put(player, null);
        }
        this.playerTurnStatusMap.put(dealer, null);
        deck.shuffle(); // Shuffle the deck when game starts
    }

    // Determines the next player who can take an action (i.e., has not stood or bust). If the
    // current player is the dealer, it triggers the dealer's turn.
    public Player getNextEligiblePlayer() {
        // If current player hasn't stood or bust, they can continue their turn
        if (currentPlayer != null
                && !Action.STAND.equals(playerTurnStatusMap.get(currentPlayer))
                && !currentPlayer.isBust()) {
            return currentPlayer;
        }

        // Find the first player who hasn't stood or bust
        if (currentPlayer == null) {
            for (Player player : players) {
                if (!Action.STAND.equals(playerTurnStatusMap.get(player)) && !player.isBust()) {
                    currentPlayer = player;
                    return currentPlayer;
                }
            }
        }

        // else, find the next player after the current one who hasn't stood or bust
        int currentPlayerIndex = players.indexOf(currentPlayer);
        for (int i = currentPlayerIndex + 1; i < players.size(); i++) {
            Player player = players.get(i);
            if (!Action.STAND.equals(playerTurnStatusMap.get(player)) && !player.isBust()) {
                if (currentPlayer == dealer) {
                    if (!Action.STAND.equals(playerTurnStatusMap.get(dealer))) dealerTurn();
                    return currentPlayer;
                }
                currentPlayer = player;
                return currentPlayer;
            }
        }

        // If no players are left to act, return null
        return null;
    }

    protected void dealerTurn() {
        // Dealer hits if below 17
        while (dealer.getHand().getPossibleValues().last() < 17) {
            Card newDraw = deck.draw();
            dealer.getHand().addCard(newDraw);
        }
        playerTurnStatusMap.put(dealer, Action.STAND);
        checkGameEndCondition();
    }

    public void startNewRound() {
        deck.reset();
        for (Player player : playerTurnStatusMap.keySet()) {
            player.getHand().clear(); // Clear player's hand
        }
        dealer.getHand().clear(); // Clear dealer's hand
        // Reset all turn statuses to null
        playerTurnStatusMap.replaceAll((p, v) -> null);
        currentPlayer = null; // Reset current player
        currentPhase = GamePhase.STARTED;
    }

    public void dealInitialCards() {
        if (!GamePhase.BET_PLACED.equals(currentPhase)) {
            throw new IllegalStateException("All players must bet before dealing");
        }
        // Deal first card to each real player in order
        for (Player player : players) {
            player.getHand().addCard(deck.draw());
        }
        // Deal first card to dealer
        dealer.getHand().addCard(deck.draw());
        // Deal second card to each real player in order
        for (Player player : players) {
            player.getHand().addCard(deck.draw());
        }
        // Deal second card to dealer
        dealer.getHand().addCard(deck.draw());
        currentPhase = GamePhase.INITIAL_CARD_DRAWN;
    }

    public void bet(Player player, int bet) {
        if (!GamePhase.STARTED.equals(currentPhase)) {
            throw new IllegalStateException("Bets must be placed at the start of the round");
        }
        player.bet(bet);
        // Transition to BET_PLACED once all players have bet
        if (players.stream()
                .filter(p -> !(p instanceof DealerPlayer))
                .allMatch(p -> p.getBet() > 0)) {
            currentPhase = GamePhase.BET_PLACED;
        }
    }

    public void hit(Player player) {
        if (Action.STAND.equals(playerTurnStatusMap.get(player))) {
            throw new IllegalStateException("Player has already stood");
        }
        if (player.isBust()) {
            throw new IllegalStateException("Player is already bust");
        }

        Card drawnCard = deck.draw();
        player.getHand().addCard(drawnCard);
        playerTurnStatusMap.put(player, Action.HIT);
    }

    public void stand(Player player) {
        if (Action.STAND.equals(playerTurnStatusMap.get(player))) {
            throw new IllegalStateException("Player has already stood");
        }
        if (player.isBust()) {
            throw new IllegalStateException("Player is already bust");
        }
        playerTurnStatusMap.put(player, Action.STAND);
    }

    // Checks if the game has ended (all players done), then resolves bets by comparing each
    // player's hand to the dealer's.
    private void checkGameEndCondition() {
        boolean allPlayersDone =
                players.stream()
                        .allMatch(
                                p -> Action.STAND.equals(playerTurnStatusMap.get(p)) || p.isBust());
        if (!allPlayersDone) {
            return;
        }

        int dealerValue = dealer.getHand().getPossibleValues().last();
        boolean dealerBusts = dealer.isBust();

        for (Player player : players) {
            if (player.isBust()) {
                player.loseBet();
            } else {
                int playerValue = player.getHand().getPossibleValues().last();
                if (dealerBusts || playerValue > dealerValue) {
                    player.payout();
                } else if (playerValue == dealerValue) {
                    player.returnBet();
                } else {
                    player.loseBet();
                }
            }
        }
        currentPhase = GamePhase.END;
    }

    // getter methods are omitted for brevity
}

流程概覽#

  • startNewRound() — 重置牌組
  • bet() — 玩家下注,dealInitialCards() 發兩張牌
  • getNextEligiblePlayer() — 選擇下一個未 stand/bust 的玩家;所有人完成後觸發莊家 dealerTurn()

莊家規則

  • 點數低於 17 必須 hit
  • 達 17 以上必須 stand

玩家動作

  • hit() — 抽牌;超過 21 標記為 bust
  • stand() — 鎖定手牌

checkGameEndCondition():所有人停止後比較手牌:

  • 莊家 bust:未 bust 玩家獲勝
  • 玩家點數 > 莊家(且 ≤ 21):獲勝
  • 玩家點數 < 莊家:輸
  • 平手:退還下注

深度討論#

解耦玩家與莊家的決策邏輯#

目前設計中,BlackJackGame 直接控制玩家動作,且莊家「hit 至 17」規則被硬編碼於 dealerTurn()。這讓決策邏輯與遊戲類別緊密耦合——任何規則調整都需修改 BlackJackGame

我們引入決策抽象將控制權轉移給個別玩家。BlackJackGame 專注於協調輪流,每位玩家獨立決定動作。

Step 1:定義決策介面#

public interface PlayerDecisionLogic {
    // Decides the next action for a player based on their hand
    Action decideAction(Hand hand);
}

Step 2:為人類與莊家量身訂做#

public class RealPlayerDecisionLogic implements PlayerDecisionLogic {
    @Override
    public Action decideAction(Hand hand) {
        return hand.getPossibleValues().last() < 16 ? Action.HIT : Action.STAND;
    }
}

public class DealerDecisionLogic implements PlayerDecisionLogic {
    @Override
    public Action decideAction(Hand hand) {
        return hand.getPossibleValues().last() < 17 ? Action.HIT : Action.STAND;
    }
}

Step 3:將決策整合到 Player#

public interface Player {
    // Returns the decision logic for the player
    PlayerDecisionLogic getDecisionLogic(); // ... other methods ...
}

public class RealPlayer implements Player {
    private final PlayerDecisionLogic decisionLogic;

    public RealPlayer(String name, int startBalance) {
        this.name = name;
        this.hand = new Hand();
        this.bet = 0;
        this.balance = startBalance;
        this.decisionLogic = new RealPlayerDecisionLogic();
    }

    // Returns the decision logic for the player
    @Override
    public PlayerDecisionLogic getDecisionLogic() {
        return decisionLogic;
    }
    // ... other methods ...
}

public class DealerPlayer implements Player {
    private final PlayerDecisionLogic decisionLogic;

    public DealerPlayer() {
        this.hand = new Hand();
        this.decisionLogic = new DealerDecisionLogic();
    }

    // Returns the decision logic for the dealer
    @Override
    public PlayerDecisionLogic getDecisionLogic() {
        return decisionLogic;
    }
    // ... other methods ...
}

Step 4:調整遊戲流程#

引入:

  • performPlayerAction() — 查詢玩家的決策邏輯決定 hit 或 stand,取代硬編碼的 dealerTurn()
  • playNextTurn() — 協調輪流
public class BlackJackGame {
    // ... fields unchanged ...

    // Find the next player who can take an action
    public Player getNextEligiblePlayer() {
        // No current player: find first eligible player from the start
        if (currentPlayer == null) {
            for (Player player : players) {
                if (!Action.STAND.equals(playerTurnStatusMap.get(player)) && !player.isBust()) {
                    currentPlayer = player;
                    return currentPlayer;
                }
            }
            // Instead of calling dealerTurn(), check if the dealer can act
            if (!Action.STAND.equals(playerTurnStatusMap.get(dealer))) {
                currentPlayer = dealer;
                return dealer;
            }
        } else {
            int currentIndex = players.indexOf(currentPlayer);
            for (int i = currentIndex + 1; i < players.size(); i++) {
                Player player = players.get(i);
                if (!Action.STAND.equals(playerTurnStatusMap.get(player)) && !player.isBust()) {
                    currentPlayer = player;
                    return currentPlayer;
                }
            }
            // If all players are done, check if the dealer can act
            if (currentPlayer != dealer && !Action.STAND.equals(playerTurnStatusMap.get(dealer))) {
                currentPlayer = dealer;
                return dealer;
            }
        }
        return null; // All turns are complete, including the dealer's
    }

    // Executes the next turn by acting for the next player or dealer.
    public void playNextTurn() {
        Player nextPlayer = getNextEligiblePlayer();
        if (nextPlayer != null) {
            performPlayerAction(nextPlayer);
        }
    }

    // Performs the action decided by the player's decision logic (hit or stand).
    public void performPlayerAction(Player player) {
        Action action = player.getDecisionLogic().decideAction(player.getHand());
        if (action == Action.HIT) {
            hit(player);
        } else if (action == Action.STAND) {
            stand(player);
        }
    }
    // ... other methods unchanged, dealerTurn() removed ...
}

此設計實踐了 Strategy Pattern

  • PlayerDecisionLogic — 設定決策契約
  • RealPlayerDecisionLogicDealerDecisionLogic — 具體行為
  • BlackJackGame — 使用它們而不需內部處理決策

本章小結#

主要收穫

  • 清晰的職責分離:Card、Deck、Hand、Player、BlackJackGame 各司其職
  • 模組化與可擴展設計:邏輯清晰、易追蹤、易擴充
  • Strategy Pattern:以 PlayerDecisionLogic 解耦決策邏輯,輕鬆切換策略