#前回まで
Pythonで麻雀を作る#1
麻雀の基本的なシステムを構築し、NPC同士で対局を行えるようにした。
#今回の目的
前回のコードを改良する。
赤ドラとノーテン罰符の処理を実装する。また、プレイヤーの入力に対応する。
#環境
Windows 10
Python 3.8.8
mahjong 1.1.11
#やったこと(変更点)
ポーカーのコードのように、コードを6つのモジュールに分割し内容を整理した。
大きな変更点として、前回は牌は単純に整数で区別しており、関数にその値を入力することで牌の役割を識別させていたが、今回は牌1つ1つをオブジェクトにした。
それにともない全体的にコードの内容を編集した。
また、前回まで牌をmahjongライブラリに読み込む際に一度牌の種類ごとに文字列に変換し、それをさらにmahjongライブラリのTilesConverterというクラスにあるメソッドに読み込んで専用の形の配列に整形していたが、二度手間だったため、牌を直接mahjongライブラリに読み込める形式に変換する関数を作成した。
さらに、赤ドラとノーテン罰符の実装、コンソール上でのプレイヤーの操作への対応を行った。
#実装
今回は以下の6つのモジュールを作成した。
main.py
import game
game = game.Game(hanchan=False, play=True, aka=True)
game.game()
game.py
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
janshi.py
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
rule.py
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
taku.py
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)])
hai.py
# 牌の種類などを管理する
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]
#実行結果
main.pyの実行結果だが、内容的にはほとんど前回と変わらないため、変更点だけ示す。
以下のように赤牌のある和了に対応できていることが分かる。
実行結果(赤ドラ)
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点
実行結果(ノーテン罰符)
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点
実行結果(プレイヤー操作モード)
起家 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筒
Press any key
と 捨て牌の番号を入力してください(0~13) :
の部分が入力受付で、後者のところで0~13の数字を打つことで手牌中の対応した番号の牌が切られていることがわかる。
ちなみに操作モードでも立直出来る状態になったら勝手に立直し、和了出来る状態になったら勝手に和了する(要改善)。
#今後やりたいこと
振聴の判定かGUIの実装をやりたい。
副露の判定は処理を考えているが難しい。
#次回以降の記事
Pythonで麻雀を作る#2.5
立直、和了するか選択できるようにした。また、立直棒のやりとりを実装した。