← 返回大綱
實作練習

21 點
從需求到程式

不是抄程式碼,是學會「怎麼從規則推出程式」

今天的路線

五個階段

1看懂需求 — 規則裡藏著要存什麼資料、要寫哪些函式
2設計介面 — 每個函式收什麼、回什麼(先不寫內容)
3分段實作 — 一次只做一小步,每步都能自己測
4組裝驗收 — 把函式串成完整遊戲
5需求變更 — 老闆又改需求了,程式要動哪裡?
這頁怎麼用

選擇題答完會自動出現解說,實作題卡住時有 提示 1 → 提示 2 → 答案 三段可以點。
不用舉手,自己點就好。

階段一

看懂需求

從遊戲規則,推出「要什麼資料」跟「要哪些函式」

需求書

客戶說:「我要一個 21 點」

玩法:玩家跟莊家各拿牌,誰的點數比較接近 21 但不超過,誰就贏。
點數:2~10 就是牌面數字;J、Q、K 都算 10 點;A 可以算 11 或 1。
A 的規則:A 先當 11 點算,如果這樣會超過 21,就自動改成 1 點。
開局:玩家跟莊家各發兩張牌,莊家的第二張要蓋著不給看。
玩家回合:玩家可以一直選「抽牌」或「停牌」,超過 21 就爆牌直接輸。
莊家回合:莊家沒得選,只要不到 17 點就一定要繼續抽。
結算:比大小,有人爆牌就直接判定,點數一樣算平手。
拆解 1

先問:這局遊戲是怎麼跑的?

寫程式之前,先用中文把「一局」從頭到尾走一遍。

1準備一副洗好的牌
2發給玩家兩張、發給莊家兩張
3把手牌顯示出來(莊家蓋一張)
4玩家回合:一直問「抽還是停」,直到停牌或爆牌
5莊家回合:不到 17 點就一直抽
6比點數,公布結果
重點

這六步待會就是主程式的六行。程式的骨架,在還沒寫任何一行 code 之前就決定好了。

拆解 2

規則裡的「名詞」= 要存起來的資料

把需求書裡的名詞圈出來,它們每一個都要變成變數。

需求裡的名詞是什麼大概要存什麼
一張牌黑桃 A、紅心 7…牌面 + 花色
一副牌 / 牌堆還沒發出去的 52 張一整串牌,要能抽掉
手牌玩家、莊家各自拿到的牌一串牌,會越來越多張
點數對照K 是幾點?A 是幾點?牌面 → 數字的對照
總點數手牌加起來多少一個整數
下一步

知道「要存什麼」還不夠,還要決定用哪種資料結構存。接下來三題就是在做這個決定。

決定 1

一張牌,用什麼型別存?

一張牌用字串 "A♠" 最省事:印出來就是人看得懂的樣子,放進 list 也很直覺。
要拿牌面時再用切片把花色切掉就好("A♠"[:-1] 會得到 "A",這招等一下會練)。
選項 A 兩個變數分開放,一張牌就要兩個變數,52 張會爆炸;
選項 C 存 1~52 還要另外換算,多一層麻煩;
選項 D 用 list 可以,但每次都要寫 card[0]card[1],比字串囉唆。
決定 2

整副牌堆,用什麼裝?

牌堆會一直被抽走,所以必須選「可以修改」的容器 → list
tuple 不能改,抽一張牌就卡住了;
set 沒有順序,牌堆洗好的順序會消失,也沒辦法「抽最上面那張」;
dict 是拿來做對照表的,牌堆只是一串東西,不需要 key。
決定 3

「K 是 10 點」這種對照,用什麼存?

A 對應到 11」這種一對一的查表,就是 dict 存在的理由:values["K"] 直接拿到 10。
選項 A 用 if/elif 也能動,但要寫 13 個分支,而且以後改規則要一個個改;
選項 B 用 list 的話你得先知道 K 排在第幾個,等於自己記索引,很容易錯;
set 只能存「有哪些值」,沒辦法表達對應關係。
拆解 3

規則裡的「動詞」= 要寫的函式

把左邊的動作,配對到右邊的函式名稱(點左邊一個,再點右邊一個)

需求裡的動作

函式名稱

已配對 0 / 7
階段一產出

規格表:我們要做的七件事

函式做什麼難度
create_deck()做出 52 張牌並洗牌★★
deal()從牌堆抽 n 張放進手牌
hand_value()算手牌點數,含 A 的特殊規則★★★
show_hand()印出手牌,可以蓋一張★★
player_turn()玩家回合:一直問抽或停★★
dealer_turn()莊家回合:未滿 17 就抽
judge()比點數印出結果★★
注意這件事

到目前為止我們一行程式都還沒寫,但已經知道要寫七個函式、各自要做什麼。
這就是「需求分析」。真正的工程師也是這樣開始的。

階段二

設計介面

每個函式「收什麼、回什麼」,先講清楚再動手

觀念

什麼叫函式的「介面」?

介面 = 參數(收什麼) + 回傳值(回什麼)

def hand_value(hand):     # 收:一副手牌(list)
    ...
    return total          # 回:總點數(int)
為什麼要先想這個?

只要介面確定了,別人(或未來的你)就能先拿去用,不用管裡面怎麼寫。
先決定介面 → 再寫實作,順序反過來很容易寫到一半整個重來。

複習:有沒有 return 差很多

def a(hand):
    print(21)        # 只印給人看,拿不回來

def b(hand):
    return 21        # 交回給程式,可以繼續算

x = a([])            # x 是 None
y = b([]) + 1        # y 是 22
介面題 1

hand_value 應該長什麼樣?

需求:算出一副手牌總共幾點

要算「哪一副手牌」,所以一定要收 hand 當參數
算完的點數後面還要拿去比大小、判斷有沒有爆,所以必須 return,不能只 print。
選項 C 只印出來的話,if hand_value(hand) > 21 這種判斷就寫不出來了。
介面題 2

deal 需要幾個參數?

需求:從牌堆抽牌,放進某個人的手牌。開局要一次發 2 張,中途抽牌是 1 張。

「從哪裡抽」→ deck,「放給」→ hand,「抽幾張」→ n,三個都需要。
n=1 設成預設值,因為中途抽牌都是抽一張,寫 deal(deck, hand) 就好;
開局才特別寫 deal(deck, hand, 2)。這就是第四章預設參數的用途。
選項 D 少了 deck,函式根本不知道要從哪副牌抽。
介面題 3

莊家要蓋一張牌,介面怎麼設計?

需求:show_hand 平常全部顯示,但玩家回合時莊家的第二張要蓋住

「差別很小的兩種行為」最適合用預設參數切換,程式只要維護一份。
選項 B 兩個函式會有大量重複的程式碼,改一個要記得改另一個;
選項 C 最危險 —— 莊家翻牌之後也要正常顯示,但函式只看名字就永遠蓋著,規則會錯。
參數控制行為,不要用名字猜行為。
最難的一題

A 到底算幾點?先看測資

規則寫「A 先當 11,超過 21 就改 1」。先看幾個實際例子:

手牌如果 A 都算 11正確答案
A♠ K♥11 + 10 = 2121(沒爆,不用改)
A♠ 9♥ 5♦11 + 9 + 5 = 2515(A 改成 1)
A♠ A♥ 9♦11 + 11 + 9 = 3121(只改一張 A)
A♠ A♥ K♦11 + 11 + 10 = 3212(兩張 A 都改)
看出規律了嗎

A 改成 1,等於總點數減 10。而且不是一次改完 —— 是減到不爆為止就停
第三列只減一次就 21 了,所以第二張 A 保持 11。

介面題 4

那段「一直減到不爆」要用什麼寫?

要「重複減,直到不爆」→ 用 while(if 只會減一次,A♠A♥K♦ 那列就會錯)。
但光 while 還不夠:手上有幾張 A,就只能減幾次
選項 C 沒有算 A 的張數,遇到 K♠ Q♥ J♦(30 點、沒有 A)會一路減到 0,完全錯誤。
所以條件要兩個都成立:total > 21 而且 aces > 0
階段三

分段實作

七個函式,一次寫一小步

熱身

寫 create_deck 之前,先複習兩件事

① 空 list + append:東西一個一個加進去

fruits = []              # 先開一個空的
fruits.append("蘋果")     # ['蘋果']
fruits.append("香蕉")     # ['蘋果', '香蕉']
print(len(fruits))       # 2

② 兩層 for:外層跑一次,內層全部跑一輪

for a in ["X", "Y"]:
    for b in [1, 2, 3]:
        print(a + str(b))

# X1 X2 X3 Y1 Y2 Y3  ← 共 2 × 3 = 6 個
連起來想

4 種花色 × 13 種牌面 = 52 張。這正好就是兩層 for 迴圈的形狀。

create_deck — 步驟 1/4

先把兩個素材準備好

用兩個 list 分別存「四種花色」跟「十三種牌面」。

# 目標:印出來要長這樣 ['♠', '♥', '♦', '♣'] ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'] 4 13
suits = ["♠", "♥", "♦", "♣"] ranks = ["A", , "3", "4", "5", "6", "7", "8", "9", , "J", "Q", "K"] print(suits) print(len(suits), len(ranks))
"2" 2 "10" 10
牌面要用字串存。之後查點數表時 key 是 "2",寫成數字 2 會查不到。
兩個變數,各自是一個 list。牌面全部都用字串存(包含數字,"2" 不是 2),這樣等一下查點數表才不會出問題。
suits = ["♠", "♥", "♦", "♣"] ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] print(suits) print(ranks) print(len(suits), len(ranks))
create_deck — 步驟 2/4

先做出「一個花色」的 13 張

用一層 for 迴圈,把 13 個牌面都接上 "♠",放進 deck

# 預期輸出: ['A♠', '2♠', '3♠', '4♠', '5♠', '6♠', '7♠', '8♠', '9♠', '10♠', 'J♠', 'Q♠', 'K♠'] 13
deck = [] for r in ranks: deck.() print(deck) print(len(deck))
append add insert r + "♠" "♠" + r r, "♠"
"♠" + r 會變成 ♠A,花色跑到前面去了。
先開一個空的 deck = [],然後跑過 ranks 裡的每一個牌面,把「牌面 + 花色」append 進去。字串用 + 就能接起來。
deck = [] for r in ranks: deck.append(r + "♠") print(deck) print(len(deck))
create_deck — 步驟 3/4

四個花色,一次做完 52 張

在剛剛的迴圈外面再包一層,跑過每一種花色。

# 預期輸出: 52 前 5 張:['A♠', '2♠', '3♠', '4♠', '5♠'] 最後 1 張:K♣
deck = [] for s in : for r in : deck.append() print(len(deck)) print(deck[0:5])
suits ranks r + s s + r deck
外層跑花色、內層跑牌面。順序顛倒的話牌的排列會變成 A♠ A♥ A♦…,不是一個花色一疊。
外層跑 suits,內層跑 ranks。內層那段就是步驟 2 寫好的東西,只是把寫死的 "♠" 換成外層的變數。不要在迴圈裡面才 deck = [],那樣每換一個花色就會清空一次。
suits = ["♠", "♥", "♦", "♣"] ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] deck = [] for s in suits: for r in ranks: deck.append(r + s) print(len(deck)) print("前 5 張:", deck[0:5]) print("最後 1 張:", deck[-1])
補充:第五章的一行寫法

deck = [r + s for s in suits for r in ranks] 結果一模一樣。
看得懂就好,用迴圈寫沒有比較差 —— 迴圈版每一步在幹嘛看得更清楚。

create_deck — 步驟 4/4

包成函式,順便洗牌

把上面整段包成 create_deck(),洗好牌之後 return 出來。

# 呼叫方式: deck = create_deck() print(len(deck)) print(deck[0:5]) # 預期輸出(順序每次都不一樣): 52 ['7♦', 'K♠', '2♣', 'A♥', '10♦']
import random def create_deck(): deck = [] for s in ["♠", "♥", "♦", "♣"]: for r in ["A","2","3","4","5","6","7","8","9","10","J","Q","K"]: deck.append(r + s) random.(deck) deck deck = create_deck() print(len(deck), deck[0:3])
shuffle choice sample return print break
shuffle直接把 deck 洗亂,不會回傳新的 list,所以不要寫 deck = random.shuffle(deck)
洗牌要用 random 模組,程式最上面加一行 import random,然後 random.shuffle(deck)
注意:shuffle直接把 deck 洗亂,它不會回傳新的 list,所以不要deck = random.shuffle(deck)
import random def create_deck(): suits = ["♠", "♥", "♦", "♣"] ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] deck = [] for s in suits: for r in ranks: deck.append(r + s) random.shuffle(deck) return deck deck = create_deck() print(len(deck)) print(deck[0:5])
熱身

寫 deal 之前:pop() 在做什麼?

pop() 會「拿走並交回」最後一個

deck = ["A♠", "2♠", "3♠"]
card = deck.pop()        # card 拿到 "3♠"
print(card)              # 3♠
print(deck)              # ['A♠', '2♠'] ← 只剩兩張了
為什麼抽牌剛好用 pop()

抽一張牌本來就是兩件事同時發生:牌堆少一張手上多一張
pop() 一次做完,配上 append() 剛好就是完整的發牌動作。

合起來就是一次發牌

hand.append(deck.pop())   # 從牌堆拿走一張,直接放進手牌
deal — 步驟 1/2

先寫「抽一張」

寫一個 deal(deck, hand),從 deck 抽一張放進 hand。

# 呼叫方式: deck = ["A♠", "2♠", "3♠"] hand = [] deal(deck, hand) print(hand) print(deck) # 預期輸出: ['3♠'] ['A♠', '2♠']
def deal(deck, hand): hand.(deck.()) deck = ["A♠", "2♠", "3♠"] hand = [] deal(deck, hand) print(hand) print(deck)
append pop remove insert add
pop() 會「拿走並交回」最後一個;remove() 只刪掉,不會把牌交給你。
函式裡只要一行。而且這個函式不需要 return —— 因為它是直接去改 hand 這個 list,改完外面就看得到了。
def deal(deck, hand): hand.append(deck.pop()) deck = ["A♠", "2♠", "3♠"] hand = [] deal(deck, hand) print(hand) print(deck)
deal — 步驟 2/2

加上「一次抽 n 張」

開局要一次發 2 張。加上參數 n預設是 1

# 呼叫方式: deck = create_deck() player = [] deal(deck, player, 2) # 發兩張 deal(deck, player) # 沒寫 n,預設發一張 print(len(player)) print(len(deck)) # 預期輸出: 3 49
def deal(deck, hand, ): for i in : hand.append(deck.pop()) deck = create_deck() player = [] deal(deck, player, 2) deal(deck, player) print(len(player), len(deck))
n=1 n n=0 range(n) range(1, n) range(0)
預設值寫成 n=1,這樣 deal(deck, hand) 不寫張數時就是發一張。
「重複做 n 次」→ 用 for i in range(n)。預設值寫在參數後面:n=1
def deal(deck, hand, n=1): for i in range(n): hand.append(deck.pop()) deck = create_deck() player = [] deal(deck, player, 2) deal(deck, player) print(len(player)) print(len(deck))
熱身

寫 hand_value 之前:兩個必備技巧

① 用 dict 查點數

values = {"A": 11, "K": 10, "Q": 10, "J": 10, "9": 9}

print(values["K"])       # 10
print(values["9"])       # 9  ← key 是字串 "9",不是數字 9

② 把花色切掉:card[:-1]

card = "K♠"
print(card[:-1])         # K   ← 從頭取到「最後一個之前」
print(card[-1])          # ♠   ← 最後一個

card = "10♥"
print(card[:-1])         # 10  ← 兩位數也切得對!
為什麼不用 card[0]

"10♥"[0] 會得到 "1",不是 "10",10 點的牌全部都會算錯。
從後面切才安全,因為花色永遠只有一個字。

hand_value — 步驟 1/4

先算最單純的:只有數字牌

假設手牌裡沒有 A、J、Q、K,把點數加總 return 出來。

# 呼叫方式: print(hand_value(["7♠", "5♥"])) print(hand_value(["2♦", "3♣", "9♠"])) # 預期輸出: 12 14
def hand_value(hand): values = {"2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "10":10} total = 0 for card in hand: rank = card[] total = total + values[] total print(hand_value(["7♠", "5♥"]), hand_value(["2♦", "3♣", "9♠"]))
:-1 0 -1 rank card return print
card[0] 遇到 "10♥" 只會拿到 "1",10 點的牌全部算錯;card[-1] 拿到的是花色。
先開一個 total = 0,跑過手牌每一張,把花色切掉、查表拿到點數、加進 total。
最後別忘了 return total
def hand_value(hand): values = {"2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "10":10} total = 0 for card in hand: rank = card[:-1] total = total + values[rank] return total print(hand_value(["7♠", "5♥"])) print(hand_value(["2♦", "3♣", "9♠"]))
hand_value — 步驟 2/4

加上 J、Q、K

三張都是 10 點。這步只要改 dict,迴圈完全不用動。

# 呼叫方式: print(hand_value(["K♠", "Q♥"])) print(hand_value(["J♦", "5♣"])) # 預期輸出: 20 15
def hand_value(hand): values = {"2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "10":10, "J":, "Q":, "K":} total = 0 for card in hand: total = total + values[card[:-1]] return total print(hand_value(["K♠", "Q♥"]), hand_value(["J♦", "5♣"]))
10 10 10 11 1 0
這一步只要改 dict,迴圈一個字都不用動 —— 這就是用 dict 而不是 if/elif 的好處。
在 values 裡多加三個 key:"J""Q""K",值都是 10。其他一個字都不用改 —— 這就是用 dict 而不是 if/elif 的好處。
def hand_value(hand): values = {"2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "10":10, "J":10, "Q":10, "K":10} total = 0 for card in hand: rank = card[:-1] total = total + values[rank] return total print(hand_value(["K♠", "Q♥"])) print(hand_value(["J♦", "5♣"]))
hand_value — 步驟 3/4

A 先全部當 11,順便數有幾張

加上 "A": 11,並用一個變數 aces 記錄手上有幾張 A。

# 呼叫方式: print(hand_value(["A♠", "K♥"])) print(hand_value(["A♠", "A♥", "9♦"])) # 這步的預期輸出(還沒處理爆牌,所以第二筆是錯的,正常): 21 31
def hand_value(hand): values = {"A":, "2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "10":10, "J":10, "Q":10, "K":10} total = 0 aces = for card in hand: rank = card[:-1] total = total + values[rank] if rank == : aces = aces + 1 return total print(hand_value(["A♠", "K♥"]), hand_value(["A♠", "A♥", "9♦"]))
11 1 0 1 "A" A
這一步還沒處理爆牌,所以第二筆會印出 31,是正常的。
在迴圈裡多一個判斷:如果這張的 rank 是 "A",就 aces = aces + 1
記得 aces = 0 要放在迴圈外面,跟 total 一樣。
def hand_value(hand): values = {"A":11, "2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "10":10, "J":10, "Q":10, "K":10} total = 0 aces = 0 for card in hand: rank = card[:-1] total = total + values[rank] if rank == "A": aces = aces + 1 return total print(hand_value(["A♠", "K♥"])) print(hand_value(["A♠", "A♥", "9♦"]))
hand_value — 步驟 4/4

爆了就把 A 改成 1

return 之前補上那段 while(就是階段二選過的答案)。

# 呼叫方式: print(hand_value(["A♠", "K♥"])) print(hand_value(["A♠", "9♥", "5♦"])) print(hand_value(["A♠", "A♥", "9♦"])) print(hand_value(["A♠", "A♥", "K♦"])) print(hand_value(["K♠", "Q♥", "J♦"])) # 預期輸出: 21 15 21 12 30
def hand_value(hand): values = {"A":11, "2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "10":10, "J":10, "Q":10, "K":10} total = 0 aces = 0 for card in hand: rank = card[:-1] total = total + values[rank] if rank == "A": aces = aces + 1 while total > 21 : total = total - aces = aces - 1 return total print(hand_value(["A♠","K♥"]), hand_value(["A♠","9♥","5♦"]), hand_value(["A♠","A♥","9♦"]), hand_value(["A♠","A♥","K♦"]), hand_value(["K♠","Q♥","J♦"]))
and or aces > 0 aces == 0 10 11
沒有 aces > 0 的話,K♠ Q♥ J♦(30 點、沒有 A)會被一路減到 0。
一張 A 從 11 變 1,總點數就是 減 10,同時「還能改的 A」也少一張。
條件要兩個同時成立:還在爆牌,而且還有 A 可以改。
def hand_value(hand): values = {"A":11, "2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "10":10, "J":10, "Q":10, "K":10} total = 0 aces = 0 for card in hand: rank = card[:-1] total = total + values[rank] if rank == "A": aces = aces + 1 while total > 21 and aces > 0: total = total - 10 aces = aces - 1 return total
最後一筆很重要

["K♠","Q♥","J♦"] 要得到 30,不是 0。
因為 aces 是 0,while 一次都不會跑 —— 沒有 A 就不該亂減。

熱身

寫 show_hand 之前:把 list 印成一句話

直接 print 一個 list 很醜

hand = ["A♠", "K♥"]
print(hand)              # ['A♠', 'K♥'] ← 中括號跟引號都跑出來了

用迴圈把它接成一個字串

hand = ["A♠", "K♥"]
text = ""
for card in hand:
    text = text + card + " "

print(text)              # A♠ K♥ 

再用 f-string 排版

name = "你"
print(f"{name}:{text}({hand_value(hand)} 點)")
# 你:A♠ K♥ (21 點)
show_hand — 步驟 1/2

基本版:印出名字、手牌、點數

# 呼叫方式: show_hand("你", ["A♠", "K♥"]) show_hand("莊家", ["7♦", "5♣", "9♠"]) # 預期輸出: 你:A♠ K♥ (21 點) 莊家:7♦ 5♣ 9♠ (21 點)
def show_hand(name, hand): text = "" for card in hand: text = print(f"{name}:{text}({} 點)") show_hand("你", ["A♠", "K♥"]) show_hand("莊家", ["7♦", "5♣", "9♠"])
text + card + " " card + " " text + " " hand_value(hand) hand_value total
少了前面的 text +,每一圈都會把之前接好的字蓋掉,最後只剩最後一張牌。
收兩個參數:namehand。先用迴圈把手牌接成一個字串,再用 f-string 一次印出來。
點數直接呼叫剛剛寫好的 hand_value(hand),不用重寫一次 —— 函式可以呼叫函式。
def show_hand(name, hand): text = "" for card in hand: text = text + card + " " print(f"{name}:{text}({hand_value(hand)} 點)") show_hand("你", ["A♠", "K♥"]) show_hand("莊家", ["7♦", "5♣", "9♠"])
show_hand — 步驟 2/2

加上「蓋一張牌」

加參數 hide_second,預設 False。如果是 True,只顯示第一張,其他用 ? 代替,而且不能洩漏點數

# 呼叫方式: show_hand("莊家", ["7♦", "J♣"], True) show_hand("莊家", ["7♦", "J♣"]) # 預期輸出: 莊家:7♦ ? 莊家:7♦ J♣ (17 點)
def show_hand(name, hand, ): if : print(f"{name}:{hand[0]} ?") else: text = "" for card in hand: text = text + card + " " print(f"{name}:{text}({hand_value(hand)} 點)") show_hand("莊家", ["7♦", "J♣"], True) show_hand("莊家", ["7♦", "J♣"])
hide_second=False hide_second=True hide_second hand[0] name
預設值要是 False —— 平常都是全部顯示,只有玩家回合時的莊家才傳 True
if hide_second: 分成兩條路。蓋牌那條只要印 hand[0] 加上 ?不要印點數,不然玩家就偷看到答案了。
def show_hand(name, hand, hide_second=False): if hide_second: print(f"{name}:{hand[0]} ?") else: text = "" for card in hand: text = text + card + " " print(f"{name}:{text}({hand_value(hand)} 點)")
熱身

寫 player_turn 之前:while Truebreak

「一直重複,直到某件事發生」

while True:              # 條件永遠成立 = 無限重複
    ans = input("要繼續嗎?(y/n) ")
    if ans == "n":
        break            # 跳出迴圈
    print("繼續!")
為什麼玩家回合要用 while

因為不知道玩家會抽幾次 —— 可能一次都不抽,也可能連抽五張。
次數不確定的重複用 while;次數確定(像發兩張牌)才用 for

記得

沒有 breakwhile True 會跑到天荒地老。寫下 while True 的同時就先想好怎麼跳出去。

player_turn — 步驟 1/3

先做最笨的版本:一直抽到爆

先不管玩家想不想抽,就一路抽到超過 21 為止。目的是先把迴圈的形狀寫對。

# 呼叫方式: deck = create_deck() hand = [] deal(deck, hand, 2) player_turn(deck, hand) # 預期輸出(例): 你:5♠ 3♥ (8 點) 你:5♠ 3♥ 7♦ (15 點) 你:5♠ 3♥ 7♦ K♣ (25 點) 爆牌了!
def player_turn(deck, hand): : show_hand("你", hand) if hand_value(hand) > 21: print("爆牌了!") deal(deck, hand) deck = create_deck() hand = [] deal(deck, hand, 2) player_turn(deck, hand)
while True for i in range(3) if True break continue return
continue 的話爆牌後不會停,會一直抽到牌堆空掉才出錯。
每一圈做三件事:顯示手牌 → 檢查爆了沒 → 抽一張。爆了就 print 訊息然後 break。
def player_turn(deck, hand): while True: show_hand("你", hand) if hand_value(hand) > 21: print("爆牌了!") break deal(deck, hand)
player_turn — 步驟 2/3

讓玩家自己決定抽或停

input() 問玩家:輸入 h 抽牌、輸入 s 停牌(停牌就跳出迴圈)。

# 預期畫面: 你:5♠ 3♥ (8 點) 要抽牌(h)還是停牌(s)?h 抽到:7♦ 你:5♠ 3♥ 7♦ (15 點) 要抽牌(h)還是停牌(s)?s 你選擇停牌。
def player_turn(deck, hand): while True: show_hand("你", hand) if hand_value(hand) > 21: print("爆牌了!") break choice = ("要抽牌(h)還是停牌(s)?") if choice == "h": deal(deck, hand) print(f"抽到:{hand[]}") else: print("你選擇停牌。") deck = create_deck() hand = [] deal(deck, hand, 2) player_turn(deck, hand)
input print str -1 0 1 break continue
剛抽到的那張是手牌的最後一張,所以是 hand[-1]
把「無條件 deal」改成 if choice == "h": 才 deal,else: 就 break。
剛抽到的那張是手牌的最後一張 → hand[-1]
def player_turn(deck, hand): while True: show_hand("你", hand) if hand_value(hand) > 21: print("爆牌了!") break choice = input("要抽牌(h)還是停牌(s)?") if choice == "h": deal(deck, hand) print(f"抽到:{hand[-1]}") else: print("你選擇停牌。") break
player_turn — 步驟 3/3

補上:剛好 21 點就不用再問了

如果總點數剛好等於 21,直接印出恭喜並結束回合 —— 再抽一定爆,沒有必要問。

# 預期畫面: 你:A♠ K♥ (21 點) Blackjack! # 三種結束方式: # 1. 爆牌 2. 剛好 21 3. 玩家自己選停牌
def player_turn(deck, hand): while True: show_hand("你", hand) total = hand_value(hand) if total > 21: print("爆牌了!") break if total 21: print("Blackjack!") break choice = input("要抽牌(h)還是停牌(s)?") if choice == "h": deal(deck, hand) else: break deck = create_deck() hand = [] deal(deck, hand, 2) player_turn(deck, hand)
== >= > =
= 是「把 21 存進 total」,不是比較,Python 會直接報語法錯誤。
在爆牌判斷旁邊再加一個 if hand_value(hand) == 21:,印訊息後 break。
建議先用一個變數 total = hand_value(hand) 接起來,這樣同一圈就不用重算三次。
def player_turn(deck, hand): while True: show_hand("你", hand) total = hand_value(hand) if total > 21: print("爆牌了!") break if total == 21: print("Blackjack!") break choice = input("要抽牌(h)還是停牌(s)?") if choice == "h": deal(deck, hand) print(f"抽到:{hand[-1]}") else: print("你選擇停牌。") break
dealer_turn

莊家回合:沒得選,未滿 17 就抽

莊家比玩家簡單 —— 不用問,規則直接寫成迴圈條件就好。

# 呼叫方式: dealer_turn(deck, dealer_hand) # 預期畫面: --- 莊家翻牌 --- 莊家:7♦ 5♣ (12 點) 莊家抽了:4♠ 莊家:7♦ 5♣ 4♠ (16 點) 莊家抽了:K♥ 莊家:7♦ 5♣ 4♠ K♥ (26 點) 莊家爆牌了!
def dealer_turn(deck, hand): print("--- 莊家翻牌 ---") show_hand("莊家", hand) while hand_value(hand) : deal(deck, hand) show_hand("莊家", hand) if hand_value(hand) > 21: print("莊家爆牌了!") deck = create_deck() hand = [] deal(deck, hand, 2) dealer_turn(deck, hand)
< > <= 17 21 16
莊家沒有選擇權,規則直接寫進 while 條件就好,不需要 break
這裡不用 while True!因為結束條件很明確:「點數到 17 就停」。
直接把規則寫進條件:while hand_value(hand) < 17:,不用 break。
def dealer_turn(deck, hand): print("--- 莊家翻牌 ---") show_hand("莊家", hand) while hand_value(hand) < 17: deal(deck, hand) print(f"莊家抽了:{hand[-1]}") show_hand("莊家", hand) if hand_value(hand) > 21: print("莊家爆牌了!")
比較一下

玩家回合要 while Truebreak,因為「何時結束」由人決定;
莊家回合可以直接把規則寫進 while 條件,因為結束時機是算得出來的

熱身

寫 judge 之前:if/elif 的順序會影響結果

elif 只有在前面全部不成立時才會檢查

score = 95
if score >= 60:
    print("及格")        # ← 這裡就成立了
elif score >= 90:
    print("優等")        # ← 永遠跑不到!

# 輸出:及格
對 judge 的意義

「玩家爆牌」必須最先判斷
因為玩家 25 點、莊家 20 點時,如果先比大小就會判玩家贏 —— 但他早就爆了。

judge

判斷勝負:五種情況

先想清楚順序,再寫 if/elif。

# 呼叫方式: judge(["K♠","Q♥","5♦"], ["10♣","9♠"]) # 玩家 25 爆 judge(["K♠","9♥"], ["10♣","5♠","K♦"]) # 莊家 25 爆 judge(["K♠","9♥"], ["10♣","8♠"]) # 19 vs 18 judge(["K♠","8♥"], ["10♣","8♠"]) # 18 vs 18 # 預期輸出: 你:25 點 vs 莊家:19 點 你爆牌了,莊家贏! ...
def judge(player_hand, dealer_hand): p = hand_value(player_hand) d = hand_value(dealer_hand) print(f"你:{p} 點 vs 莊家:{d} 點") if p : print("你爆牌了,莊家贏!") d > 21: print("莊家爆牌,你贏了!") elif : print("你贏了!") elif d > p: print("莊家贏!") : print("平手!") judge(["K♠","Q♥","5♦"], ["10♣","9♠"]) judge(["K♠","8♥"], ["10♣","8♠"])
> 21 < 21 elif if p > d d > p else
「玩家爆牌」一定要最先判斷。順序顛倒的話,玩家 25 點對莊家 20 點會被判玩家贏。
判斷順序:① 玩家爆了嗎 → ② 莊家爆了嗎 → ③ 玩家比較大 → ④ 莊家比較大 → ⑤ 剩下的就是平手
先把兩邊點數各存成一個變數,程式會好讀很多。
def judge(player_hand, dealer_hand): p = hand_value(player_hand) d = hand_value(dealer_hand) print("--- 結算 ---") print(f"你:{p} 點 vs 莊家:{d} 點") if p > 21: print("你爆牌了,莊家贏!") elif d > 21: print("莊家爆牌,你贏了!") elif p > d: print("你贏了!") elif d > p: print("莊家贏!") else: print("平手!")
階段四

組裝與驗收

七個零件都做好了,現在把它們接起來

組裝

主程式:回頭看階段一的流程

還記得那六個步驟嗎?現在一步對一行程式。

print("歡迎來到 21 點!") deck = player_hand = [] dealer_hand = [] deal(deck, dealer_hand, 2) show_hand("莊家", dealer_hand, ) player_turn(deck, player_hand) if hand_value(player_hand) : dealer_turn(deck, dealer_hand)
create_deck() create_deck deal(deck, player_hand, 2) deal(player_hand, 2) True False <= 21 > 21 judge(player_hand, dealer_hand) judge()
第 5 步是唯一要動腦的:玩家已經爆牌的話,莊家根本不用抽
第 5 步是唯一要動腦的:玩家已經爆牌的話,莊家根本不用抽(反正贏定了)。
條件寫 if hand_value(player_hand) <= 21:
print("歡迎來到 21 點!") deck = create_deck() player_hand = [] dealer_hand = [] deal(deck, player_hand, 2) deal(deck, dealer_hand, 2) show_hand("莊家", dealer_hand, True) player_turn(deck, player_hand) if hand_value(player_hand) <= 21: dealer_turn(deck, dealer_hand) judge(player_hand, dealer_hand)
主程式只有十行

讀起來就跟階段一的中文流程一模一樣。
複雜的東西都被關在函式裡面了 —— 這就是為什麼要拆函式。

完整程式碼

全部接起來

import random def create_deck(): suits = ["♠", "♥", "♦", "♣"] ranks = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"] deck = [] for s in suits: for r in ranks: deck.append(r + s) random.shuffle(deck) return deck def deal(deck, hand, n=1): for i in range(n): hand.append(deck.pop()) def hand_value(hand): values = {"A":11, "2":2, "3":3, "4":4, "5":5, "6":6, "7":7, "8":8, "9":9, "10":10, "J":10, "Q":10, "K":10} total = 0 aces = 0 for card in hand: rank = card[:-1] total = total + values[rank] if rank == "A": aces = aces + 1 while total > 21 and aces > 0: total = total - 10 aces = aces - 1 return total def show_hand(name, hand, hide_second=False): if hide_second: print(f"{name}:{hand[0]} ?") else: text = "" for card in hand: text = text + card + " " print(f"{name}:{text}({hand_value(hand)} 點)") def player_turn(deck, hand): while True: show_hand("你", hand) total = hand_value(hand) if total > 21: print("爆牌了!") break if total == 21: print("Blackjack!") break choice = input("要抽牌(h)還是停牌(s)?") if choice == "h": deal(deck, hand) print(f"抽到:{hand[-1]}") else: print("你選擇停牌。") break def dealer_turn(deck, hand): print("--- 莊家翻牌 ---") show_hand("莊家", hand) while hand_value(hand) < 17: deal(deck, hand) print(f"莊家抽了:{hand[-1]}") show_hand("莊家", hand) if hand_value(hand) > 21: print("莊家爆牌了!") def judge(player_hand, dealer_hand): p = hand_value(player_hand) d = hand_value(dealer_hand) print("--- 結算 ---") print(f"你:{p} 點 vs 莊家:{d} 點") if p > 21: print("你爆牌了,莊家贏!") elif d > 21: print("莊家爆牌,你贏了!") elif p > d: print("你贏了!") elif d > p: print("莊家贏!") else: print("平手!") # === 主程式 === print("歡迎來到 21 點!") deck = create_deck() player_hand = [] dealer_hand = [] deal(deck, player_hand, 2) deal(deck, dealer_hand, 2) show_hand("莊家", dealer_hand, True) player_turn(deck, player_hand) if hand_value(player_hand) <= 21: dealer_turn(deck, dealer_hand) judge(player_hand, dealer_hand)
驗收

自己檢查:這些情況都對嗎?

寫完程式不是結束,要拿這些情境去試。這叫「驗收」。

  • ☐ 開局時莊家只看得到一張牌,而且看不到點數
  • ☐ 一直按 h 抽牌,超過 21 會顯示爆牌並停止
  • ☐ 拿到 A + K 會顯示 21 點,不是 12 點
  • ☐ 拿到 A + A + 9 會顯示 21 點,不是 31 或 11
  • ☐ 拿到 K + Q + J 會顯示 30 點(沒有 A 不能亂減)
  • ☐ 玩家爆牌後,莊家不會再抽牌
  • ☐ 莊家 16 點會繼續抽,17 點會停
  • ☐ 兩邊點數一樣時顯示平手
  • ☐ 牌堆張數會隨著發牌變少(52 → 48 …)
技巧

要測 A 的情況很難靠運氣抽到。可以暫時把手牌寫死:
player_hand = ["A♠", "A♥", "9♦"],測完再改回來。

階段五

需求變更

「做好了嗎?我想再加一個功能。」

變更 1

客戶:「我想玩很多局,還要記分」

一局結束後問要不要再來一局,並記錄贏了幾局、輸了幾局。

七個函式做的都是「一局之內」的事,多玩幾局完全不影響它們 —— 主程式外面包一層 while 就好。
這就是把函式切乾淨的回報:需求改了,但大部分程式碼一個字都不用動。
如果當初全部寫成一大坨,現在就得整段翻掉重寫。
變更 2

客戶:「玩家要能下注籌碼」

開局前先輸入賭注,贏了加、輸了扣,平手不變。

現在的 judge 只有 print,結果沒有交回給程式,所以主程式無從加減籌碼。
把它改成 return "win" / "lose" / "draw",主程式就能接手處理錢的事。
錢的邏輯不該塞進 hand_value 或 deal —— 一個函式只做一件事,混在一起以後誰都不敢改。
動手做

把變更 1 實作出來

讓遊戲可以一直玩下去,離開時顯示戰績。

# 預期畫面: === 第 1 局 === (一局遊戲…) 還要再玩一局嗎?(y/n) y === 第 2 局 === (一局遊戲…) 還要再玩一局嗎?(y/n) n 總共玩了 2 局,你贏 1 局、輸 1 局、平手 0 局
def judge(p_hand, d_hand): p = hand_value(p_hand) d = hand_value(d_hand) if p > 21: print("你爆牌了,莊家贏!") elif d > 21: print("莊家爆牌,你贏了!") elif p > d: print("你贏了!") return "win" elif d > p: print("莊家贏!") return "lose" else: print("平手!") print("回傳值:", judge(["K♠","Q♥","5♦"], ["10♣","9♠"]))
return "lose" return "win" return "draw" print("lose") break
關鍵改動是 print 改成 return —— 結果要交回給主程式,主程式才有辦法加減籌碼或記分。
兩件事:① judge 最後加上 return "win" 之類的字串;② 主程式外面包 while True,用三個變數記次數。
計分變數要放在 while 外面,放裡面每局都會被歸零。
# judge 改成有回傳值(前面 print 的部分不變) def judge(player_hand, dealer_hand): p = hand_value(player_hand) d = hand_value(dealer_hand) print("--- 結算 ---") print(f"你:{p} 點 vs 莊家:{d} 點") if p > 21: print("你爆牌了,莊家贏!") return "lose" elif d > 21: print("莊家爆牌,你贏了!") return "win" elif p > d: print("你贏了!") return "win" elif d > p: print("莊家贏!") return "lose" else: print("平手!") return "draw" # === 主程式 === win = 0 lose = 0 draw = 0 round_num = 1 while True: print(f"=== 第 {round_num} 局 ===") deck = create_deck() player_hand = [] dealer_hand = [] deal(deck, player_hand, 2) deal(deck, dealer_hand, 2) show_hand("莊家", dealer_hand, True) player_turn(deck, player_hand) if hand_value(player_hand) <= 21: dealer_turn(deck, dealer_hand) result = judge(player_hand, dealer_hand) if result == "win": win = win + 1 elif result == "lose": lose = lose + 1 else: draw = draw + 1 again = input("還要再玩一局嗎?(y/n) ") if again != "y": break round_num = round_num + 1 print(f"總共玩了 {win + lose + draw} 局,你贏 {win} 局、輸 {lose} 局、平手 {draw} 局")
延伸挑戰

還想再進化?

挑戰 A:下注系統

# 一開始有 100 籌碼,每局下注前先問要押多少
# 贏了加、輸了扣,籌碼歸零就結束遊戲

挑戰 B:輸入防呆

# 玩家輸入 h/s 以外的東西時,重問一次而不是直接當成停牌
# 提示:用 while 或 continue

挑戰 C:牌堆用完怎麼辦

# 玩很多局之後牌堆會發完 → deck.pop() 會出錯
# 想想看要在哪裡檢查?要怎麼處理?

挑戰 D:Blackjack 特別獎金

# 開局兩張就 21 點(A + 10/J/Q/K)叫 Blackjack
# 賠率 1.5 倍。這要改哪個函式?

21 點完成!

你不只寫出了一個遊戲,還走完了一次完整的開發流程:
看懂需求 → 設計介面 → 分段實作 → 驗收 → 應付需求變更

選擇題:0 對 / 0