Pythonで麻雀を作る#1

#目的
コンピューター同士もしくは人対コンピューターで対戦できる麻雀のコードを作成し、オブジェクト指向のプログラミングに対する理解を深める。
また、麻雀を深く理解することで麻雀の実力を向上させる。

#環境
Windows 10
Python 3.8.8
mahjong 1.1.11*

*麻雀の点数計算や向聴数計算を行うライブラリ
https://pypi.org/project/mahjong/

#やったこと
麻雀の対戦を行うシステムの根幹部分を構築した(ロン、副露、連荘、ノーテン罰符などは未実装)。
また、打牌の選択に関して簡易的だがツモ切りするだけではなく和了に近づくような切り方をするようなアルゴリズムを実装した。
具体的には手牌中の14牌それぞれに対してその牌を切った際の向聴数を求め、向聴数が最も小さくなるよう打牌するようにした。
点数計算に関してはmahjongライブラリに丸投げした。

#実装
以下のソースコードを実装した。

```python
import random
from mahjong.shanten import Shanten
from mahjong.tile import TilesConverter
from mahjong.hand_calculating.hand import HandCalculator
from mahjong.hand_calculating.hand_config import HandConfig, OptionalRules
from mahjong.constants import EAST, SOUTH, WEST, NORTH
from mahjong.meld import Meld

# 卓クラス 山、局数、場風、ドラ表示牌の管理
class Taku:
    def __init__(self):
        self.bakaze = EAST
        self.kyoku = 1
        self.hai = []
        # 1~9が萬子、11~19が筒子、21~29が索子、31~37が順に東南西北白発中を表す
        self.hai = [*range(1, 10), *range(11, 20), *range(21, 30), *range(31, 38)] * 4

        self.yama = self.hai.copy()
        
        self.kancounter = 0
        self.dorahyoujihai = []
        self.dorahyoujihai.append(self.yama[-(6 + self.kancounter * 2)])

    # カンした際に実行 カンドラが増える
    def kan(self):
        self.kancounter += 1
        self.dorahyoujihai.append(self.yama[-(6 + self.kancounter * 2)])

# 雀士クラス 雀士の手牌、河、自風、ツモの管理
# 向聴数計算、点数計算、打牌の選択を行う
class Janshi:
    def __init__(self):
        self.name = ''

        self.jikaze = EAST
        self.jikaze_str = '東家'

        self.tehai = []
        self.kawa = []
        self.tenbou = 25000

        self.riichi = False

        # mahjongライブラリに読み込むための変数
        self.manzu = ''
        self.pinzu = ''
        self.souzu = ''
        self.jihai = ''

    def haipai(self, yama):
        self.tehai =  yama[0:13]
        del yama[0:13]

    def tsumo(self, yama):
        hai = yama[0]
        del yama[0]
        self.tehai.append(hai)
        return hai

    def riipai(self):
        self.tehai = sorted(self.tehai)

    # 牌をmahjongライブラリに読み込める形式に変換する
    # また、牌を読みやすい文字列に変換した配列を返す
    def tehaitiles(self, tehai = []):
        self.manzu = ''
        self.pinzu = ''
        self.souzu = ''
        self.jihai = ''
        haihyouji = []
        if tehai == []:
            tehai = self.tehai
        for hai in tehai:
            if hai < 10:
                self.manzu += str(hai)
                haihyouji.append(str(hai) + '萬')
            elif 10 < hai < 20:
                self.pinzu += str(hai - 10)
                haihyouji.append(str(hai - 10) + '筒')
            elif 20 < hai < 30:
                self.souzu += str(hai - 20)
                haihyouji.append(str(hai - 20) + '索')
            elif 30 < hai < 38:
                self.jihai += str(hai - 30)
                haihyouji.append('東南西北白発中'[hai - 31])
        return haihyouji

    # 向聴数を求める
    def shantensuu(self, tehai = []):
        if tehai == []:
            tehai = self.tehai
        shanten = Shanten()
        self.tehaitiles(tehai)
        tiles = TilesConverter.string_to_34_array(man=self.manzu, pin=self.pinzu, sou=self.souzu, honors=self.jihai)
        result = shanten.calculate_shanten(tiles)
        return result

    # 点数計算を行う
    def agari(self, dora, bakaze):
        calculator = HandCalculator()
        self.tehaitiles(self.tehai)
        tiles = TilesConverter.string_to_136_array(man=self.manzu, pin=self.pinzu, sou=self.souzu, honors=self.jihai)
        self.tehaitiles([self.tehai[13]])
        win_tile = TilesConverter.string_to_136_array(man=self.manzu, pin=self.pinzu, sou=self.souzu, honors=self.jihai)[0]
        self.tehaitiles(dora)
        dora_indicators = [TilesConverter.string_to_136_array(man=self.manzu, pin=self.pinzu, sou=self.souzu, honors=self.jihai)[0]]
        melds = None
        config = HandConfig(is_tsumo=True, is_riichi=self.riichi, player_wind=self.jikaze, round_wind=bakaze)
        tehai_result = calculator.estimate_hand_value(tiles, win_tile, melds, dora_indicators, config)

        print(tehai_result.yaku)
        print(str(tehai_result.fu) + '符' + str(tehai_result.han) + '翻')
        if self.jikaze == EAST:
            print(str(tehai_result.cost['main']) + 'オール\n')
        else:
            print(str(tehai_result.cost['main']), str(tehai_result.cost['additional']) + '点\n')

        return tehai_result

    # 点棒を受け取る
    def get_tenbou(self, result):
        self.tenbou = self.tenbou + result.cost['main'] + result.cost['additional'] * 2

    # 点棒を支払う
    # 注意 第三引数oyaが0の時に親モード、0以外で子モード
    def lost_tenbou(self, result, oya):
        if oya == 0:
            self.tenbou -= result.cost['main']
        else:
            self.tenbou -= result.cost['additional']

    # 打牌の選択を行う
    def dahai(self):
        if self.riichi:
            hai = self.tehai[13]
            del self.tehai[13]
            return hai
        shantenvalue = [i for i in range(len(self.tehai))]
        for i in range(len(self.tehai)):
            shantenvalue[i] = self.shantensuu(self.tehai[:i] + self.tehai[i + 1:])
        hai = self.tehai[shantenvalue.index(min(shantenvalue))]
        del self.tehai[shantenvalue.index(min(shantenvalue))]
        self.kawa.append(hai)
        return hai

# ゲームを進行するクラス
class Game:
    def __init__(self):
        a = 0
        self.taku = Taku()
        self.janshi = [Janshi() for i in range(4)]

        self.junme = 1
        self.chiicha = 0
        self.oya = self.chiicha

        self.ryukyoku = False
        self.agari = False

    # プレイヤーに名前をつける
    def playername(self):
        for i in range(4):
            self.janshi[i].name = 'player' + str(i + 1)

    # その局における風を求める
    def kazegime(self):
        self.janshi[self.oya].jikaze = EAST
        self.janshi[self.oya].jikaze_str = '東家'
        self.janshi[(self.oya+1)%4].jikaze = SOUTH
        self.janshi[(self.oya+1)%4].jikaze_str = '南家'
        self.janshi[(self.oya+2)%4].jikaze = WEST
        self.janshi[(self.oya+2)%4].jikaze_str = '西家'
        self.janshi[(self.oya+3)%4].jikaze = NORTH
        self.janshi[(self.oya+3)%4].jikaze_str = '北家'

    # 親決めを行う
    def oyagime(self):
        self.chiicha = random.randint(0, 3)
        self.oya = self.chiicha
        self.kazegime()
        print('起家 ' + self.janshi[self.chiicha].name)

    def bakaze_str(self):
        if self.taku.bakaze == EAST:
            return '東'
        if self.taku.bakaze == SOUTH:
            return '南'
        if self.taku.bakaze == WEST:
            return '西'
        if self.taku.bakaze == NORTH:
            return '北'

    # 局の開始時の処理
    def kyokustart(self):
        self.junme = 1
        self.ryukyoku = False
        self.agari = False
        self.tobi = False
        for i in range(4):
            self.janshi[i].riichi = False

        self.oya = (self.oya + self.taku.kyoku - 1) % 4
        self.kazegime()

        print(str(self.bakaze_str()) + str(self.taku.kyoku) + '局')

        self.taku.yama = self.taku.hai.copy()
        random.shuffle(self.taku.yama)

        for i in range(4):
            idx = (self.oya + i) % 4
            self.janshi[idx].haipai(self.taku.yama)
            self.janshi[idx].riipai()
            print(self.janshi[idx].jikaze_str + 'の手牌')
            print(self.janshi[idx].tehaitiles(self.janshi[i].tehai))

        print('ドラ表示牌')
        print(self.janshi[0].tehaitiles(self.taku.dorahyoujihai))

    # 点棒のやり取りを行う
    def tenbou_koukan(self, idx, result):
        self.janshi[idx].get_tenbou(result)
        for i in range(4):
            if i != idx:
                self.janshi[i].lost_tenbou(result, i-self.oya)
    
    # 局終了時の処理
    def finish_kyoku(self):
        for i in range(4):
            print(self.janshi[i].name + ' ' + str(self.janshi[i].tenbou) + '点')
            if(self.janshi[i].tenbou < 0):
                print('トビ')
                self.tobi = True
        print('\n')

    # 1巡における処理
    def ichijun(self):
        print('\n' + str(self.junme) + '巡目')
        for i in range(4):
            # 東→南→西→北の巡で処理を行うための変数
            idx = (self.oya + i) % 4

            print(self.janshi[idx].jikaze_str + '(' + self.janshi[idx].name + ')' + 'の手番')
            print(self.janshi[idx].tehaitiles(self.janshi[idx].tehai))

            print('ツモ ' + self.janshi[idx].tehaitiles([self.janshi[idx].tsumo(self.taku.yama)])[0])

            shantensu = self.janshi[idx].shantensuu()

            # ロン、副露は未実装のため向聴数-1で和了
            if shantensu == -1:
                print('ツモ!')
                print(self.janshi[idx].jikaze_str + 'の和了')
                result = self.janshi[idx].agari(self.taku.dorahyoujihai, self.taku.bakaze)
                self.tenbou_koukan(idx, result)
                self.agari = True
                self.taku.kyoku += 1
                self.finish_kyoku()
                break

            print(str(shantensu) + '向聴')

            print('打 ' + self.janshi[idx].tehaitiles([self.janshi[idx].dahai()])[0])

            # 山が残り14牌になったら流局処理
            if len(self.taku.yama) == 14:
                print('流局\n')
                self.ryukyoku = True
                self.taku.kyoku += 1
                self.finish_kyoku()
                break

            # 聴牌し、山が残り17牌以上なら強制的に立直する
            if shantensu == 0 and not self.janshi[idx].riichi and len(self.taku.yama) > 17:
                print(self.janshi[idx].jikaze_str + 'のリーチ')
                self.janshi[idx].riichi = True

            self.janshi[idx].riipai()
            print('\n')
        self.junme += 1
```

#実行
以下のようにして1局だけ対局を実行した。

```python
game = Game()
game.oyagime()
game.playername()
for i in range(1):
    game.kyokustart()
    while 1:
        game.ichijun()
        if game.agari == True or game.ryukyoku == True:
            break
    if game.tobi == True:
        break
    if i == 3:
        game.taku.bakaze = SOUTH
        game.taku.kyoku = 1
```


#実行結果
長すぎるので途中3巡目～14巡目までは省略

```
起家 3
東1局
東家の手牌
['1萬', '2萬', '6萬', '8萬', '1筒', '1索', '5索', '7索', '8索', '9索', '南', '南', '北']
南家の手牌
['3萬', '4萬', '5萬', '9萬', '3筒', '4筒', '5筒', '6筒', '8索', '東', '発', '発', '中']
西家の手牌
['2萬', '5萬', '6萬', '6筒', '7筒', '4索', '4索', '7索', '東', '南', '西', '白', '中']
北家の手牌
['1萬', '2萬', '6萬', '8萬', '1筒', '1索', '5索', '7索', '8索', '9索', '南', '南', '北']
ドラ表示牌
['南']

1巡目
東家(player4)の手番
['1萬', '2萬', '6萬', '8萬', '1筒', '1索', '5索', '7索', '8索', '9索', '南', '南', '北']
ツモ 8筒
3向聴
打 1筒


南家(player1)の手番
['3萬', '4萬', '5萬', '9萬', '3筒', '4筒', '5筒', '6筒', '8索', '東', '発', '発', '中']
ツモ 白
3向聴
打 9萬


西家(player2)の手番
['2萬', '5萬', '6萬', '6筒', '7筒', '4索', '4索', '7索', '東', '南', '西', '白', '中']
ツモ 1筒
5向聴
打 2萬


北家(player3)の手番
['4萬', '4萬', '5萬', '7萬', '3筒', '7筒', '8筒', '4索', '7索', '8索', '西', '発', '中']
ツモ 4筒
3向聴
打 4索



2巡目
東家(player4)の手番
['1萬', '2萬', '6萬', '8萬', '8筒', '1索', '5索', '7索', '8索', '9索', '南', '南', '北']
ツモ 2筒
3向聴
打 8筒


南家(player1)の手番
['3萬', '4萬', '5萬', '3筒', '4筒', '5筒', '6筒', '8索', '東', '白', '発', '発', '中']
ツモ 3萬
3向聴
打 3萬


西家(player2)の手番
['5萬', '6萬', '1筒', '6筒', '7筒', '4索', '4索', '7索', '東', '南', '西', '白', '中']
ツモ 7筒
4向聴
打 5萬


北家(player3)の手番
['4萬', '4萬', '5萬', '7萬', '3筒', '4筒', '7筒', '8筒', '7索', '8索', '西', '発', '中']
ツモ 6索
2向聴
打 西

～省略～

15巡目
東家(player4)の手番
['6萬', '8萬', '8萬', '9萬', '6筒', '5索', '6索', '7索', '9索', '9索', '南', '南', '南']
ツモ 9筒
1向聴
打 6筒


南家(player1)の手番
['3萬', '4萬', '5萬', '7萬', '8萬', '4筒', '5筒', '6筒', '1索', '2索', '3索', '発', '発']
ツモ 6索
0向聴
打 6索


西家(player2)の手番
['7筒', '7筒', '4索', '4索', '5索', '東', '南', '西', '白', '白', '発', '中', '中']
ツモ 9索
2向聴
打 5索


北家(player3)の手番
['4萬', '4萬', '5萬', '6萬', '7萬', '4筒', '4筒', '7筒', '8筒', '9筒', '6索', '7索', '8索']
ツモ 5萬
0向聴
打 5萬



16巡目
東家(player4)の手番
['6萬', '8萬', '8萬', '9萬', '9筒', '5索', '6索', '7索', '9索', '9索', '南', '南', '南']
ツモ 白
1向聴
打 9筒


南家(player1)の手番
['3萬', '4萬', '5萬', '7萬', '8萬', '4筒', '5筒', '6筒', '1索', '2索', '3索', '発', '発']
ツモ 6萬
ツモ!
南家の和了
[Menzen Tsumo, Riichi]
30符2翻
1000 500点

player1 27000点
player2 24500点
player3 24500点
player4 24000点
```

ちゃんと麻雀が打てていると思います。
また、和了の処理と点棒のやり取りも出来ていることが分かります。

ちなみに誰も和了できないと

```
～省略～

南家(player3)の手番
['9萬', '9萬', '3筒', '4筒', '5筒', '5筒', '7筒', '8筒', '8筒', '6索', '9索', '9索', '中']
ツモ 3索
2向聴
打 3筒
流局

player1 25000点
player2 25000点
player3 25000点
player4 25000点
```

となり流局しますが、ノーテン罰符の処理は未実装なので点棒は動きません。

#これからやる(やりたい)こと
まずシステムとしてロン上がり、副露、連荘、ノーテン罰符の実装。
それからGUIで実行できるようにする。
GUIができたら勝手に対戦させるだけでなく自分で打てるようにする。
平行して打牌のアルゴリズムを強化し、まともな対戦ができるレベルまでもっていきたい。
特に打牌のアルゴリズムの強化に関しては現状でいくつかアイディアがあるので形にしたい。

#修正する(したい)点
今のところ三麻モードを実装する予定は無いが、ループの際に4をベタ打ちしてしまっているので修正する。
janshiクラスのメソッドtehaitiles、shantensuu、agariに関して、Janshiクラスの役割からかけ離れている気がするため独立したクラスにしたい。
Gameクラスに関してもメソッド数が多くなってしまっているのでなんとかしたい。項

#参考記事
mahjongライブラリの使用方法に関しては環境の項で記したURLと以下の記事を参考にさせていただきました。
https://qiita.com/FJyusk56/items/8189bcca3849532d095f

また、本ソースコードを作成するにあたり、クラス構成など以下の記事を参考にさせていただきました。
https://qiita.com/minowa/items/107a5a0f3e0d1fe04ef6

#次回以降の記事
[Pythonで麻雀を作る#2](https://qiita.com/JuneOgust/items/64215dbe88f551810e36)
コードの全体的な構成を整えた。また、コンソール上から打牌を選択できるモードを追加した。

[Pythonで麻雀を作る#2.5](https://qiita.com/JuneOgust/items/7051da9b6d87fa074f50)
立直、和了するか選択できるようにした。また、立直棒のやりとりを実装した。