はじめに
こちらのrubyでビンゴしようよ!!!!の記事が面白かったので、これを参考に、PureScriptでビンゴゲームを作ってみました。
ビンゴゲームを作るという課題は、元々はブログ記事でRuby初心者向けに提案されていました。課題の内容はRubyに限定したものではなく一般的なものなので、いろいろな言語で挑戦してみると面白いのではないかと思います。また、リンク先のブログ記事では、他にも課題があげられていたので、興味のある方は腕試しにぜひ挑戦されてみると面白いと思います!
設計
課題自体はシンプルなので、いろいろ作り方はあると思うのですが、せっかくなので、なるべく見通しの良い作りにしたいと思いました。
Rubyの元の記事のコメントを読むと、MVCというか、ビンゴカードを表現するモデルのロジックと、その表示処理を分離して作ることが推奨されています。
これをPureScriptに当てはめた場合、そもそも表示処理はEffectになるので、わりと自然に分離した作りになるように思うのですが、それだけでなく、乱数の発生もEffectになるため、全体的に綺麗な作りになる気がします。
PureScript自体については、こちらの記事がとても参考になります。
マスの扱い
ビンゴのマスには1〜75までの数字が入りますが、中央だけはFreeになっています。そこで、マスを表現するデータ構造としてBox
を導入しました。
data Box = BoxFree | BoxInt Int
Maybeと同じ構造なのですが、Maybeとは区別してShowクラスのインスタンスにしたいため、このようにしました。
instance showBox :: Show Box where
show (BoxInt v) = show v
show BoxFree = ""
カードの扱い
カードは単純に、Boxを5x5に並べたものとしました。(定義では、5というサイズはなくて、単なる配列の配列ですが)
type BingoCard = Array (Array Box)
乱数の処理
ビンゴカードを作る場合、例えば1列目を作るには、1〜15の数を作成して、そこからランダムに5つ取り出す必要があります。Rubyでは、Arrayクラスのオブジェクトにsomeという便利なメソッドがあるようなのですが、PureScriptはないようなので、次の二段階の方法で作成することにしました。
- 配列をシャッフルする関数
shuffle
shuffle :: forall a. Array a -> Effect (Array a)
shuffle input = do
pairs <- traverse (\i -> (numWithRandom (length input) i)) input
let shuffledPairs = sortWith _.second pairs
pure $ _.first <$> shuffledPairs
where
numWithRandom :: forall a. Int -> a -> Effect { first :: a, second :: Int }
numWithRandom len i = do
r <- randomInt 1 (len*10)
pure $ { first: i, second: r }
任意の型aの配列をランダムに並び替えます。アルゴリズムとしては、各要素に対してRandomな自然数を割り当て、その値でソートしているだけです。ランダムの値が重複しづらい方がよいので、配列の長さの10倍の値で乱数を発生させています。乱数を使っているので、戻り値はEffect (Array a)
型です。
これを使えば、1〜15の数の中から5つ取り出すのは、take 5 $ shuffle (1..15)
のように表現出来ます。
実装
メイン処理
メインとなる実装部分は、ビンゴカードを作り出すgenerateCard
関数です。
generateCard :: Effect BingoCard
generateCard = do
card' <- sequence $ map (\x -> do
let nums = range (x*15+1) (x*15+15)
randNums5 <- map (map BoxInt) $ take 5 <$> shuffle nums
pure
if (x == 2)
then
case updateAt 2 BoxFree randNums5 of
Just randNums5' -> randNums5'
Nothing -> randNums5
else
randNums5
) (0..4)
pure $ transpose card'
randNum5
は2次元配列で、1行目に1〜15の中からランダムに5つ選んだ配列、2行目に16〜30の中からランダムに5つ選んだ配列、、、、。という感じに格納されます。
中心部分をBoxFree
とするために、updateAt
を使っています。このupdateAt
は配列の特定の要素を更新する関数ですが、結果がMaybe
なので、case文で取り出しています。この条件では、Nothing
は呼ばれることはないのですが、ダミーとして何も更新しない配列を返しています。
また、最後にtranspose
で行と列を入れ替えています。これも、Rubyでは配列にそういったメソッドがあるようなのですが、PureScriptではないようなので、別途実装しています。
表示処理
表示もちょっと悩みました。Rubyのように文字列を整形する命令があまりないようなので、rjust
という補助関数(rjust n
で、長さn
になるように空白を加える関数。コード全体に記載)を用意して、次のように実装しました。
showCard :: BingoCard -> String
showCard card = intercalate "\n\n" $ (intercalate "|") <<< (map $ (rjust 3) <<< show ) <$> card
intercalate
は他の言語でいうjoin
みたいなもので、セパレータ文字列を与えて、文字列の配列をくっつけてくれます。card
が2次元配列なので、map
を2回使って(一つは明示的なmap
、もう一つは<$>
)で、内部のBoxにアクセスして、合成関数(rjust 3) <<< show
を適用しています。
コード全体
補助関数も含めたコード全体は次のようになりました。(PureScript 0.12)
module Bingo where
import Prelude
import Effect
import Effect.Console (log)
import Effect.Random
import Data.Maybe
import Data.Array
import Data.Traversable
import Data.Ordering
import Data.String as S
shuffle :: forall a. Array a -> Effect (Array a)
shuffle input = do
pairs <- traverse (\i -> (numWithRandom (length input) i)) input
let shuffledPairs = sortWith _.second pairs
pure $ _.first <$> shuffledPairs
where
numWithRandom :: forall a. Int -> a -> Effect { first :: a, second :: Int }
numWithRandom len i = do
r <- randomInt 1 (len*10)
pure $ { first: i, second: r }
transpose :: forall a. Array (Array a) -> Array (Array a) -- assuming all rows are same length.
transpose matrix =
case (sequence $ map head matrix) of
Just y -> case (sequence $ map tail matrix) of
Just ys -> y : (transpose ys)
Nothing -> []
Nothing -> []
space :: Int -> String
space 0 = ""
space n = " " <> space (n-1)
rjust :: Int -> String -> String
rjust max str =
if max < S.length str
then str
else (space (max - S.length str) ) <> str
data Box = BoxFree | BoxInt Int
instance showBox :: Show Box where
show (BoxInt v) = show v
show BoxFree = ""
type BingoCard = Array (Array Box)
generateCard :: Effect BingoCard
generateCard = do
card' <- sequence $ map (\x -> do
let nums = range (x*15+1) (x*15+15)
randNums5 <- map (map BoxInt) $ take 5 <$> shuffle nums
pure
if (x == 2)
then
case updateAt 2 BoxFree randNums5 of
Just randNums5' -> randNums5'
Nothing -> randNums5
else
randNums5
) (0..4)
pure $ transpose card'
showCard :: BingoCard -> String
showCard card = intercalate "\n\n" $ (intercalate "|") <<< (map $ (rjust 3) <<< show ) <$> card
main :: Effect Unit
main = do
card <- generateCard
log " B| I| N| G| O\n"
log $ showCard card
結果
実際に実行してみると、次のような結果になりました。
B| I| N| G| O
15| 21| 39| 48| 66
8| 26| 43| 53| 65
2| 19| | 59| 67
7| 27| 44| 49| 72
6| 20| 35| 52| 69
終わりに
やはりRubyのように便利なメソッドやライブラリがたくさん用意されているのは何かと便利ですね。頑張って、PureScript用に汎用的に使えるライブラリを作りたいなと思いました。
shuffle
を実装する中で、Haskellであればタプルを使うような処理の部分も、レコード型にしました。PureScriptにもTuple型はあるので、最初はそれを使っていたのですが、PureScriptはレコード型が便利で積極的にそれを使っていく方がPureScriptらしいので、レコード型に書き換えました。
細かいことですが、rjust
を作るときに、第一引数を文字列にするか、長さにするか悩みました。オブジェクト指向言語のオブジェクト(Rubyでいうレシーバ)は、関数型言語でいうと第一引数に割り当てるのが良さそうな気がするのですが、それだとクロージャとしてrjust n
のように使えないので、ちょっと不便ではないかと思い、第一引数を文字列の長さにしてみました。
設計自体は、関数型言語での設計がまだよくわかっていませんが、データ構造を作り、なるべく汎用的に使える関数を作って組み合わせていく、、、ということを意識してみたのですが、なかなかまだ感覚がつかめていないです。ぜひ、アドバイスなどコメントをいただけたらと思います。