読者です 読者をやめる 読者になる 読者になる

Creatable a => a -> IO b

Haskellと数学とちょびっと音楽

Haskellでポーカーを作ろう〜第一回 リストのシャッフルとカードの定義〜

はいはいどーも、進捗ダメです。ちゅーんさんです。 基本的にコンスタントに何かを生み出し続けなくてはいけないのが、 ギークなアクティブニートの使命なわけですが、一日の半分以上は睡眠とドラクエに当てらています。 人生なんてそんなものです。

えーっと

そういえば、勉強会とかTLとかで、 「すごいH本読んで、基本的な事はわかったと思うけど、Haskellで具体的なプログラムを開発するイメージが出来ない」 なんていう話を聞くことがたまにあって、そんなに大きくなくても良いので実践的なやさしい文章が増えると良いなぁとか、 ずっと思っていたわけです。

で、そういう状況でドラクエのカジノのポーカーをプレイしたりしていると、 「あ、ポーカー作ろう」っていう気分になったりするじゃないですか。

なったんですよ。

んなわけで、今回からちょっとづつ、Haskellを使ってCUIでプレイできるポーカーゲームを作っていきます。
本記事を読んで、写経しながら動かしたりしているうちに、遊べるポーカーゲームが出来上がってました、 みたいな、そんな感じを目指していこうと思います。

おことわり

前提知識とか

基本的には、「モナドってなんだか良くわからないけど、do構文でなんとかIO処理とか書けるよ」 くらいの人を想定してに話を進めていきます。 型や型クラス、インスタンスの定義とかは普通に読み書きできるくらいのスキルは欲しいです。

開発の進め方について

基本的に「ある程度書いたら記事にまとめて次〜」みたいな感じで作っていくので、 過去に書いた部分を書きなおす〜みたいなフェーズが発生する可能性があります。

なるべく「実際に作っていく感じ」を記録していきたいので、 多少遠回りになるかもしれませんが、ご了承ください。

あと、Haskell得意な人で、「ここはこうした方がええんちゃう?」みたいのがあれば、 ツッコミ頂けると幸いです。

ゲームの仕様をざっくり決める

今回作るのは、ドラクエ等のようにポーカー・ハンド毎に配当が設定されているビデオポーカーではなく、 トーナメント方式で掛け金を奪い合うオーソドックスなドローポーカーです。 いうても良くわからないかもしれませんが、とりあえずCPU何人かとチップを賭けながら、 最終的に全員のチップを奪う事を目的とするゲームです。

つまり、CPUの思考ルーチンなんかも作る予定です(!!)

ちなみに、調べてみたら、ポーカーってかなーり種類がいっぱいあるんですね。 イマドキのナウいルールは、テキサス・ホールデムと呼ばれるフロップポーカーの一種で、 スマフォアプリでオンライン対戦とかやってみてるんですが、これすごい熱いですね、ちょーたのしい。 本当はこれを作りたいくらいなんですが、プログラムが複雑になりすぎる気がしたので今回は諦めます。

んで、本当はここに細々とゲームのルールを書いていっていたんですが、 やった事ある人はわかると思うんですけど、ポーカーってわりと用語とか多くて手順がややこしいので、 文章化するとひじょーに面倒だという事を思い知りましたorz

各回毎に理解しておく必要がある所を説明します。 実際に作りながら覚えて頂くか、適当なWebサイトで調べたり、実際にプレイしてみたりしてください。

カードのシャッフルについて考える

さて、とりあえず今回は下準備として、トランプカードを扱うための道具を作っていきましょう。

カードの枚数はたかだが52枚なので、パフォーマンス上の問題はあんまり意識しなくても良さそうですし、 山札や手札などのカードの束はリストで扱うとしましょう。

リストをシャッフルするという処理は、トランプ以外でも共通です。 こういう処理は標準モジュールになくても、探せばHackageというパッケージデータベースにあったりします。

https://hackage.haskell.org/package/random-shuffle

今回使うのは、このrandom-shuffleというパッケージです。 cabalを使って、このパッケージをインストールしてください。 尚、この記事ではcabalの使い方は説明しません、以下の記事を参考にすると良いでしょう。

http://bicycle1885.hatenablog.com/entry/2012/10/08/044516

多分、このくらいならHaskellのパッケージシステムの悩みの種である依存地獄に陥る心配はあんまりないのですが、 不安な人はcabal sandboxの使い方を調べると良いかと思います。

さて、肝心のrandom-shuffleパッケージの中身なのですが、System.Random.Shuffleモジュール以下に、 3つの関数が用意されています。 シャッフルしたいだけなのになんか仰々しい型ですね(´・ω・`)

shuffle :: [a] -> [Int] -> [a]
shuffle' :: RandomGen gen => [a] -> Int -> gen -> [a]
shuffleM :: MonadRandom m => [a] -> m [a]

単にランダムにリストをシャッフルしたい時は、shuffleM関数を使うと覚えておけば良いです。 乱数の生成は参照透過ではないため、このような少々ややこしい型になってしまっています。 IOモナド内では、どのような副作用も認められているので、IOMonadRandom型クラスのインスタンスになっています。 その事さえ理解していれば、この関数を使うのはとても簡単です。

main :: IO ()
main = do
  shuffledList <- shuffleM [1,2,3,4,5]
  print shuffledList

以下のプログラムを実行すると、1から5までの数がランダムに並んだリストを出力します。

というわけで、カードのシャッフルについてはこのshuffleM関数を使えばさくさくっと出来そうなので、 続いてトランプのカードの定義に入って行きましょう。

トランプカードを定義する

型定義

まず、Cards.hsというファイルを用意します。 トランプには、ハート/ダイアモンド/クラブ/スペードの4種類のマーク(スート)がありますので、 直和型を使って定義します。

data Suit = Hearts | Diamonds | Clubs | Spades
  deriving (Show, Read, Eq, Ord, Enum)

スートはカードの強さに関係ありませんが、 カードそのものがソート出来ると何かと便利なので、Ord型クラスのインスタンスにしておきましょう。 また、Enum型クラスのインスタンスにしておくことで、全てのスートを列挙するのが簡単になります。 んで、トランプカードは、番号とスートからなるので、次のようにCard型を定義します。

data Card = Card Int Suit
  deriving (Eq, Ord)

トランプの番号を表すInt型をSuitの前に書いたのは、deriving Ordした際に、手前の型から大小比較のキーとなるからです。 番号の大きなカードほど強いのでしたね。

後ほどもうちょっと詳しく説明しますが、好き勝手なカードを錬成(w)出来ても困るので、Read型クラスのインスタンスにもしません。 さらに、Show型クラスのインスタンスにしなかったのは、 実際にカードのリストを表示した時にCard 11 Heartsみたいなのがいっぱい表示されてもわけわからんので、 もうちょっと見やすい感じでShow出来るようにしたかったからです。

まず、番号を文字列にする関数を定義します。 本来、A(エース)は1ですが、強さで比較する際に最強の番号なので、 14がAになるようにしておいたほうが後々楽できそうです。

showCardNumber :: Int -> String
showCardNumber 14 = "A_"
showCardNumber 13 = "K_"
showCardNumber 12 = "Q_"
showCardNumber 11 = "J_"
showCardNumber 10 = "10"
showCardNumber x = (show $ x) ++ "_"

んで、showする際に、それぞれスートの頭文字を頭にくっつけるよう、Show型クラスのインスタンスにしてやります。

instance Show Card where
  show (Card i Hearts) = "H" ++ showCardNumber i
  show (Card i Diamonds) = "D" ++ showCardNumber i
  show (Card i Clubs) = "C" ++ showCardNumber i
  show (Card i Spades) = "S" ++ showCardNumber i

GHCiで実際に表示してみます。

*Cards> Card 5 Hearts
H5_
*Cards> Card 12 Clubs 
CQ_
*Cards> Card 10 Spades 
S10

ASCII文字以外を使うと何かしら問題が出てくる可能性があるので、この段階では♡とか♣みたいな全角記号は使いません。 余裕があったらprintする時に良い感じに出力出来るような仕組みを用意しても良いですが、デバッグ用ならこんなもんで十分でしょう。

全てのカードを列挙する

ここは単なるデータの列挙なので、ちゃちゃっといきましょう。

まずリスト内包表記を使ったパターン。

allCards :: [Card]
allCards = [ Card num suit | suit <- [Hearts ..], num <- [2..14] ]

基本的にこのパターンがわかればOKです。

あとは、知識として、リストモナドを使ったパターンもご紹介しておきましょう。
こちらは、今の所「こんな書き方も出来るんだー」くらいの認識で構いません。

allCards :: [Card]
allCards = do
  suit <- [Hearts ..]
  num <- [2..14]
  return $ Card num suit

単純に慣れの問題ですが、僕はリストモナドのパターンが最初に思いつきます。 とはいえ、このくらいならリスト内包表記のほうが綺麗ですね。 どっちにしても、52枚全てのカードを列挙する事ができますので、お好きな方でどうぞ。

*Cards> allCards
[H2_,H3_,H4_,H5_,H6_,H7_,H8_,H9_,H10,HJ_,HQ_,HK_,HA_
,D2_,D3_,D4_,D5_,D6_,D7_,D8_,D9_,D10,DJ_,DQ_,DK_,DA_
,C2_,C3_,C4_,C5_,C6_,C7_,C8_,C9_,C10,CJ_,CQ_,CK_,CA_
,S2_,S3_,S4_,S5_,S6_,S7_,S8_,S9_,S10,SJ_,SQ_,SK_,SA_]

ありえないカードは作らせない

カードの番号はInt型なので、以下のように本来はあり得ないカードを作れてしまいます。

*Cards> Card 100 Hearts 
H101_

この問題を解決する方法は、オブジェクト指向でフィールドをprivateにし、 ゲッターのみを提供するアプローチと少し似ているかもしれません。

定義した型をエクスポートする場合、通常次のように(..)と書いて、 データコンストラクタも一緒にエクスポートする事を明示します。

module Cards 
  ( Suit(..) 
  , Card(..)
  ) where

この時に以下のようにして、Card型に関しては、あえて(..)を書かずにデータコンストラクタを使えないようにします。 同時にallCardsを一緒にエクスポートする事によって、 CardsモジュールをインポートしたモジュールがCard型の値が欲しい場合には、allCardsから取得する必要があるようにしましょう。

module Cards 
  ( Suit(..) 
  , Card
  , allCards
  ) where

これだけだと、欲しいカードをallCardsから探す事が出来ないので、 カードからスートや番号を取得する関数を定義します。

cardSuit :: Card -> Suit
cardSuit (Card _ s) = s

cardNumber :: Card -> Int
cardNumber (Card n _) = n

最終的に、モジュールの定義部分は、次のようになるでしょう。

module Cards 
  ( Suit(..) 
  , Card
  , allCards
  , cardSuit
  , cardNumber
  ) where

動かしてみる

Main.hsを作成して、たった今作ったCardsモジュールをインポートします。

module Main where
import Cards

GHCiを起動して、allCardsから目的のカードを検索できる事や、 Cardデータコンストラクタで変なデータを作ったり出来ない事を確認してみましょう。

*Main> filter (\card -> Hearts == cardSuit card) allCards
[H2_,H3_,H4_,H5_,H6_,H7_,H8_,H9_,H10,HJ_,HQ_,HK_,HA_]
*Main> filter (\card -> 13 == cardNumber card) allCards
[HK_,DK_,CK_,SK_]
*Main> Card 999 Diamonds 

<interactive>:4:1: Not in scope: data constructor `Card'

うむ、問題無いようですね。
あとは、System.Random.ShuffleをインポートしshuffleM関数を使って、 ランダムに5枚のカードを取ってくる簡単な実験をしてみましょう。
尚、sort関数を使うために、Data.Listモジュールをインポートしておく必要があります。

main :: IO ()
main = do
  shuffled <- shuffleM allCards
  print . sort . take 5 $ shuffled

このプログラムを実行すれば、毎回5枚分のランダムなカードが出力されます。 実際に強い役が出るまで何度も実行してみると時間つぶしくらいにはなるかもしれませんね。

まとめ

というわけで、最初の一歩ということで、トランプを扱うための仕組みを整えました。 次回はポーカーの役を判定する仕組みを作っていく予定です。

ときに、プログラム自体は1時間もかかってないと思うのですが、 文章書くのにまる1日くらいかかってしまいましたorz<手が遅すぎる
完成するまで、どのくらいかかるか解りませんが、気長にお付き合いいただければと思います。

それではノシノシ

次→