タイトルの通り、Python初心者の自分が天和シミュレーターを作成してみました。麻雀に詳しくない方も、設計上の工夫や制作上の小話など楽しんで読んで頂けると嬉しいです。
導入 ~悪魔の囁き~
みなさん麻雀は好きですか?はい、好きですね。よかったです。
僕も麻雀が好きです。麻雀に初めて触れたのは去年ですが、この半年ほどで時にマウスやスマホを投げつつも雀魂段位戦を約700半荘打っています。
そんな麻雀が好きな僕とみなさんですが、もう一つ質問です。
天和を和了ったことはありますか?
はい、ありませんね。僕もありません(そもそも天和の経験があるならこんなシミュレーターなど作らない)。
麻雀に詳しくない方に説明すると、天和とは「局の開始時、親に配られた14枚の牌が既に和了形になっている時に発生する役満」です。その発生確率は33万分の1と言われ、映像に残っているものはこの1回しかありません。↓一応リンクです。
https://www.youtube.com/watch?v=G3Lvwp-2duE
ですが麻雀というゲームに触れてしまった以上、天和を和了ることは誰もが夢見るものです。そんな夢を見ながら雀魂段位戦に潜る毎日を過ごしていると、YouTubeである動画が目に留まりました。
ヨビノリたくみさんの確率に関する動画です。最初に天和の確率についても言及されていますが、大切なのは動画14:50辺りからの、
「親を33万回できたとしても、天和を和了る確率は63.2%程度」
というお話です。1年間で約5000回親で配牌を受けたとしても66年間かかってしまいます。その上でこの63%、さいみんじゅつの命中ぐらいの確率を引く必要があります。つまり引けません。
ここで僕の中のシンジロー=アントワネットが囁きました。
「親で33万回配牌を受けられないのなら、無限回の配牌を受ければいいじゃない。」
いいでしょう。天和まで無限に配牌を繰り返してみましょう。
設計
というわけで天和シミュレーターの制作開始です。構成としては、
- 136枚から14枚の配牌をする。
- それが和了形になっているか確認する。
- 和了形ならば終了、ならなければ1に戻る
以上です。とっても簡単ですね。さあプログラミングだ!!
配牌 難易度:1500
def distribution():
# 配牌用関数
# 34種の牌を定義
haiList = [
"m1","m2","m3","m4","m5","m6","m7","m8","m9",
"p1","p2","p3","p4","p5","p6","p7","p8","p9",
"s1","s2","s3","s4","s5","s6","s7","s8","s9",
"z1","z2","z3","z4","z5","z6","z7"
]
# 136枚に牌を拡張
allHaiList = []
for i in range(4):
for j in range(34):
allHaiList.append(haiList[j])
# 手牌を14枚配る
hand = random.sample(allHaiList,14)
hand.sort()
return hand
まず34種類の牌を配列として定義、それを4倍に拡張することで136枚の牌山を生成、そこから14枚ランダムに取り出すといった流れですね。
Python標準ライブラリrandom様様でした。組み込み関数のsort()で理牌されるのも高得点です。
和了判定
メインにして難関、和了判定の時間です。麻雀の和了形は
以下のような4面子と1雀頭での構成に加え、
同種の牌2枚×7組を揃える七対子、
各柄の1,9と字牌1枚ずつ、そのうち1種は2枚必要になる国士無双、
以上3つの和了形を全てチェックする必要があります。
国士無双 難易度:2900
この中で最も判定が簡単そうな国士無双から始めてみましょう。麻雀では役満ですが、プログラミング上の難易度は30符2翻といったところです。
def thirteenOrphans(hand):
# 国士無双の判定
thirteenOrphansHand = ["m1","m9","p1","p9","s1","s9","z1","z2","z3","z4","z5","z6","z7"]
tmpHand = list(set(hand) ^ set(thirteenOrphansHand))
# 国士無双の上がり形だと配列tmpHandの中身が空になる
if tmpHand == []:
return True
else:
return False
集合演算のset()が非常に役に立ちました。set()^set()は排他的論理和、つまりどちらか一方にのみ含まれるものを取得できます。なので国士無双の形になるのはthirteenOrphansHand(国士無双に必要な牌13種)とhand(配牌)の排他的論理和である、配列tmpHandが空になる時のみです。
例えば、以下の配牌だと一萬がtmpHandに残り、
この配牌では二筒が残ります。
簡単ですね。20分ぐらいで書き終えていたと思います(体感)。
七対子 難易度:4800
次にクソ配牌の味方、七対子です。こちらもサラっとやっつけましょう。
def sevenPairs(hand):
# 七対子の判定
usedHai = list(set(hand))
if len(usedHai) == 7:
i = 0
while i < 7 :
if hand.count(usedHai[i]) != 2:
return False
else:
i += 1
else:
return True
else:
return False
七対子の特性として、「使われる牌は必ず7種類で全て2枚ずつ」というあまりにもプログラミング向けの説明ができます。
ここでも集合演算のset()です。牌の種類をlen()でカウントし、while内で枚数確認していますね。地味にwhile~else文を書いたのは初めてかもしれません。
通常手 難易度:18000
跳満っていざ作ろうとすると難しいですよね。難易度的にはそんなイメージです。雀頭チェック、刻子と順子の区別、複合形の見逃し回避...自分のIQの低さをありありと突き付けられた気分でした。
まず、基本的な判定方法は以下としました。
- 雀頭候補を抽出
- 候補になった雀頭それぞれに対し、他の12枚を面子分解できるか判定
というわけで、まず雀頭候補の抽出です。
def normalWin(hand):
# 通常手の判定
headList = []
# 雀頭候補を調べる
for i in range(14):
if hand.count(hand[i]) >= 2:
if hand[i] not in headList:
headList.append(hand[i])
手牌14枚の中で、2枚以上ある牌は全て雀頭候補になる、ということでこれをheadList配列に加えます。このheadListをもとに面子分解をループさせ、一回でも面子分解に成功すれば和了となる、といった算段ですね。
簡単に言っていますがこの面子分解が非常に難しかったです。以下のように複雑な門前清一色など、人間ですらフリーズしそうな手牌をしっかりと分解できなければいけません。
で、肝心の面子分解ですがまず考えた方法が、
「刻子候補を抜き出し、その後順子になるか調べる」
という方法です。雀頭候補をループさせたように、刻子候補もループさせようというわけですね。
しかしこれには大きな欠点があります。「雀頭は確定で1つに定まるが、刻子は0~4個の可能性がある」ということです。
以下のような四暗刻の形なら刻子は4個で確定ですが、
このような形だと刻子が0個の場合から、4個の可能性まであります。
つまり、形が複雑になるほど条件分岐が大量になり、長くなった処理速度が配牌を繰り返す過程でさらに重くのしかかりそうです。何より、予期せぬバグが発生する可能性がとっっっっっっっっっっても高そうです。やめましょう。
というわけで、面子分解のロジックを見直すことになってしまいました。こんな時に頼れるのはやはりQiitaです。とてもいい記事があったので参考にさせて頂きました。
この中の「面子の組へ分解できるか判定」の部分がとても参考になりました。簡単に説明すると以下のようになります。
- まず各柄ごとに要素数が9の配列を準備する。
- 各要素にその牌の"枚数"を格納する。
- n番目の要素を3で割った余りを、"次の要素"と"次の次の要素"から引くことを繰り返す。
具体的な牌姿を例に説明すると、例えば雀頭を除いた後の12枚が以下の形だったとします。
この形だと、配列の中身は以下のようになります。
handNumList = [1,1,1,3,1,1,4,0,0]
そしてn番目の要素を割った余りをn+1,n+2番目の要素から引くという処理を繰り返した、後の配列が以下です。
handNumList = [0,0,0,0,0,0,0,0,0]
つまりこの処理は、牌を枚数に変換することで順子も刻子も余らず同時に抜き出すことができます。そして全ての要素が0になった時は面子分解が成立した、ということですね。感動!!
というわけで、面子分解の関数は以下のようになりました。
def chowDisassemble(handNumList):
# 順子を全て分解する
for j in range(7):
# その数牌の枚数を3で割った余りを出す
remNum = handNumList[j] % 3
# 上で出した余りを、その次と次の次の枚数から引く
if remNum <= handNumList[j+1] & remNum <= handNumList[j+2]:
handNumList[j] -= remNum
handNumList[j+1] -= remNum
handNumList[j+2] -= remNum
return handNumList
def pungDisassemble(handNumList):
# 刻子を全て分解する
for k in range(9):
if (handNumList[k] != 0) and (handNumList[k] != 3):
return False
return True
刻子分解の関数を別にしたのは、字牌の面子分解の時に必要な処理と同じためです。字牌には順子がないので、この関数を分けておくことで字牌だけの面子分解を改めて定義する手間が省けました。
無限配牌~天和まで
これで通常手の判定が可能になりました!あとは夢の天和まで無限回の配牌を繰り返すだけですね。
def isWin():
# 和了判定
global trials
global win
#配牌→国士無双→七対子→通常手
hand = distribution()
isWin = thirteenOrphans(hand)
if isWin == False:
isWin = sevenPairs(hand)
if isWin == False:
isWin = normalWin(hand)
if isWin == True:
print("Blessing of Heaven!!!")
print(hand)
print("Trials:"+ str(trials))
win = True
else:
# 上がってない配牌も見たければ
print(hand)
trials += 1
win = False
# 試行回数
trials = 1
while win == False:
isWin()
While文を使って、変数trialsに試行回数を記録しつつの無限配牌です。実際に動かしてみましょう。
無事に19万1276回の配牌の末、天和の和了が発生しました!これで人生を麻雀に捧げることなく天和を和了ることができましたね。シンジロー=アントワネットも満足でしょう。
ちなみに筆者の最少試行回数は7923回、最多試行回数は314万8957回です。そして、314万8956回配牌を受けて天和を上がれない確率は8.16のマイナス319乗%だそうです。
その確率引けるなら天和引けよ!!!!なんかものすごく損した気分です。今から宝くじ買ってこようかな.....
追記:314万8956回配牌を受けて天和を上がれない確率は0.0072%だそうです。普通に計算式を間違えていました。とても得した気分です。これで宝くじを買わずに済みます。
あとがき
割と天和シミュレーターの作成が楽しかったので、頑張ってWebバージョンでも作ってみようかと思っています。
ところでヨビノリさんの動画に影響されてこんなものを作ってしまいましたが、同じようなものを作っている方が既にいらっしゃいました。決して真似したわけではないんです.....
参考
牌画はこちらのツールを使用させて頂きました。