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

Elmでブラックジャックを実装してみた

More than 1 year has passed since last update.

はじめに

前回に引き続き、今度はElmでブラックジャックを実装してみます。
元はと言えばこの記事に影響されてやろうと思ったものでした。
今度こそQiita内には先駆者はいないはずです・・・

私のElm歴は半年で、しかもあまり書いていないのでとてもレベルが低いと思います。
Elmerな方々にはぜひアドバイスなどいただければと思います。

一部解説している順番と実際にコードに書かれている順番は異なります。ご了承ください。

完成品は一応以下にあります。
https://github.com/IamKeck/elm-blackjack

動作イメージ

elm-blackjack.gif

もうちょっと見た目こだわれよって感じですね。

プログラムの中身

モジュール構成

Model.elm、Update.elm、View.elm、Main.elmというわけ方をしました。
Model.elmにはModelとMsgを、Update.elmにはupdateを、View.elmにはviewを、
Main.elmにはmainを定義しています。そのまんまです。

Model

まずカード周りから定義します。

-- Card


type Suit
    = Spade
    | Heart
    | Club
    | Diamond


type alias Card =
    { suit : Suit, number : Int }

例によって例のごとく(前回もそうだった)、数字が14だったり0だったりするカードを定義できてしまいますがまあ・・・

デッキを定義します。Haskellならリストモナドを使って簡単にスートリストと数字リストを組み合わせることができました。
Elmの|> List.concatMapがHaskellの>>=に対応している気がするので、Elmではこれを使うのでしょう。
ただ私は、Appricativeのap <*>を使うとスマートにできるのではないかと思ったので、この際演算子を定義してみました。

(<*>) : List (a -> b) -> List a -> List b
(<*>) fs xs =
    List.concatMap (\f -> List.map f xs) fs
infixl 4 <*>

ついでに<$>も定義して

(<$>) : (a -> b) -> List a -> List b
(<$>) =
    List.map
infixl 4 <$>

デッキは以下のように定義することができました。 Cool!

deck : List Card
deck =
    Card <$> [ Spade, Heart, Club, Diamond ] <*> (List.range 1 13)

・・・
まあ今回は実務コードでもなんでもないので許してください・・・

得点計算用に、Card -> Intな関数を定義します。
A=1だったりA=11だったりするので2つ定義します。 この辺は前回同様です。

cardToPointAceAsOne : Card -> Int
cardToPointAceAsOne c =
    if c.number > 10 then
        10
    else
        c.number


cardToPointAceAsEleven : Card -> Int
cardToPointAceAsEleven c =
    if c.number == 1 then
        11
    else
        cardToPointAceAsOne c

さっきの関数とList Cardを受け取って総得点を計算する関数を定義します。
まだバースト(21点を超えてしまい失格になること)の判定はしません。

calcPoint : (Card -> Int) -> List Card -> Int
calcPoint f cs =
    List.map f cs |> List.sum

List Cardを受け取って、バースト判定込みで総得点を計算する関数を定義します。
バーストをNothingで表現したいため、Maybeを使います

calcValidPoint : List Card -> Maybe Int
calcValidPoint cs =
    let
        high =
            calcPoint cardToPointAceAsEleven cs

        low =
            calcPoint cardToPointAceAsOne cs
    in
        if low > 21 then
            Nothing
        else if high > 21 then
            Just low
        else
            Just high

デッキをシャッフルする処理を書いていきます。
今回もこの記事を参考にします。

簡単にいうと、["Alice", "Bob", "Charlie"]というリストがあるとき、同じ長さの乱数値リスト[2,3,1]を作成し、zipします。
[("Alice", 2), ("Bob", 3), ("Charlie", 1)]というリストができるので、リストの値のsndでソート(sortOnとかsortBy)し、map fstして余計な乱数値数列を取り除きます。

ただし、Elmで全ての数がユニークな乱数値数列を作るのは面倒そうでした。
ユニークにするのは諦める代わりに、数値の範囲を1 ~ 5200にしてなるべく値が被らないようにしました。
この辺説明不足ですね。

-- デッキと乱数値数列を受け取り、シャッフル済みのデッキを返す
shuffleCards : List Card -> List Int -> List Card
shuffleCards cs xs =
    List.map2 (,) cs xs |> List.sortBy Tuple.second |> List.map Tuple.first

-- ソート用乱数値数列を作るGenerator
sortListGenerator : List Card -> Random.Generator (List Int)
sortListGenerator cs =
    let
        cs_l =
            List.length cs
    in
        cs_l * 100 |> Random.int 0 |> Random.list cs_l

カードから一旦離れます。

ゲーム結果を定義します。 バーストと純粋な得点での勝敗は一応分けました。 念のためErrorという値も設定しています。

type Result
    = YouWin
    | DealerWins
    | YouBust
    | DealerBust
    | Draw
    | Error

ゲーム状態を定義します

type GameStatus
    = Title -- 起動直後
    | UsersTurn -- ユーザーが行動を選択するフェーズ
    | Over --結果表示

モデル本体はこんな感じでしょうか。
deckは山札を、playerやdealerは各々の手札を表します。

type alias Model =
    { deck : List Card
    , player : List Card
    , dealer : List Card
    , status : GameStatus
    , result : Maybe Result
    , playersPoint : Maybe Int
    , dealersPoint : Maybe Int
    }

初期モデルもついでに定義します

initialModel =
    { deck = []
    , player = []
    , dealer = []
    , status = Title
    , result = Nothing
    , playersPoint = Nothing
    , dealersPoint = Nothing 
    }

Msgも定義します
Hitはカードを引く行動、Standは現在の手札で勝負する行動を示します。
GotShuffleListは先ほどのソート用乱数を受け取った時のMsgです。

type Msg
    = GameStart
    | Hit
    | Stand
    | GotShuffleList (List Int)

最後に、勝敗判定を行う関数を定義します。

judge : Model -> Result
judge m =
    case ( m.playersPoint, m.dealersPoint ) of
        ( Nothing, dp ) ->
            DealerWins

        ( yp, Nothing ) ->
            YouWin

        ( Just yp, Just dp ) ->
            if yp == dp then
                Draw
            else if yp > dp then
                YouWin
            else
                DealerWins

Update

さてupdate関数を・・・といきたいところですが、まずディーラーの行動フェイズを定義します。
ディーラーはユーザーがStandした後、点数が17点以上になるまでカードを引き続けます。
Modelを丸ごと作り直すので、Update側に書いた方がいいかなあと思いました。

dealersTurn : Model.Model -> Model.Model
dealersTurn m =
    case m.dealersPoint of
        Nothing ->
            m

        Just p ->
            if p < 17 then
                case m.deck of
                    nc :: nd ->
                        dealersTurn
                            { m
                                | dealer = nc :: m.dealer
                                , deck = nd
                                , dealersPoint =
                                    Model.calcValidPoint (nc :: m.dealer)
                            }

                    [] ->
                        m
            else
                m

繰り返し部分を再帰にしていますが、コンパイル後のjsファイルを見るとうまく末尾再帰最適化されているようでした。

ではでかいupdate関数の中身に行きます。
冒頭3行は以下の通りです。

update : Model.Msg -> Model.Model -> ( Model.Model, Cmd Model.Msg )
update msg model =
    case msg of

以下caseの各branchについて書いていきます。

まずはGameStartです。「開始」ボタンやゲーム終了後の「再挑戦」を押した際にメッセージが発火されます。

        Model.GameStart ->
            let
                cmd =
                    Model.sortListGenerator Model.deck |> Random.generate Model.GotShuffleList

                nm =
                    { model | player = [], dealer = [], result = Nothing }
            in
                ( nm, cmd )

デッキソート用乱数をCmdで要求し、プレイヤーやディーラーの手札を初期化、結果も初期化しています。

次に、ソート用乱数の受け取りMsgです

            let
                newDeck =
                    Model.shuffleCards Model.deck xs
            in
                case newDeck of
                    a :: b :: c :: d :: xs ->
                        let
                            dp =
                                Model.calcValidPoint [ a, c ]

                            pp =
                                Model.calcValidPoint [ b, d ]

                            result =
                                if (dp == Just 21) && (pp == Just 21) then
                                    Just Model.Draw
                                else if pp == Just 21 then
                                    Just Model.YouWin
                                else if dp == Just 21 then
                                    Just Model.DealerWins
                                else
                                    Nothing

                            status =
                                case result of
                                    Just _ ->
                                        Model.Over

                                    Nothing ->
                                        Model.UsersTurn

                            player =
                                d :: b :: model.player

                            dealer =
                                a :: c :: model.dealer
                        in
                            ( { model
                                | deck = xs
                                , status = status
                                , result = result
                                , player = player
                                , dealer = dealer
                                , playersPoint = Model.calcValidPoint player
                                , dealersPoint = Model.calcValidPoint dealer
                              }
                            , Cmd.none
                            )

                    _ ->
                        ( { model | status = Model.Over, result = Just Model.Error }, Cmd.none )

一見長いのですが、elmのこのコーディングスタイル(elm-formatのもの)でそう見えるだけで、
そこまで複雑ではないんですよね。
初期山札をシャッフルした後、ディーラーとプレイヤーに各々2枚ずつカードを配っています。
この初回手札だけで21点になってしまうことがあります。(ナチュラルブラックジャック)
この時はゲームを即終了させるようにします。
それ以外の場合、ゲームはプレイヤーの行動フェイズに移行します。

山札が4枚なかった時の処理を一応書いています。 即終了、結果はエラーとしておきました。
起こり得ないことですが・・・

次はプレイヤーの行動フェイズです。

        Model.Hit ->
            case model.deck of
                newCard :: newDeck ->
                    let
                        playersNewHand =
                            newCard :: model.player

                        newPoint =
                            Model.calcValidPoint playersNewHand

                        ( newStatus, result ) =
                            case newPoint of
                                Nothing ->
                                    ( Model.Over, Just Model.YouBust )

                                Just _ ->
                                    ( model.status, Nothing )
                    in
                        { model
                            | player = playersNewHand
                            , playersPoint = newPoint
                            , deck = newDeck
                            , status = newStatus
                            , result = result
                        }
                            ! []

                [] ->
                    ( { model | status = Model.Over, result = Just Model.Error }, Cmd.none )

カードを引いて、得点計算をしています。 バーストしていたら即ゲーム終了です。
そうでなければ引き続きプレイヤーの行動フェイズです。
また山札がなくなっていた場合の処理を書いています。
本来は山札がなくなった時点でStandするようにすべきなんでしょうが、その処理を追加するのを忘れていました・・・
まあディーラーとプレイヤーの一対一の勝負では起こり得ないので・・・

最後にStandした際の処理です。

        Model.Stand ->
            let
                newModel =
                    dealersTurn model

                result =
                    Model.judge newModel
            in
                ( { newModel
                    | status = Model.Over
                    , result = Just result
                  }
                , Cmd.none
                )

まあ前述のディーラー行動フェイズでModelを更新した後、得点を計算して結果を取得、
ゲーム状態を終了済みにしているだけですね。

Updateは以上です

View

view関数本体に入る前にいくつかヘルパー関数を定義します。

表示のため、カードを文字列表現にします。

showCard : Model.Card -> String
showCard c =
    let
        suit =
            case c.suit of
                Model.Club ->
                    "♣"

                Model.Heart ->
                    "♥"

                Model.Spade ->
                    "♠"

                Model.Diamond ->
                    "♦"

        number =
            case c.number of
                1 ->
                    "A"

                11 ->
                    "J"

                12 ->
                    "Q"

                13 ->
                    "K"

                n ->
                    toString n
    in
        suit ++ " " ++ number

結果によって表示するメッセージを決定します。
英語はガバガバです

showResult : Maybe Model.Result -> String
showResult result =
    case result of
        Just Model.YouWin ->
            "You Win!"

        Just Model.DealerWins ->
            "You Lose!"

        Just Model.YouBust ->
            "You've Busted!"

        Just Model.DealerBust ->
            "The Dealer Has Busted! You Win!"

        Just Model.Draw ->
            "Draw"

        Just Model.Error ->
            "Error!"

        Nothing ->
            "Error!"

ではview関数です。
最初の3行はこの通りです。

view : Model.Model -> Html Model.Msg
view m =
    let

letの各変数(変数でいいんでしょうか)について説明していきます。

上の方に出すプレイヤーへのメッセージです。

        statusText =
            case m.status of
                Model.Title ->
                    "Welcome To Black Jack"

                Model.UsersTurn ->
                    "Black Jack: Your Turn. Hit or Stand"

                Model.Over ->
                    "Black Jack: Game Over" ++ (showResult m.result)

ディーラーの手札として表示する文字列です。
ユーザー行動フェーズは最初の一枚だけ表示させます。
ゲーム終了後は全ての手札を見せます。
それ以外の場合で手札を見せることはないと思うので、""としておきます。

        dealersCards =
            case m.status of
                Model.UsersTurn ->
                    List.reverse m.dealer
                        |> List.head
                        |> Maybe.andThen (showCard >> Just)
                        |> Maybe.withDefault ""
                        |> flip (++) " ?"

                Model.Over ->
                    List.reverse m.dealer |> List.map showCard |> String.join " "

                _ ->
                    ""

プレイヤーの手札です。これは特に分岐などせず普通に表示させます。

        playersCards =
            List.reverse m.player |> List.map showCard |> String.join " "

プレイヤーの得点です。 ModelのplayersPointがNothingの時はバーストしているのですが、"Busted!"とでも表示させておきます。
初期状態でもplayersPointはNothingなので、表示文字列が"Busted"となってしまいます。
まあこれはHtml構築時に表示させないようにします。

        playersPoint =
            Maybe.map toString m.playersPoint |> Maybe.withDefault "Busted!"

ディーラーの点数です。 これも同様です。

        dealersPoint =
            Maybe.map toString m.dealersPoint |> Maybe.withDefault "Busted!"

操作ボタン群です。
これだけHTMLになってしまいました。

        buttons =
            case m.status of
                Model.Title ->
                    [ button [ onClick Model.GameStart ]
                        [ text "Start" ]
                    ]

                Model.UsersTurn ->
                    [ button [ onClick Model.Hit ]
                        [ text "Hit" ]
                    , button [ onClick Model.Stand ]
                        [ text "Stand" ]
                    ]

                Model.Over ->
                    [ button [ onClick Model.GameStart ]
                        [ text "Retry" ]
                    ]

では後半(in部分)です。

    in
        div []
            [ p []
                [ text statusText ]
            , div [ hidden <| m.status == Model.Title ]
                [ p []
                    [ text ("Dealer's Card: " ++ dealersCards) ]
                , p []
                    [ text ("Player's Cards: " ++ playersCards) ]
                , p []
                    [ text ("Player's Point: " ++ playersPoint) ]
                , p [ hidden <| m.status /= Model.Over ]
                    [ text ("Dealer's Point: " ++ dealersPoint) ]
                ]
            , div []
                buttons
            ]

まあまあコンパクトにまとまりました。
Modelの状態によっては表示させたくない部分はhiddenを使っています。正しい使い方なのかはよくわかりませんが。

Main

あとはくっつけるだけです。

init : ( Model.Model, Cmd Model.Msg )
init =
    (Model.initialModel ! [])


subscriptions : Model.Model -> Sub Model.Msg
subscriptions _ =
    Sub.none


main =
    program { init = init, view = View.view, update = Update.update, subscriptions = subscriptions }

おまけ

ビルドは以下のようにnpm scriptで行うようにしました。
test書いてないですねすみません・・・
watchパッケージのおかげでwatchもできます。

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "cd src && elm-make Main.elm --yes --output ../main.js",
    "watch": "watch 'npm run build' ./src -p=/elm-stuff/"
  },

感想など

Viewの書き方これで合ってるんでしょうか・・・ あまり人のコードを読んでいないせいであまり自信がありません。 メインのHTML生成部には余計な式をあまり入れず、スッキリさせたほうがいいんだろうなという気はするのですが・・・
431行も使ってしまいましたが、これはelm-formatのコーディングスタイルの影響も大きいはずです。

あと見た目のやる気がなさすぎるので、もうちょっとグラフィカルにしようと思いました。
せっかくブラウザゲーとして実装してるんだし、せっかくElm使ってるんだし。

ともかく、今度こそ二番煎じ、三番煎じではないはずです!
ご意見、ご指摘等ぜひよろしくお願いします!
ここまでありがとうございました!

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
ユーザーは見つかりませんでした