トランプゲーム「ブラックジャック」を F# で実装してみました。設計上の判断を述べながら実装をみていきたいと思います。
実装全体は GitHub で1ファイルにまとめています: 2020-02-09-fsharp-blackjack/Program.fs
文脈
「プログラミング入門者からの卒業試験は『ブラックジャック』を開発すべし」によると、ブラックジャックはプログラミング入門者の卒業試験としてうってつけの題材だそうです。
さまざまな言語による実装例が報告されていますが、F# 版はまだなさそうなので、やってみました。
カードの型
機能ごとに書いたので、記事も機能ごとに書くことにします。トランプカードの定義からはじめます。
ランク (カードの数字) はブラックジャックにおいて重要です。特に A/JQK/その他 は明確に区別したいので、判別共用体として定義するのがよさそうです。
type Rank =
| Ace
/// 2〜10
| Rank of rank:int
| Jack
| Queen
| King
デックを作るときのために、すべての種類のランクを列挙する関数も用意しておきます。(2〜10 のところは for 式を使ってもいいかも。)
let allRanks () =
[
Ace
Rank 2
Rank 3
Rank 4
Rank 5
Rank 6
Rank 7
Rank 8
Rank 9
Rank 10
Jack
Queen
King
]
一方、ランクと違ってスート (カードの柄) はいまのところルール上意味がありません。おそらく string で十分でしょう。F# は型検査があるので、後から変更するのも難しくありません。
type Suit = string
let allSuits () =
[
"スペード"
"クローバー"
"ハート"
"ダイヤ"
]
ランクとスートを用意したので、トランプカードの型を定義できます。ジョーカーは使わないので、カードはランクとスートのペアです。タプルでもいいし、レコードでもいいし、判別共用体でもいいです。今回は私の趣味で、型推論と相性がよさげな判別共用体にしました。
type Card =
| Card of suit:Suit * rank:Rank
全種類のカードを列挙する関数を定義すれば完了です。リスト内包表記は便利ですね。
let allCards () =
[
for suit in allSuits () do
for rank in allRanks () do
Card (suit, rank)
]
なお、カードの名前を文字列にする関数は自明なので省略しています。
デック
次はデック (カードの束) です。デックはシャッフル操作やドロー操作を持つので、動的配列 ResizeArray<T>
で表しておくと都合がよさそうです。
(.NET 勢に向けていうと、ResizeArray<T>
は System.Collections.Generic.List<T>
の F# における別名です。)
type Deck = ResizeArray<Card>
すべてのカードを生成してデックに入れ、シャッフルしたものを初期デックとします。シャッフルの実装は省略。(フィッシャーイェーツのアルゴリズム)
let generateDeck () =
let deck = ResizeArray(allCards ())
shuffleList deck
deck
カードドローは配列末尾の要素を取り出せばいいです。デックが尽きたケースが気になりますが、現在の仕様では尽きないので、とりあえず assert
で明記するだけにしておきます。
let drawFromDeck (deck: Deck) =
// 現在の実装では、カードが尽きることはない。
assert (deck.Count >= 1)
let last = deck.Count - 1
let card = deck.[last]
deck.RemoveAt(last)
card
手札と範囲検査
デックからプレイヤーに配られたカードの集まりを 手札 (hand) と呼ぶことにします。(場札の方がよい?)
手札の型を定義します。(いま書いてて気づきましたが、デックと同じ型になってしまっているので、判別共用体にした方がよかったかもしれません。)
type Hand = ResizeArray<Card>
手札の生成やカードの追加は ResizeArray
のコンストラクタおよび Add メソッドを使うだけですが、別途、関数にしておきます。ゲームの実装時に、「動的配列にカードを加える」のではなく「手札にカードを加える」という表現をしたいからです。手札の型を変更したときのためでもあります。
let newHand (): Hand = ResizeArray()
let addToHand card (yourHand: Hand) =
yourHand.Add(card)
ルールによると、ディーラーの初手は片方が裏向きであり、ディーラーの手番の開始時にそれを公開する部分があります。プレイヤーの手札とちょっと扱いが違う匂いがしますね。実装を考えてみると、初期手札が与えられたとき、裏向きのカードは2枚目なので……
let dealersInitialHandToFacedDownCard (dealerHand: Hand) =
hand.[1]
ところで、配列のインデックスアクセスはバグの温床 として私の中で有名です。
さきほど、デックからカードをドローする処理を書きましたが、そこでは Count - 1
という明らかに配列の長さを超えない式を使っていて、Count >= 1
も関数内でちゃんと確かめています。さすがに大丈夫そうです。
でも dealersInitialHandToFacedDownCard
は関数を呼ぶ側が気をつけないと範囲外エラーが出ます。 インデックスアクセス安全性の責任が漏れている のです。嫌な匂いです。(まあ、こんな小さいプログラムでは実害はないかもしれませんが……)
そういうわけで、「ディーラーの初手」をただの「手札」と区別しておきます。
type DealerHand =
| DealerHand of facedUp:Card * facedDown:Card
裏向きのカードを取る関数は型安全になります。
let dealerHandToFacedDownCard dealerHand =
match dealerHand with
| DealerHand (_, card) ->
card
裏向きのカードの公開が終わったら「初手」ではなく、ただの手札になるので、Hand への変換もいります。
let dealerHandToHand dealerHand =
match dealerHand with
| DealerHand (card1, card2) ->
let cards = [card1; card2] // リストの区切りはセミコロンなので注意!!
ResizeArray(cards)
スコア計算
エースの処理で頭を悩ませがちな点数計算機能です。
仕様上、エースを含む手札のスコアは「エース以外の和を計算した後に、エース1枚につき +11、ただしバストするなら代わりに +1」とします。スコアが複数あるとは考えません。
やや天下りですが、カードの「価値」(Value)という概念を次のように定めます。
- 2〜10 の価値はそれぞれの数字
- J/Q/K の価値は 10
- エースの価値は $α$ (整数ではない定数)
カードの価値の和は $p + qα$ と書けます。($α$ を虚数単位 $i$ みたいに扱って計算。)
実装を見ていきましょう。価値を判別共用体で定義します。
type Value =
| Value of baseValue:int * aceCount:int
// カードの価値のゼロ
let noValue () =
Value (0, 0)
// カードの価値の和
let addValue first second =
match first, second with
| Value (firstBase, firstAce),
Value (secondBase, secondAce) ->
Value (firstBase + secondBase, firstAce + secondAce)
カード (ランク) の価値の定義は上に書いたことを F# に翻訳すればよいです。
let rankToValue rank =
match rank with
| Ace ->
Value (0, 1)
| Rank n ->
Value (n, 0)
| Jack
| Queen
| King ->
Value (10, 0)
手札の価値はカードの価値の総和で定義されます。
let handToScore (hand: Hand) =
let mutable value = noValue ()
for card in hand do
value <- addValue value (cardToValue card)
evaluate value
さて、手札→価値 ができたので、次は 価値→スコア です。手札の価値は、手札に含まれる「エース以外の和」と「エースの枚数」に分解できるので、最初に決めた仕様どおりの計算を書けます。
let scoreIsBust score =
score > 21
/// いまのスコアが score のときのエース1枚の価値を評価する。
let evaluateAce score =
if scoreIsBust (score + 11) then 1 else 11
let evaluate value =
match value with
| Value (baseValue, aceCount) ->
let mutable sum = baseValue
for _ in 1..aceCount do
sum <- sum + evaluateAce sum
sum
勝敗
いいたいことがだいたい書いたので、あとは駆け足で。
勝敗は判別共用体で表せます。
type GameResult =
| YouWin
| YouLose
勝敗は互いの手札で判定します。
let handsToGameResult yourHand dealersHand =
let yourScore = handToScore yourHand
let dealersScore = handToScore dealersHand
let youWin =
scoreIsBust dealersScore || (
not (scoreIsBust yourScore)
&& yourScore > dealersScore
)
if youWin then YouWin else YouLose
ディーラーの思考
ディーラーが取るアクションは「ヒットするか?」という bool でもいいですが、判別共用体にした方が明確な気がします。
type DealerAction =
| DealerHitAction
| DealerStandAction
let scoreToDealerAction (score: int) =
assert (not (scoreIsBust score))
if score <= 16 then
DealerHitAction
else
DealerStandAction
ゲーム進行
部品が揃いました。ようやくゲームを実装できます。
おそらく F# においてシンプルな方法は、ゲーム上の各場面をそれぞれ関数として定義して、相互再帰することです。例えば、ゲーム開始時に gameStart
関数を呼ぶことにします。内容は、デックの生成や初手のディールを行い、次にプレイヤーのアクションフェイズに入る、という流れです。
let gameStart () =
// 略
let deck = generateDeck ()
let yourHand = newHand ()
youHit deck yourHand
youHit deck yourHand
let facedUp = drawFromDeck deck
let facedDown = drawFromDeck deck
let dealersHand = DealerHand (facedUp, facedDown)
// 略
doYourActionPhase (deck, yourHand, dealersHand)
アクションフェイズ (ヒットするか選択するフェイズ) は、ヒットする限り繰り返されます。ここではループではなく再帰を使って「ヒットしたら、またアクションフェイズを行う」という表現にしています。「また」の部分が F# 上で表現されないため、ループになっていることが分かりにくい、というのは欠点かもしれません。
let doYourActionPhase (deck, yourHand, dealersHand) =
let score = handToScore yourHand
// 略
if scoreIsBust score then
// 略
gameEnd YouLose
else
printfn "ヒットしますか? (カードを引くなら Y、引かないなら N)"
if not (confirm ()) then
doDealerOpenPhase (deck, yourHand, dealersHand)
else
youHit deck yourHand
doYourActionPhase (deck, yourHand, dealersHand)
なお if/else の構造が分かりにくいかもしれませんが、これは early リターンの代わりです。F# には return
文がないので、early リターンはできませんが、else の中身を字下げしないことで字下げが深くなる問題を回避しています。
if 条件 then
真のときの処理
else
偽のときの処理
ゲームの処理の残りは、ゴリゴリと書いていくだけなので略。gameEnd
関数が呼ばれたら終わりです。
let gameEnd result =
match result with
| YouWin ->
printfn "あなたの勝ちです! おめでとう🎉"
| YouLose ->
printfn "あなたの負けです。どんまい♪"
printfn "ブラックジャック終了! また遊んでね★"
おわりに
当初の想定より、もりだくさんな内容の記事になりました。たしかに、ブラックジャックの実装は面白い題材のようです。