本章探討**二十一點(Blackjack,又稱「21」)**的物件導向設計。Blackjack 是熱門的撲克遊戲,目標是手牌點數總和盡可能接近 21 而不超過。它結合了策略(決定要 hit 或 stand)與運氣(拿到的牌),是賭場經典之作。
需求蒐集#
題目情境#
想像你坐在賭桌前準備玩一局 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 等情境。
辨識核心物件#
- BlackJackGame:管理整個流程的中心實體——發牌、追蹤動作、決定贏家
- Player:代表參與者的介面,具體實作有:
- RealPlayer:人類玩家,追蹤下注與餘額
- DealerPlayer:莊家,不下注,必須 hit 至 17 以上
- Hand:管理玩家手牌並計算所有可能的點數總和(特別是 A 可為 1 或 11)
- Deck:管理遊戲所用的牌組,新局時洗牌、玩家 hit 時提供新牌
- Card:由 Rank 與 Suit enum 定義
類別圖設計#
Card#
Card 是直接、不可變的基本元件,封裝 rank 與 suit。為純資料實體無行為邏輯,不可變確保意外變動不會破壞遊戲一致性。
不可變(immutable)意指一旦建立後 rank 與 suit 不可更動。
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
為什麼用 enum 而非其他方式?
- 字串:彈性高但需額外驗證、轉換時麻煩、記憶體開銷較高
- 整數:可簡單表示數值但易出錯(可能誤填 0 或 15),缺乏內在語意
- enum:以具名常數提供清晰、型別安全的表示
Deck#
Deck 是 Blackjack 牌組管理的基石,處理標準 52 張牌組。它用 List<Card> 模擬實體牌組順序,提供:
- 洗牌(Shuffling):隨機化
- 抽牌(Drawing)
- 計算剩餘牌數並檢查是否為空
- 重置(Resetting):開新局時洗牌
Player#
Player 介面是所有參與者的藍圖:
- RealPlayer:追蹤名稱、手牌、下注、餘額;提供下注、賠付、查詢方法
- DealerPlayer:莊家(不下注、無餘額),依規則 hit 至 17 以上
Design Choice:用介面抽象化人類玩家與莊家的共同行為,促進可擴展性。
Hand#
Hand 管理玩家或莊家的手牌,追蹤牌清單並計算所有可能的點數總和。它聰明地處理 A——可為 1 或 11,盡量讓手牌接近 21 而不超過。
提供方法:
- 新增牌到手牌
- 存取牌清單
- 取得可能的點數(用 sorted set 避免重複)
- 清空手牌
- 透過
isBust()判斷是否爆牌
Design Choice:將 Hand 與 Player 分離可保持設計乾淨——Hand 專注於牌與點數,Player 處理下注與餘額等其他細節。
BlackJackGame#
BlackJackGame 是中心實體,協調從發牌到回合結束的所有動作。它監督牌組、玩家、莊家,處理發牌、追蹤輪流、結算下注。
完整類別圖#
程式實作#
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 不在每次評估時動態重算,而是預先計算所有可能總和。
資料結構選擇:用
SortedSet(TreeSet實作)維持有序總和,提供 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 標記為 buststand()— 鎖定手牌
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— 設定決策契約RealPlayerDecisionLogic與DealerDecisionLogic— 具體行為BlackJackGame— 使用它們而不需內部處理決策
本章小結#
主要收穫:
- 清晰的職責分離:Card、Deck、Hand、Player、BlackJackGame 各司其職
- 模組化與可擴展設計:邏輯清晰、易追蹤、易擴充
- Strategy Pattern:以
PlayerDecisionLogic解耦決策邏輯,輕鬆切換策略