はじめに
前回に引き続き、今度はElmでブラックジャックを実装してみます。
元はと言えばこの記事に影響されてやろうと思ったものでした。
今度こそQiita内には先駆者はいないはずです・・・
私のElm歴は半年で、しかもあまり書いていないのでとてもレベルが低いと思います。
Elmerな方々にはぜひアドバイスなどいただければと思います。
一部解説している順番と実際にコードに書かれている順番は異なります。ご了承ください。
完成品は一応以下にあります。
https://github.com/IamKeck/elm-blackjack
動作イメージ
もうちょっと見た目こだわれよって感じですね。
プログラムの中身
モジュール構成
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使ってるんだし。
ともかく、今度こそ二番煎じ、三番煎じではないはずです!
ご意見、ご指摘等ぜひよろしくお願いします!
ここまでありがとうございました!