麻雀和了り判定

麻雀の和了判定のアルゴリズムを説明する。探せばあちらこちらに転がっているが、より実践的なものをここでは扱う。何箇所かはここここを参考にしているため、あわせて参照されたし。

下準備

まず、前提として牌の情報を以下のような形で持ち、std::listあたりにぶち込んでおく。

  • 萬子:0〜8
  • 索子:9〜17
  • 筒子:18〜26
  • 字牌:27〜33

牌はそれぞれ四枚あるので、その全てにIDを割り振る。34は二枚目の一萬となり、pie%34で種類を割り出すことが出来る。(尚、ややこしいようなら一色につき10IDを割り当てても良い(後述))
ゲーム部分ではこれを使って手牌listやら山listやらを作り、牌をやり取りする。

和了り判定準備

で、手牌が14枚になるたび、和了り判定を行う。まずは下準備。上で利便性のためバラバラに持っていた牌データをひとまとめにする。int[38]の配列を作り、持っている牌を持っている枚数分だけカウントする。

int NewHand[38]; // ここに入れる。
int tmp[38];	// あとで使う。
const int PieSet[34] = {
	1,2,3,4,5,6,7,8,9,
	11,12,13,14,15,16,17,18,19,
	21,22,23,24,25,26,27,28,29,
	31,32,33,34,35,36,37
};
int main() {
	list<int> hand; // この中に手牌データがあると仮定。長さ14。
	for(list<int>::iterator it=hand.begin();it!=hand.end();++it) {
		NewHand[PieSet[*it%34]]++;
	}
	return 0;
}

こんな感じで変換。NewHandの中では牌種類は以下のようになる。

  • 萬子:1〜9
  • 索子:11〜19
  • 筒子:21〜29
  • 字牌:31〜37
  • 0,10,20,30は未使用

例えば五筒の刻子を持っていた場合、NewHand[25]==3となる。
これがさっき後述といった一色につき10IDである。何故このようにするかというと、一の位をそろえることにより直感的な扱いが出来るため、断幺やら三色同順やらの計算が楽になるからである。

基本的な考え方

まず、NewHand[38]を同じ大きさのint型、tmp[38]にぶち込む。そしてtmpの中から、二枚以上ある牌を見つけてそれを雀頭とし、牌数をマイナスする。その後、刻子・順子の条件を満たす牌を順次マイナスし、tmpが空っぽになれば和了りである。

/* 全部使い切ってたら1を返す関数 */
int CheckUseOut(){
	for(int i=0; i<38; i++) if(tmp[i]>0) return 0;
	return 1;
}

尚、NewHand、tmpは分かりやすさのためこの記事ではグローバル変数として持っておく。ポインタの概念が分かってる人にはいちいちチェック関数にtmpのポインタを渡すとよい。

刻子・順子チェック

刻子と順子のチェックは以下のような関数で行う。刻子に変な引数があるのは後で説明する。

int Income;	// 和了り牌
int Head;	// 雀頭
int Time[4];	// 刻子
int Order[4];	// 順子	
int CntT,CntO;	// 刻子,順子のカウント
// 刻子チェック
void c_Time(int num) {
	for(int i=0;i<38;i++) {
		if(tmp[i] >= 3) {
			tmp[i] -= 3;
			Time[CntT++] = i;
			if (num>0&&CntT==num) return;		// 一個だけ抽出
		}
	}
}
// 順子チェック
void c_Order() {
	for(int i=0;i<38-10;i++) {
		if (i%10>7) continue;
		while(tmp[i]>0&&tmp[i+1]>0&&tmp[i+2]>0){
			tmp[i]--; tmp[i+1]--; tmp[i+2]--;
			Order[CntO++] = i;
		}
	}
}

Income・Headはあとで使う。Time・Orderにはそれぞれ刻子、順子の牌(順子の場合は先頭の牌)を入れる、CntT・CntOはカウントしている数を入れる。これらは和了り判定には関係ないが、後々の役判定に使うため、この時点でカウントしている。

判定の流れ

  • フリテンチェック

最後に追加された牌がフリテンだったら即return。和了れない。

国士無双だけは特殊なカウントをしなければいけないので別途行う。W役満なしならこの時点で計算終了。ありなら別に最初でなくとも良い。

int CheckKokushi() {
	bool hd=false;
	for (int i=0;i<38;i++) {
		switch(i) { 
			case 1: case 9: case 11: case 19: case 21: case 29: 
			case 31: case 32: case 33: case 34: case 35: case 36: case 37: 
				if (!hd&&tmp[i]==2) {
					tmp[i]-=2;
					hd=true;
				} else if (tmp[i]==1) {
					tmp[i]--;
				}
				break;
			default: break;
		}
	}
	return CheckUseOut();
}
for(int k=0;k<38;k++){
	ResetTemp();

	if(tmp[k] >= 2) {
		// 雀頭
		tmp[k] -= 2; Head=k;
		// 刻子→順子
		c_Time();
		c_Order();
		if (CheckUseOut()) PutScoreToHighScore();
		// 順子→刻子
		ResetTemp();
		tmp[k] -= 2; Head=k;
		c_Order();
		c_Time();
		if (CheckUseOut()) PutScoreToHighScore();
		// 刻子取り出し→順子→刻子
		ResetTemp();
		tmp[k] -= 2; Head=k;
		c_Time(1);
		c_Order();
		c_Time();
		if (CheckUseOut()) PutScoreToHighScore();

	}
}

まず、使用する変数・配列をリセットする。ResetTemp()の中身は以下の通りである。

void ResetTemp() {
	memcpy(tmp,NewHand,sizeof(tmp));
	memset(Time,0,sizeof(Time));
	memset(Order,0,sizeof(Order));
	Head=CntT=CntO=0;
}

そして、kを0〜37までループさせ、tmpを一周チェックする。二枚以上存在していたらそれを雀頭とし、刻子順子チェックの開始である。
始めに刻子→順子の順番に抜き出してチェックを行う。その後、逆の順番に抜き出してチェックを行う(参照したサイトでは前者だけで良いと書いてあるが、誤りである)。
そして最後に、特殊なチェック:刻子を一つだけ抜き出した上で順子を抜き出し、再び刻子を抜き出すというチェックを行う。その理由は、次のような手牌に対応するためだ。
222334455567発発
この手牌を刻子→順子で取り出すと、334467が余ってしまい判定が手詰まりになる。順子→刻子で取り出すと、255が余ってしまい、またしても判定が正常に行われない。最初に222だけを取り出した後、順子を全て抜き出すことで、初めて正しく判定を行うことが出来る。(最後の刻子取出しは未知なるケースに対応するための念押しであるが、要らないかもしれない)
もしいずれかのケースで和了りが確認された場合、その時点で得点を計算し、現在のカウントしてる最も高いスコアより高ければ代入する関数PutScoreToHighScore()を呼び出す。中身はそのままなので割愛する。これをreturnとしてしまうと、処理は軽くなるが解釈の複数ある手牌(平和がらみなど)の得点が通常より低くなってしまったりする。上手くアルゴリズムを突き詰めれば回避することも可能だが、煩雑になるし麻雀を熟知していなければ分からないので今回は単純なハイスコアとの比較を用いる。

もし此処まで和了りがなければ、七対子の計算を行う。(七対子が可能な手牌で七対子より低い点数になるものは恐らく存在しないため、和了りがあったならスキップして良い)

{
	ResetTemp();
	for (int i=0;i<38;i++) if (tmp[i]==2) tmp[i]-=2;
	if (CheckUseOut()) PutScoreToHighScore();
}

和了り役・得点の判定

ここには全てを載せない。その数は膨大で、手段としてはそれをしらみつぶしに判定していくしかないからだ。幾つかサンプルを載せるので、これらを元に自分で作り上げて欲しい。ちなみにルールによっても若干変わる。(十七歩では刻子=暗刻であったりカンがなかったりで大分楽だった)
方法としては__int64(long long int)型のScoreに、ビット演算で役を入れていく。

#define MJ_CHOUSANGEN 0x00000001
Score |= MJ_CHOUSANGEN;

全役の判定が終了したら一つずつ洗い出し、改めて飜数を数えていく。役と飜の対応がスッキリとし、判定された役が簡単に確認できるので良い。

if (Income==Head&&CntT==4&&!Pon()) Score |= MJ_SUANKO;

Incomeは和了り牌である。CntTはさっき計算していた、刻子の数だ。Pon()は割愛するが、ポンをしているかどうかの判定である。四暗刻はやるべきことをきちんと事前にやっていれば、非常に簡単な処理となる。

if (NewHand[35]) {
	int cnt=0;
	for (int i=0;i<4;i++) cnt += CheckWhichEver(3,Time[i],35,36,37);
	if (cnt>=3) My.Score|=MJ_DAISANGEN;
}

CheckWhichEverは二番目の値がそれ以降の値のうちどれかであれば1を返す関数で、役判定においては多用するので以下のような感じで実装すべし。

/* 左値が他のいずれかの値であるかのチェック */
int CheckWhichEver(int howmany,int val,...) {
	va_list ap;
	va_start(ap,val);
	for (int i=0;i<howmany;i++) {
		if (val==va_arg(ap,int)) {
			va_end(ap);
			return 1;
		}
	}
	va_end(ap);
	return 0;
}

大三元のチェックはカウントされた刻子を見て、白発中があるかどうか確かめるというものである。

if (CheckWhichEver(13,Head,1,9,11,19,21,29,31,32,33,34,35,36,37)) {
	bool h=true,j=true;
	int cnt=0;
	if (Head>30) j=false;
	if (CntT<4) h=false;
	for (int i=0;i<4;i++) {
		cnt += CheckWhichEver(13,Time[i],1,9,11,19,21,29,31,32,33,34,35,36,37);
		if (Time[i]>30) j=false;
	}
	if (!h) { for (int i=0;i<4;i++) cnt += CheckWhichEver(6,Order[i],1,7,11,17,21,27); }
	if (cnt==4) {
		if (h) My.Score |= MJ_HONROTO;
		else if (j) My.Score |= MJ_JUNCHAN;
		else My.Score |= MJ_CHANTA;
	}
}

重複しない役であるため、一気に三つ判定する。全ての刻子・順子が端にあるか(或いは字牌か)を判定し、そうであった場合、全てが刻子なら混老頭字牌が含まれていないなら純全帯、それ以外なら全帯のフラグを立てる。
他にも重複しない役は一括でチェックすることにより、重なることを防ぐことができる。

{
	int c=-1;
	bool hon=FALSE;
	for (int i=0;i<30;i++) {
		if (NewHand[i]>0) {
			if (c>=0&&i/10!=c) goto NO_ONECOLOR;
			else if (c<0) c=i/10;
		}
	}
	for (int i=31;i<38;i++) {
		if (NewHand[i]>0) hon=TRUE;
	}
	if (hon) My.Score|=MJ_HONITSU;
	else My.Score|=MJ_CHINITSU;
}
NO_ONECOLOR:

最初に見つけた色と違う色を見つけたらその時点で退場。その後字牌を見つけたら混一色へ。手牌が成立していることは確実なので、単純にそぐわない色のチェックをするだけ。

  • 平和
if (!Chi()&&CntO==4&&!CheckWhichEver(5,Head,Chanfon,Menfon,PIE_HAKU,PIE_HATSU,PIE_CHUN)) {
	for (int i=0;i<4;i++) {
		if (Order[i]%10==1&&Income==Order[i]+2||Order[i]%10==7&&Income==Order[i]) {}
		else if (Income==Order[i]||Income==Order[i]+2) My.Score|= MJ_PINFU;
	}
}

Chi()はチーしているかをチェックする関数である。順子が四つあり、役牌が含まれて居ない(Chanfon,Menfonは場風、自風の入ったint型変数)場合、チェックを開始する。辺張待ちでなく、かつ順子の端が和了り牌だった場合、ピンフフラグを立てる。

まとめ

色々細かい点は割愛したが、一通りの流れを説明した。カンやドラなど触れていない面倒な点も多々あるが導入ということでご容赦をば。
これであなたも麻雀ゲームを作れる!……と言いたい所だが、CPUの思考や卓の処理など面倒な問題が山積みなのでまだまだ道は険しいかもしれない。