こちらのPureScriptでビンゴしようよ!!!!に触発され、みんな大好きElmで実装しようと思いました。
せっかくなので内容整理するのがめんどくさい実際の思考の順番で実装をどんどん追記していくような形式で書かせていただきます。
必要な機能をかんがえる
機能としては
- ビンゴカードを表すデータ型
- 行ごとや列ごとに取り出せると嬉しい
- 乱数ジェネレータを使ってビンゴカードを作る機能
- Bは1~15, Iは16~30, ...
- Nの真ん中はFree
- そのデータ型から目的の文字列にフォーマットする関数
文字の表示までをやる
今回は乱数のためにCmdも利用するのでBrowser.elementを使ってアプリを作ります。
とりあえずの雛形として以下のようなものを用意します。
module Main exposing (main)
import Browser
import Html exposing (Html, div, h1, text)
type alias Model =
()
init : () -> ( Model, Cmd Msg )
init flags =
( (), Cmd.none )
type alias Msg =
Never
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
( model, Cmd.none )
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
view : Model -> Html Msg
view model =
div []
[ h1 []
[ text "Elm Bingo" ]
, div
[]
[ text "これから実装するよ!" ]
]
main =
Browser.element
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
モデルの定義をする
とりあえず思いついたのは、Arrayの二次元配列です。
import Array exposing (Array)
type BingoCard = BingoCard (Array (Array Int))
表示を考えると行ずつに取り出したいので横方向に伸びる行を縦に積んだようなデータ構造を想定してます。ですが、上の定義だと縦の列を横にならべてるというようにも解釈できてしまいます。ここはカスタム型を使ってデータ構造に名前をつけちゃいます。
type Row
= Row (Array Int)
type BingoCard
= BingoCard (Array Row)
また、ビンゴカードの各マスは真ん中だけFreeというような構造です。真ん中だけFreeというデータ構造も表せないわけではないですが、かなり面倒なので「ビンゴのマスはFreeか数字のどちらかである」というような解釈をすることにします。
type Cell -- `Free` か `Cell Int` のどちらか
= Free
| Cell Int
type Row
= Row (Array Cell)
これで一通りモデルの定義ができました。
ポイントは、小さなパーツを元におっきなパーツを作るって考え方です。行はセルの集まり、ビンゴカードは行の集まり、といった感じです。
viewを書きたいのでとりあえず仮のデータを用意する
仮データとしてビンゴカードを適当に定義しちゃいます。<|
こっち向きのパイプはコンストラクタ <| コンストラクタに渡したい複雑な式
のような使い方だと読みやすくなります。
bingo : BingoCard
bingo =
BingoCard <|
Array.fromList
[ Row <| Array.fromList [ Cell 1, Cell 16, Cell 31, Cell 46, Cell 61 ]
, Row <| Array.fromList [ Cell 2, Cell 17, Cell 32, Cell 47, Cell 62 ]
, Row <| Array.fromList [ Cell 3, Cell 18, Free, Cell 48, Cell 63 ]
, Row <| Array.fromList [ Cell 4, Cell 19, Cell 34, Cell 49, Cell 64 ]
, Row <| Array.fromList [ Cell 5, Cell 20, Cell 35, Cell 50, Cell 65 ]
]
viewを書く
ビンゴカードを表示するためのの共通部分の処理をざっくり考えてみると
- Stringをスペース詰めながら右詰めにするやつ
- 両端にスペース入れる
- 文字列のリストの間に"|"を差し込む処理
- 文字列のリストの間に"\n"を差し込む処理
この関数たちをいい感じに組み合わせればフォーマット関数は出来上がりそうです。ポイントは頭がこんがらがらないようにちゃんと小さなパーツから作ることです。
showBingoCard : BingoCard -> String
showBingoCard (BingoCard rows) =
let
-- セルを文字列にする
showCell : Cell -> String
showCell cell =
case cell of
Free ->
""
Cell n ->
String.fromInt n
-- 行を文字列のリストに変換する
rowToStrList : Row -> List String
rowToStrList (Row cells) =
cells
|> Array.toList
|> List.map showCell
-- 表もヘッダも同じフォーマッタを使いたいので共通化
formatRow : List String -> String
formatRow list =
list
|> List.map (String.pad 4 ' ')
|> String.join "|"
-- ヘッダの定義
header : List String
header =
[ "B", "I", "N", "G", "O" ]
in
rows
|> Array.toList
|> List.map rowToStrList
|> (::) header
|> List.map formatRow
|> String.join "\n"
複雑な処理をしてるはずなのになんだかスッキリしてて気持ちがいいですね!
ここまできたらあとは楽で、先ほど作った仮のビンゴカードにフォーマッタを適用してそれをtextとして表示するだけです。僕の環境だとpreを使っても表示が崩れてたので等幅フォントの指定をしておきます。
import Html.Attributes exposing (style)
view : Model -> Html Msg
view model =
div []
[ h1 [] [ text "Elm Bingo" ]
, pre [ style "font-family" "Courier" ] [ text (showBingoCard bingo) ]
]
めちゃくちゃいい感じに表示できました!ちょろい!!
ここまでくればあとはビンゴカードの生成のロジックを書けばいいだけです。
乱数を使い、ビンゴカードを作成
ビンゴのカードの番号の詳細をもう一度把握しておきましょう
- B列は1~15, I列は16~30, ... といったように15刻みの範囲で数字が選ばれる
- 番号は重複しない
- 真ん中はFree
これを実現するためには
- aからbまでの整数のリストをシャッフルしたうち先頭5つを取り出す関数
- 列を横に並べたものを元に、行を縦に並べたものを作る関数
- この二次元配列っぽいデータをBingoCardに変換する関数
これらがあれば十分そうです。elm-communityの~~Extraってライブラリに便利関数がたくさんあったのでこれらを使って実装していきます。
import List.Extra
import Maybe.Extra
import Random exposing (Generator)
import Random.Extra
import Random.List
genBingoCard : Generator BingoCard
genBingoCard =
let
genColumn : Int -> Int -> Generator (List Cell)
genColumn min_ max_ =
List.range min_ max_
|> List.map Cell
|> Random.List.shuffle
|> Random.map (List.take 5)
mapToBingoCard : List (List Cell) -> BingoCard
mapToBingoCard =
List.map (Array.fromList >> Row) >> Array.fromList >> BingoCard
in
Random.Extra.sequence
[ genColumn 1 15
, genColumn 16 30
, genColumn 31 45 |> Random.map (List.Extra.setAt 2 Free)
, genColumn 46 60
, genColumn 61 75
]
|> Random.map List.Extra.transpose
|> Random.map mapToBingoCard
なんて読みやすいんでしょうか。さすがElm、さすが僕。
難しい部分は全部終わりました。最後に、このビンゴカードジェネレータをどうにかしてviewに表示すれば良いでしょう。
今まで作ってきたものを組み合わせる
Random.generateの型は(a -> msg) -> Generator a -> Cmd msg
という方になってます。aがビンゴ型、msgがアプリのメッセージの型になるように実装します。
ビンゴを作成したことを表すメッセージを作成して...
type Msg
= GenerateBingo BingoCard
init時にビンゴジェネレータを走らせるように書きます。ビンゴカードはジェネレート前の状態が存在するため、Maybe BingoCard
のようにしておきます。
type alias Model =
Maybe BingoCard
init : () -> ( Model, Cmd Msg )
init _ =
( Nothong -- 最初はビンゴカードがない状態
, Random.generate GenerateBingo genBingoCard
)
作成したビンゴカードはupdateから受け取れるので、
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GenerateBingo card ->
( Just card, Cmd.none ) -- 生成されたcardでモデルを更新!
そして最後、モデルに入っているBingoCardをテキストで表示します
view : Model -> Html Msg
view model =
let
formattedBingoCard : String
formattedBingoCard =
model
|> Maybe.map showBingoCard
|> Maybe.withDefault "creating..."
in
div []
[ h1 [] [ text "Elm Bingo" ]
, pre [ style "font-family" "Courier" ] [ text formattedBingoCard ]
]
ようやく実行です。ここまではひたすら型パズルを解いていっただけです。特に関数の実装をよく読んだりなどはしてないので本当に仕様通りに行くか心配ですが...
どうやらうまく実装できたみたいです、ヤッタネ🎉
全体のソースは以下のようになりました
module Main exposing (main)
import Array exposing (Array)
import Browser
import Html exposing (Html, div, h1, pre, text)
import Html.Attributes exposing (style)
import List.Extra
import Random exposing (Generator)
import Random.Extra
import Random.List
type Cell
= Free
| Cell Int
type Row
= Row (Array Cell)
type BingoCard
= BingoCard (Array Row)
type alias Model =
Maybe BingoCard
showBingoCard : BingoCard -> String
showBingoCard (BingoCard rows) =
let
-- セルを文字列にする
showCell : Cell -> String
showCell cell =
case cell of
Free ->
""
Cell n ->
String.fromInt n
-- 行を文字列のリストに変換する
rowToStrList : Row -> List String
rowToStrList (Row cells) =
cells
|> Array.toList
|> List.map showCell
-- 表もヘッダも同じフォーマッタを使いたいので共通化
formatRow : List String -> String
formatRow list =
list
|> List.map (String.pad 4 ' ')
|> String.join "|"
-- ヘッダの定義
header : List String
header =
[ "B", "I", "N", "G", "O" ]
in
rows
|> Array.toList
|> List.map rowToStrList
|> (::) header
|> List.map formatRow
|> String.join "\n"
genBingoCard : Generator BingoCard
genBingoCard =
let
genColumn : Int -> Int -> Generator (List Cell)
genColumn min_ max_ =
List.range min_ max_
|> List.map Cell
|> Random.List.shuffle
|> Random.map (List.take 5)
mapToBingoCard : List (List Cell) -> BingoCard
mapToBingoCard =
List.map (Array.fromList >> Row) >> Array.fromList >> BingoCard
in
Random.Extra.sequence
[ genColumn 1 15
, genColumn 16 30
, genColumn 31 45 |> Random.map (List.Extra.setAt 2 Free)
, genColumn 46 60
, genColumn 61 75
]
|> Random.map List.Extra.transpose
|> Random.map mapToBingoCard
type Msg
= GenerateBingo BingoCard
init : () -> ( Model, Cmd Msg )
init _ =
( Nothing
, Random.generate GenerateBingo genBingoCard
)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GenerateBingo card ->
( Just card, Cmd.none )
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
view : Model -> Html Msg
view model =
let
formattedBingoCard : String
formattedBingoCard =
model
|> Maybe.map showBingoCard
|> Maybe.withDefault "creating..."
in
div []
[ h1 [] [ text "Elm Bingo" ]
, pre [ style "font-family" "Courier" ] [ text formattedBingoCard ]
]
main : Program () Model Msg
main =
Browser.element
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
まとめ
実は実装途中でExtraにあるtransposeやtraverseなどの一般的な関数をゴテゴテ書いてたのですが、@miyamo_madokaさんからExtraあるよって教えてもらい、急遽そちらを使うようにしました。
- 車輪の再発明する前にちゃんとパッケージ読みましょう。
- でも再発明楽しかった、ぜひみんなにもやってほしい!