Help us understand the problem. What is going on with this article?

F# で“卒業試験”ことブラックジャック開発をやってみた

トランプゲーム「ブラックジャック」を 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 "ブラックジャック終了! また遊んでね★"

おわりに

当初の想定より、もりだくさんな内容の記事になりました。たしかに、ブラックジャックの実装は面白い題材のようです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした