はじめに
リアルでもゲームでも時間が無くて麻雀をやらなくなったのですが、移動中とかに頭の中で麻雀のことを考えるのは結構好きです。
pythonで配列の要素の組み合わせを取得するitertoolsというものを知りました。
これって麻雀の判定に使えると思ったのでアイディアとしてストックしてた天和シミュレーターと簡単なCPUとして使うための何切るロジックを作ってみました。
記事にする前に検索してみたら他にも例があったのでネタとしては新鮮味に欠けるようですが、作ってみた部品群をまとめておきます。
牌の管理
IntEnumクラスで数値を持つ列挙型を扱えるので、これを利用して種類のIDと必要なメソッドをまとめて管理します。
クラス名をHAIにするかPAIにするか悩みましたが、標準読み(と思われる)のHAIにしておきます。英語だとtileになるらしいですがしっくりきませんでした。
メソッドも追加できるので、判定に必要な部品を実装しておきます。
赤ドラとか追加したかったらプロパティ増やせばいいと思います。
from enum import IntEnum
HAI_TYPE_NUM = 34
HAI_NUM = 34 * 4
HaiPrintTable = ('壱','二','三','四','五','六','七','八','九',
'①','②','③','④','⑤','⑥','⑦','⑧','⑨',
'1','2','3','4','5','6','7','8','9',
'東','南','西','北',
'白','發','中')
class HAIColor(IntEnum):
'''種別'''
INVALID = -1
CHAR = 0 # manzu
BAMBOO = 1 # pinzu
CIRCLES = 2 # souzu
WIND = 3
DORAGON = 4
class HAI(IntEnum):
INVALID = -1
M1 = 0
M2 = 1
M3 = 2
M4 = 3
M5 = 4
M6 = 5
M7 = 6
M8 = 7
M9 = 8
P1 = 9
P2 = 10
P3 = 11
P4 = 12
P5 = 13
P6 = 14
P7 = 15
P8 = 16
P9 = 17
S1 = 18
S2 = 19
S3 = 20
S4 = 21
S5 = 22
S6 = 23
S7 = 24
S8 = 25
S9 = 26
WE = 27 # wind - east
WS = 28 # wind - south
WW = 29 # wind - west
WN = 30 # wind - north
DW = 31 # Doragon - white
DG = 32 # Doragon - green
DR = 33 # Doragon - red
def __repr__(self):
# return self.name # 日本語非対応の場合はこちらを有効
return HaiPrintTable[self]
def color(self):
if HAI.M1 <= self and self <= HAI.M9:
return HAIColor.CHAR
if HAI.P1 <= self and self <= HAI.P9:
return HAIColor.BAMBOO
if HAI.S1 <= self and self <= HAI.S9:
return HAIColor.CIRCLES
if HAI.WW <= self and self <= HAI.WN:
return HAIColor.WIND
if HAI.DW <= self and self <= HAI.DR:
return HAIColor.DORAGON
return HAIColor.INVALID
def _offset_hai(self, offset):
target_type = self.color()
if target_type == HAIColor.WIND or target_type == HAIColor.DORAGON or target_type == HAIColor.INVALID:
return HAI.INVALID
try:
temp = HAI(self + offset)
except:
return HAI.INVALID
if target_type == temp.color():
return temp
return HAI.INVALID
def prev(self, index=1):
return self._offset_hai(index * -1)
def next(self, index=1):
return self._offset_hai(index)
天和シミュレーター
上がり判定ロジック
上がりを判定するためのAgariCheckerクラスを実装します。
以下の3つのパターンを判定する必要があります。
-
国士無双
一九字牌が1種類ずつ13個。残り1つも一九字牌となる組み合わせ。
特殊なパターンなので、個別に判定するメソッドを用意します。 -
七対子
2,2,2,2,2,2,2の組み合わせ。
特殊なパターンなので、個別に判定するメソッドを用意します。 -
通常
3,3,3,3,2の組み合わせ。
再帰的なロジックで組み合わせを判定するメソッドを用意します。
天和シミュレーターの処理
mainの中にAgariCheckerを使った天和シミュレーターの処理を実装しておきます。
-
牌をランダムに並べる
-
AgariCheckerで上がれる状態か判定する
-
上がっていなかったら、1に戻る
from MjHai import HAI, HAI_NUM
from MjHaiUtils import HaiUtil
import itertools
class AgariChecker:
def __init__(self, hais):
self.hais = sorted(hais)
self.agari_list = []
self.scrap_list = []
def _is_kokusi(self):
# 13,1のセット
kokusi = [HAI.M1, HAI.M9,
HAI.P1, HAI.P9,
HAI.S1, HAI.S9,
HAI.DW, HAI.DR, HAI.DG,
HAI.WW, HAI.WS, HAI.WE, HAI.WN]
rest_hais = list(self.hais)
for hai in kokusi:
if hai in rest_hais:
rest_hais.remove(hai)
else:
break
if len(rest_hais) == 1:
if rest_hais[0] in kokusi:
tokens = []
tokens.append(list(self.hais))
self.agari_list.append(tokens)
return True
return False
def _is_seven_pairs(self):
# 2*7のセット
rest_hais = list(self.hais)
tokens = []
for index in range(7):
pairs_itr = itertools.combinations(rest_hais, 2)
for pairs_candidate in pairs_itr:
if HaiUtil.is_pairs(pairs_candidate):
rest_hais = HaiUtil.remove_hais(
rest_hais, pairs_candidate)
tokens.append(list(pairs_candidate))
break # 他の組み合わせはない
else:
return False
self.agari_list.append(tokens)
return True
def _split_combinations(self, hais, tokens=[], commit_tokens=[]):
'''haisの配列から組み合わせ有効なものを取り出す。再帰的になくなるまでやる'''
results = list(commit_tokens)
rest_hais = list(hais)
token_size = 3
if len(rest_hais) < 3:
token_size = 2
find_combinations = False
itr = itertools.combinations(rest_hais, token_size)
for candidate in itr:
if HaiUtil.is_combinations(candidate):
find_combinations = True
next_hais = HaiUtil.remove_hais(rest_hais, candidate)
next_tokens = list(tokens)
next_tokens.append(candidate)
if len(next_hais) > 0:
results = self._split_combinations(next_hais,
tokens=next_tokens,
commit_tokens=results)
else:
next_tokens = sorted(next_tokens)
if not next_tokens in results:
results.append(next_tokens)
if not find_combinations:
# 対象となる組み合わせが無い場合、残りをscrap_listに登録
self._set_scrap_list(hais)
return results
def _is_standerd(self):
# 3,3,3,3,2のセット
rest_hais = list(self.hais)
self.agari_list.extend(self._split_combinations(rest_hais))
if len(self.agari_list) > 0:
return True
return False
def is_agari(self):
result = False
if self._is_kokusi():
result = True
if self._is_seven_pairs():
result = True
if self._is_standerd():
result = True
return result
def _set_scrap_list(self, hais):
# 余りリストへ登録
if len(self.scrap_list) == 0:
# 未登録なのでそのまま追加
self.scrap_list.append(hais)
else:
# 登録済の場合
if len(self.scrap_list[0]) > len(hais):
# 候補の個数が少なくなる場合は、これまでの情報をリセットしてから登録
self.scrap_list = []
self.scrap_list.append(hais)
elif len(self.scrap_list[0]) == len(hais):
# 候補の個数が同じ場合はリストに追加
if not hais in self.scrap_list:
self.scrap_list.append(hais)
if __name__ == '__main__':
import random
import time
while True:
hais = HaiUtil.hais_all()
tehai = hais[: 14]
print("\r{}".format(sorted(tehai)), end="")
obj = AgariChecker(tehai)
if obj.is_agari():
print("\n OK.")
for n, agari in enumerate(obj.agari_list):
print("{} {}".format(n, sorted(agari)))
break
牌操作の単機能のメソッドは別クラスにスタティックメソッドとして定義しています。
from MjHai import HAI, HAI_NUM, HAI_TYPE_NUM, HAIColor
import random
class HaiUtil:
@staticmethod
def find_count(hais, hai):
count = 0
for temp in hais:
if temp is hai:
count = count + 1
return count
@staticmethod
def is_pairs(candidate):
'''2個のhaiが同じか判定'''
if len(candidate) == 2:
if candidate[0] == candidate[1]:
return True
return False
@staticmethod
def is_triplets(candidate):
'''3個のhaiが同じか判定'''
if len(candidate) == 3:
if candidate[0] == candidate[1] and candidate[0] == candidate[2]:
return True
return False
@staticmethod
def is_straight(candidate):
'''3個が連番か判定'''
if len(candidate) == 3:
sorted_list = sorted(candidate)
if sorted_list[0].next(1) == sorted_list[1] and sorted_list[0].next(2) == sorted_list[2]: # 連番
return True
return False
@staticmethod
def is_combinations(candidate):
if len(candidate) == 3:
if HaiUtil.is_triplets(candidate): # 3個同じ
return True
elif HaiUtil.is_straight(candidate): # 連番
return True
return False
elif len(candidate) == 2:
return HaiUtil.is_pairs(candidate) # 2個同じ
return False
@staticmethod
def remove_hais(org_hais, remove_hais):
'''org_haisからremove_haisに含まれるアイテムを削除する'''
temp_hais = list(org_hais)
for hai in remove_hais:
temp_hais.remove(hai)
return temp_hais
@staticmethod
def hais_all():
hais = []
for i in range(HAI_NUM):
hais.append(HAI(i % HAI_TYPE_NUM))
random.shuffle(hais)
return hais
実行結果
あがるまでprintし続けます。結構時間かかります。
#python MjAgariChecker.py
[六, 七, 八, ⑤, ⑥, ⑦, 5, 6, 7, 7, 8, 9, 中, 中]
OK.
簡易CPUの実装
何切る判定ロジック
AgariCheckerを継承して何切る判定ロジックを作ります。
今回は適当な対戦相手としてランダム切りよりまし程度のものを作ります。
AgariCheckerで上がりの組み合わせを作りますが、余ったものを切る対象として使います。
余ったものを比べてポイントを付けていきます。一番ポイントが低いものを優先して捨てることにします。
- 字が1個しかない場合は-100ポイント
- 同じものが2個あったら+5ポイント
- 1 or 9 だったら-1ポイント
- 同じ色、連番ものがあったら+10ポイント(ex 対象1で2がある場合)
- 同じ色、1つ飛び番号ものがあったら+5ポイント(ex 対象1で3がある場合)
簡易CPUの処理
1つ切ったら、1つツモるループを用意しました。
from MjAgariChecker import AgariChecker
from MjHai import HAI, HAIColor
from MjHaiUtils import HaiUtil
import itertools
class DropCandidateChecker(AgariChecker):
def __init__(self, hais):
super().__init__(hais)
def get_scrap_rank(self):
'''仲間はずれリストを返す ※事前にis_agari()を呼んでおくこと'''
point_list = []
rest_hais = self.scrap_list[0]
for index, hai in enumerate(rest_hais):
point = 0
if hai.color() == HAIColor.DORAGON or hai.color() == HAIColor.WIND:
if HaiUtil.find_count(rest_hais, hai) == 1:
point -= 100
if HaiUtil.find_count(rest_hais, hai) == 2:
point += 5
if hai.next(1) != HAI.INVALID:
if hai.next(1) in rest_hais:
point += 10
else:
point -= 1
if hai.next(2) != HAI.INVALID:
if hai.next(2) in rest_hais:
point += 5
if hai.prev(1) != HAI.INVALID:
if hai.prev(1) in rest_hais:
point += 10
else:
point -= 1
if hai.prev(2) != HAI.INVALID:
if hai.prev(2) in rest_hais:
point += 5
point_list.append({'point': point, 'value': hai})
# pointでソート 不要なものが先頭になる
drop_candidate_list = sorted(point_list, key=lambda x: x['point'])
result = []
for item in drop_candidate_list:
result.append(HAI(item['value']))
return result
if __name__ == '__main__':
loop = 1000
for _ in range(loop):
hais = HaiUtil.hais_all()
tehai = hais[: 13]
yama = hais[13:]
for i, next_hai in enumerate(yama):
tehai.append(next_hai)
print("\r{} {}".format(i, sorted(tehai)), end="")
obj = DropCandidateChecker(tehai)
if obj.is_agari():
print(" OK.")
for agari in obj.agari_list:
print(" {}".format(sorted(agari)))
break
scraps = obj.get_scrap_rank()
tehai.remove(scraps[0])
実行結果
#python MjDropCandidateChecker.py
28 [三, 四, 五, 七, 八, 九, ④, ④, ⑥, ⑥, ⑥, 3, 4, 5] OK.
[(三, 四, 五), (七, 八, 九), (④, ④), (⑥, ⑥, ⑥), (3, 4, 5)]
32 [五, 六, 七, 2, 2, 2, 4, 4, 4, 5, 5, 7, 8, 9] OK.
[(五, 六, 七), (2, 2, 2), (4, 4, 4), (5, 5), (7, 8, 9)]
56 [①, ②, ③, ④, ⑤, ⑥, 4, 4, 5, 5, 6, 6, 7, 7] OK.
[(①, ②, ③), (④, ⑤, ⑥), (4, 5, 6), (4, 5, 6), (7, 7)]
[(①, ②, ③), (④, ⑤, ⑥), (4, 4), (5, 6, 7), (5, 6, 7)]
55 [四, 五, 六, ④, ④, ④, 5, 6, 6, 7, 7, 8, 8, 8] OK.
[(四, 五, 六), (④, ④, ④), (5, 6, 7), (6, 7, 8), (8, 8)]
52 [壱, 二, 三, ①, ②, ③, ③, ④, ⑤, ⑥, ⑥, 5, 6, 7] OK.
[(壱, 二, 三), (①, ②, ③), (③, ④, ⑤), (⑥, ⑥), (5, 6, 7)]
90 [壱, 壱, 二, 二, 三, 三, ①, ①, ②, ②, ③, ③, 8, 8] OK.
[[壱, 壱], [二, 二], [三, 三], [①, ①], [②, ②], [③, ③], [8, 8]]
[(壱, 二, 三), (壱, 二, 三), (①, ②, ③), (①, ②, ③), (8, 8)]
47 [五, 五, 五, ④, ⑤, ⑥, 2, 2, 2, 8, 8, 9, 9, 9] OK.
[(五, 五, 五), (④, ⑤, ⑥), (2, 2, 2), (8, 8), (9, 9, 9)]
34 [六, 七, 八, ④, ⑤, ⑥, ⑥, ⑦, ⑧, 1, 2, 3, 8, 8] OK.
[(六, 七, 八), (④, ⑤, ⑥), (⑥, ⑦, ⑧), (1, 2, 3), (8, 8)]
(略)
おわりに
itertools便利ですね。昔MFCで麻雀を実装した時はCList<>とか使ってかなり面倒な感じだったと記憶していますが、シンプルな感じでできたと思います。
今回作成した部品を使ってCPU対戦できる試作もしてあるのですが、記事にできるクオリティにするのにもう少し苦戦しそうな感じです。麻雀のルール複雑すぎてシンプルな実装にならないです。
次回更新する機会があれば役判定の部品でいきたいと思います。