Pythonで麻雀を作る#2

#前回まで
[Pythonで麻雀を作る#1](https://qiita.com/JuneOgust/items/25f9ca05ee068a1c611a)
麻雀の基本的なシステムを構築し、NPC同士で対局を行えるようにした。

#今回の目的
[前回](https://qiita.com/JuneOgust/items/25f9ca05ee068a1c611a)のコードを改良する。
赤ドラとノーテン罰符の処理を実装する。また、プレイヤーの入力に対応する。

#環境
Windows 10
Python 3.8.8
mahjong 1.1.11

#やったこと(変更点)
[ポーカーのコード](https://qiita.com/JuneOgust/items/94163c56f159fc00f917)のように、コードを6つのモジュールに分割し内容を整理した。
大きな変更点として、前回は牌は単純に整数で区別しており、関数にその値を入力することで牌の役割を識別させていたが、今回は牌1つ1つをオブジェクトにした。
それにともない全体的にコードの内容を編集した。
また、前回まで牌をmahjongライブラリに読み込む際に一度牌の種類ごとに文字列に変換し、それをさらにmahjongライブラリのTilesConverterというクラスにあるメソッドに読み込んで専用の形の配列に整形していたが、二度手間だったため、牌を直接mahjongライブラリに読み込める形式に変換する関数を作成した。
さらに、赤ドラとノーテン罰符の実装、コンソール上でのプレイヤーの操作への対応を行った。

#実装
今回は以下の6つのモジュールを作成した。
<details><summary>main.py</summary><div>

```python
import game

game = game.Game(hanchan=False, play=True, aka=True)
game.game()
```
</div></details>
<details><summary>game.py</summary><div>

```python
from mahjong.constants import EAST, SOUTH, WEST, NORTH
import random
import rule
import janshi
import taku

# ゲームを進行するクラス
class Game:
    WIND = {0: EAST, 1: SOUTH, 2: WEST, 3: NORTH}
    KAZE = {EAST: '東', SOUTH: '南', WEST: '西', NORTH: '北'}

    # hanchan=Falseで東風戦, Trueで半荘戦, play=FalseでNPC対戦の観戦モード, Trueでプレイヤーが操作するモード, aka=Trueで赤ドラあり(3枚)
    def __init__(self, hanchan=False, play=False, aka=False):
        a = 0
        self.taku = taku.Taku(aka)
        self.play = play
        self.kansen = not play
        self.aka = aka
        self.hanchan = hanchan
        
        self.janshi = [janshi.Janshi(self.play)]
        for i in range(3):
            self.janshi.append(janshi.Janshi())

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

        self.ryukyoku = False
        self.agari = False

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

    # その局における各家の風を求める
    def kazegime(self):
        for i in range(4):
            self.janshi[(self.oya+i)%4].jikaze = self.WIND[i]
            self.janshi[(self.oya+i)%4].jikaze_str = self.KAZE[self.janshi[(self.oya+i)%4].jikaze] + '家'

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

    # 現在の場風を文字列で返す
    def bakaze_str(self):
        return self.KAZE[self.taku.bakaze]

    # 局の開始時の処理
    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) + '局\n')

        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()

            # 観戦モードのときか自分の手牌のときだけ表示する
            if self.kansen or self.janshi[idx].play:
                print(self.janshi[idx].jikaze_str + '(' + self.janshi[idx].name + ')' + 'の手牌')
                print([hai.str for hai in self.janshi[idx].tehai])
                print('\n')

        print('ドラ表示牌')
        print([dora.str for dora in self.taku.dora_hyouji])

        # 高速で流れて見づらいため入力待機
        if self.play:
            input('Press any key')

    # 和了時の処理
    def agari_shori(self, agari_idx, tsumo):
        print(self.janshi[agari_idx].name + 'の和了')

        result = rule.Rule.agari(self.janshi[agari_idx].tehai, self.taku.dora_hyouji, tsumo, self.janshi[agari_idx].riichi, self.janshi[agari_idx].jikaze, self.taku.bakaze)
        print(result.yaku)
        print(str(result.fu) + '符' + str(result.han) + '翻')
        if self.janshi[agari_idx].jikaze == EAST:
            print(str(result.cost['main']) + 'オール\n')
        else:
            print(str(result.cost['main']), str(result.cost['additional']) + '点\n')
        self.janshi[agari_idx].get_tenbou(result.cost['main'] + result.cost['additional'] * 2)
        
        for i in range(4):
            if i != agari_idx:
                if i == self.oya:
                    self.janshi[i].lost_tenbou(result.cost['main'])
                else:
                    self.janshi[i].lost_tenbou(result.cost['additional'])

    # 流局時の処理
    def ryuukyoku_shori(self):
        self.ryukyoku = True
        number_of_tenpai = 0
        for janshi in self.janshi:
            shantensu = rule.Rule.shantensuu(janshi.tehai)
            if shantensu == 0:
                print(janshi.name + '聴牌')
                janshi.tenpai = True
                number_of_tenpai += 1
            else:
                print(janshi.name + '不聴')
                janshi.tenpai = False
        if number_of_tenpai != 0:
            for janshi in self.janshi:
                if janshi.tenpai:
                    janshi.get_tenbou(3000/number_of_tenpai)
                    janshi.tenpai = False
                else:
                    janshi.lost_tenbou(3000/(4-number_of_tenpai))

    # 局が終わったときの処理
    def finish_kyoku(self):
        self.taku.kyoku += 1
        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')
        if self.play:
            input('Press any key')


    # 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 + ')' + 'の手番')

            if self.kansen:
                print([hai.str for hai in self.janshi[idx].tehai], end=' ')
            elif self.janshi[idx].play:
                print([str(j) + ': ' + self.janshi[idx].tehai[j].str for j in range(len(self.janshi[idx].tehai))], end=' ')

            tsumohai = self.janshi[idx].tsumo(self.taku.yama)

            if self.kansen:
                print(tsumohai.str)
            elif self.janshi[idx].play:
                print('13: ' + tsumohai.str)

            shantensu = rule.Rule.shantensuu(self.janshi[idx].tehai)

            # 副露, ロン, 振聴は未実装のため向聴数-1で強制的に和了
            if shantensu == -1:
                self.agari_shori(idx, tsumo=True)
                self.agari = True
                self.finish_kyoku()
                break

            if self.kansen or self.janshi[idx].play:
                print(str(shantensu) + '向聴')

            if self.janshi[idx].play:
                while 1:
                    try:
                        sutehai = int(input('捨て牌の番号を入力してください(0~13) : '))
                    except ValueError:
                        print('0から13の整数を半角で入力してください')
                    if sutehai < 0 or 13 < sutehai:
                        print('0から13の整数を入力してください')
                    else:
                        break
                    
            else:
                sutehai = 13

            print('打 ' + self.janshi[idx].dahai(sutehai).str)

            # 山が残り14牌になったら流局処理
            if len(self.taku.yama) == 14:
                print('流局\n')
                self.ryuukyoku_shori()
                self.ryukyoku = True
                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

    # 全体のゲーム進行を行う
    def game(self):
        self.playername()
        self.oyagime()

        if self.hanchan:
            kyokusuu = 8
        else:
            kyokusuu = 4

        for i in range(kyokusuu):
            self.kyokustart()

            while 1:
                self.ichijun()
                if self.agari == True or self.ryukyoku == True:
                    break

            if self.tobi == True:
                break

            # 南入の処理
            if i == 3:
                self.taku.bakaze = SOUTH
                self.taku.kyoku = 1
```
</div></details>
<details><summary>janshi.py</summary><div>

```python
import rule
from mahjong.constants import EAST, SOUTH, WEST, NORTH

# 雀士クラス 雀士の手牌、河、自風、ツモの管理
# 向聴数計算、点数計算、打牌の選択を行う
class Janshi:
    # play=Trueとした場合, その雀士は自動で牌を切らずに入力された牌を切る
    def __init__(self, play=False):
        self.name = ''

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

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

        self.riichi = False
        self.tenpai =False

        self.play = play

    # 配牌をとる
    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, key = lambda t: t.number0to135)

    # 点棒を受け取る
    def get_tenbou(self, tensuu):
        self.tenbou += int(tensuu)

    # 点棒を支払う
    def lost_tenbou(self, tensuu):
        self.tenbou -= int(tensuu)

    # 打牌を行う
    def dahai(self, sutehai=13):
        # 立直状態での処理
        if self.riichi:
                hai = self.tehai[13]
                del self.tehai[13]
                return hai

        # プレイヤーが操作する場合の処理
        if self.play:
            hai = self.tehai[sutehai]
            del self.tehai[sutehai]
            self.kawa.append(hai)

        # NPCの場合の処理
        else:
            shantenvalue = [i for i in range(len(self.tehai))]
            for i in range(len(self.tehai)):
                shantenvalue[i] = rule.Rule.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
```
</div></details>
<details><summary>rule.py</summary><div>

```python
from mahjong.shanten import Shanten
from mahjong.hand_calculating.hand import HandCalculator
from mahjong.hand_calculating.hand_config import HandConfig, OptionalRules
from mahjong.meld import Meld

# 牌をmahjongライブラリに読み込める形式に変換する
class HaiList:
    # 向聴数の計算用
    def hai34list(tehai):
        hai34list = [0 for i in range(34)]
        for hai in tehai:
            hai34list[hai.kind * 9 + hai.number] += 1
        return hai34list

    # 点数計算用
    def hai0to135list(tehai):
        hai0to135list = [hai.number0to135 for hai in tehai]    
        return hai0to135list

class Rule:
    # 向聴数を求める
    def shantensuu(tehai):
        shanten = Shanten()
        tiles = HaiList.hai34list(tehai)
        result = shanten.calculate_shanten(tiles)
        return result

    # 点数計算を行う
    def agari(tehai, dora, tsumo, riichi, jikaze, bakaze):
        aka = tehai[0].akaari
        calculator = HandCalculator()
        tiles = HaiList.hai0to135list(tehai)
        win_tile = HaiList.hai0to135list([tehai[13]])[0]
        dora_indicators = HaiList.hai0to135list(dora)
        dora_indicators = None
        melds = None
        config = HandConfig(is_tsumo=tsumo, is_riichi=riichi, player_wind=jikaze, round_wind=bakaze,  options=OptionalRules(has_aka_dora=aka, has_open_tanyao=True))
        tehai_result = calculator.estimate_hand_value(tiles, win_tile, melds, dora_indicators, config)
        return tehai_result
```
</div></details>
<details><summary>taku.py</summary><div>

```python
import hai
from mahjong.constants import EAST, SOUTH, WEST, NORTH

# 卓クラス 山, 局数, 場風, ドラ表示牌の管理
class Taku:
    def __init__(self, aka=False):
        self.bakaze = EAST
        self.kyoku = 1
        self.hai = []
        self.aka = aka

        self.hai = [hai.Hai(i, self.aka) for i in range(136)]

        self.yama = self.hai.copy()

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

    # カンした際に実行 カンドラが増える(カン自体は未実装)
    def kan(self):
        self.kancounter += 1
        self.dora_hyouji.append(self.yama[-(6 + self.kancounter * 2)])
```
</div></details>
<details><summary>hai.py</summary><div>

```python
# 牌の種類などを管理する
class Hai:
    KIND = {0: '萬', 1: '筒', 2: '索', 3: '東', 4: '南', 5: '西', 6: '北', 7: '白', 8: '発', 9: '中'}
    AKADORA = {16: '赤5萬', 52: '赤5筒', 88: '赤5索'}

    # number0to135に0から135の整数を入力するとその牌の内容が生成される
    # self.kindは0~3までありそれぞれ萬子筒子索子字牌を表す
    # self.numberは数牌では数字(-1)を表し、字牌では0から順に東南西北白発中を表す
    # self.akaariは赤ドラが存在するゲームの際に全ての牌においてTrueとなる
    # self.akahaiはその牌自体が赤ドラの際にTrueとなる
    def __init__(self, number0to135, aka=False):
        self.number0to135 = number0to135
        self.akaari = aka
        self.akahai = False

        # 数牌の場合の処理
        if self.number0to135 < 108:
            self.kind = self.number0to135 // 36
            self.number = self.number0to135 // 4 - self.kind * 9
            if aka and self.number0to135 in self.AKADORA:
                self.str = self.AKADORA[self.number0to135]
                self.akahai = True
            else:
                self.str = str(self.number + 1) + self.KIND[self.kind]

        # 字牌の場合の処理
        else:
            self.kind = 3
            self.number = (self.number0to135 - 108) // 4
            self.str = self.KIND[self.kind + self.number]
```
</div></details>

#実行結果
main.pyの実行結果だが、内容的にはほとんど前回と変わらないため、変更点だけ示す。

以下のように赤牌のある和了に対応できていることが分かる。

<details><summary>実行結果(赤ドラ)</summary><div>

```
18巡目
東家(NPC3)の手番
['1筒', '1筒', '9筒', '9筒', '赤5索', '5索', '7索', '7索', '東', '東', '西', '西', '北']
ツモ 北
NPC3の和了
[Menzen Tsumo, Riichi, Chiitoitsu, Aka Dora 1]
25符5翻
4000オール

NPC1 20000点
NPC2 24000点
NPC3 33000点
NPC4 23000点
```
</div></details>
また、以下の通りノーテン罰符の処理に関しても正常に動作していることが分かる。

<details><summary>実行結果(ノーテン罰符)</summary><div>

```
18巡目
東家(NPC4)の手番
['1筒', '2筒', '4筒', '6筒', '7筒', '8索', '9索', '西', '西', '北', '発', '発', '中'] 9筒
3向聴
打 1筒


南家(NPC1)の手番
['8萬', '9萬', '1筒', '2筒', '3筒', '4筒', '5筒', '6筒', '8筒', '8筒', '8筒', '南', '南'] 白
0向聴
打 白
流局

NPC1聴牌
NPC2不聴
NPC3聴牌
NPC4不聴
NPC1 26500点
NPC2 23500点
NPC3 26500点
NPC4 23500点
```

</div></details>
さらに、play=Trueとした際の実行結果を示す。
このモードでは、他家の情報は捨て牌のみが表示されるようになる。

<details><summary>実行結果(プレイヤー操作モード)</summary><div>

```
起家 NPC2

東1局

西家(Player)の手牌
['4萬', '7萬', '2筒', '7筒', '7筒', '8筒', '1索', '3索', '5索', '8索', '西', '北', '白']


ドラ表示牌
['発']
Press any key

1巡目
東家(NPC2)の手番
打 5萬


南家(NPC3)の手番
打 5筒


西家(Player)の手番
['0: 4萬', '1: 7萬', '2: 2筒', '3: 7筒', '4: 7筒', '5: 8筒', '6: 1索', '7: 3索', '8: 5索', '9: 8索', '10: 西', '11: 北', '12: 白'] 13: 1筒
5向聴
捨て牌の番号を入力してください(0~13) : 13
打 1筒
```
</div></details>

```Press any key``` と ```捨て牌の番号を入力してください(0~13) : ```の部分が入力受付で、後者のところで0~13の数字を打つことで手牌中の対応した番号の牌が切られていることがわかる。
ちなみに操作モードでも立直出来る状態になったら勝手に立直し、和了出来る状態になったら勝手に和了する(要改善)。
#今後やりたいこと
振聴の判定かGUIの実装をやりたい。
副露の判定は処理を考えているが難しい。

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