koba::blog

小林聡: プログラマです

麻雀の局進行のプログラム方式

電脳麻将 では以下の2つのクラスのインスタンスが互いに通信することで局を進行させている。

Majiang.Game
牌山生成、配牌・自摸の通知、副露に対する適切な手番の割り当て、和了役判定などゲーム進行を司るクラス。麻雀サーバに相当する。
Majiang.Player
打牌選択、副露判断、立直判断、和了判断など対局者の判断を実装したクラス。クライアントに相当する。

その他に人間用のUIを備えたMajiang.UIがあるが、これはMajiang.Playerのサブクラスなので、通信部分のコードは共有している。

現在は同一ページ内に両クラスが存在するので、通信方式は関数呼出しである。麻雀サーバを作るのであれば、Majiang.Gameをサーバ側に、Majiang.Playerをクライアント側に配置し、WebSocketなどで通信することになるであろう*1

通信プロトコルは以下の通り。

Gameからの通知 Playerの応答 補足
開局 (kaiju) (なし) 半荘の開始
配牌 (qipai) (なし) 一局の開始
自摸 (zimo) 手番 打牌 (dapai)
槓 (gang) 暗槓・加槓
和了 (hule) ツモ和了
流局 (pingju) 九種九牌
手番以外
打牌 (dapai) 手番
手番以外
和了 (hule) ロン和了
副露 (fulou) チー・ポン・大明槓
副露 (fulou) 手番 打牌 (dapai)
大明槓の後
手番以外
槓 (gang) 手番
手番以外
和了 (hule) 槍槓
自摸 (gangzimo) 手番 打牌 (dapai)
槓 (gang) 槓が連続する場合
和了 (hule) 嶺上開花
手番以外
開槓 (kaigang) (なし)
流局 (pingju)
和了 (hule)
終局 (jieju) 半荘の終了

Gameからの通知にはPlayerの応答を待つもの(同期メッセージ)と待たないもの(非同期メッセージ)がある。同期メッセージは4人分の応答を待って次の処理へ進む。配牌、開槓は次の動作が続けて起こるため非同期メッセージとしている。開局、流局、和了、終局は非同期メッセージであるべきなのだが、ゲームの進行上人間の応答を待つところでは同期メッセージとしている*2

各メッセージの送受信データは 電脳麻将の牌譜形式 - koba::blog の形式を踏襲している。

Gameからの通知メッセージ

基本形式

メッセージは typedata からなる。type は上記の開局、配牌などの種別を表し、datatype ごとに形式が異なる。

開局 (kaiju)
type = 'kaiju';
data = {
    player:  ['私','下家','対面','上家'], // 対局者情報
    qijia:   2,                           // 起家
    hongpai: { m: 1, p: 1, s: 1 }         // 赤牌の数
};
player
牌譜の対局者情報と同じ。対局者名なので実際には使用しない*3
qijia
起家。牌譜の情報と同じ。上記の例では起家は仮西。Player側は場決めのときの自分の座席しか知らないので、起家と局数から自風を計算する。
hongpai
赤牌の数。牌譜にはこの情報はない*4が麻雀AIを実装する場合は必要な情報。上記の例では各色に1枚ずつ*5
配牌 (qipai)
type = 'qipai';
data = {
    zhuangfeng: 0,                                   // 場風
    jushu:      0,                                   // 局数
    changbang:  0,                                   // 積み棒の数
    lizhibang:  0,                                   // 立直棒の数
    defen:      [ 25000, 25000, 25000, 25000 ],      // 局開始時の持ち点
    baopai:     's7',                                // ドラ表示牌
    shoupai:    [ '', '', 'm299p1158s288z356', '' ]  // 配牌
};

牌譜の形式と同じだが、他家分の情報はマスクして通知する。上の例は西家への通知データ。

自摸 (zimo)
type = 'zimo';
data = { l: 0 /* 手番 */, p: 'm1' /* 自摸牌 */ };

牌譜の形式と同じだが、手番以外のPlayerには p は空文字にマスクして通知する。

打牌 (dapai)
type = 'dapai';
data = { l: 0 /* 手番 */, p: 'z6' /* 打牌 */ };

牌譜の形式と同じ。ツモ切り、および立直宣言の表し方も牌譜の形式にしたがう。

副露 (fulou)
type = 'fulou';
data = { l: 2 /* 手番 */, m: 'z666=' /* 副露面子 */ };

牌譜の形式と同じ。上記の例は西家が対面(東家)の發をポン。

槓 (gang)
type = 'gang';
data = { l: 2 /* 手番 */, m: 'z666=6' /* 副露面子 */ };

牌譜の形式と同じ。上記の例は西家が副露済みの發に加槓。暗槓の場合は、

type = 'gang';
data = { l: 3 /* 手番 */, m: 'z1111' /* 副露面子 */ };

となる。

自摸 (gangzimo)
type = 'gangzimo';
data = { l: 0 /* 手番 */, p: 'p0' /* 自摸牌 */ };

自摸と形式は同じだが type のみが異なる。Playerは gangzimo であれば嶺上開花和了できると判断できる。

開槓 (kaigang)
type = 'kaigang';
data = { baopai: 'p0' /* 槓ドラ表示牌 */ };

牌譜の形式と同じだが実装上の都合で発生順序が異なる。
「明槓のドラ後乗り」のルールの場合、

暗槓
gang → kaigang → gamgzimo → dapai (kaigang と gangzimo の順序が逆)
大明槓
fulou → gangzimo → kaigang → dapai (kaigang と dapai の順序が逆)
加槓
gang → gangzimo → kaigang → dapai (kaigang と dapai の順序が逆)

という順序になる。

流局 (pingju)
type = 'pingju';
data = {
    name:    '荒牌平局',                   // 流局理由
    shoupai: [ 'm678p66s12,s6-78,s555=',   // 流局時の手牌
               'm1177p456s340,z444=',
               '',
               'm44p12340,z2222=,p999=' ],
    fenpei:  [ 1000, 1000, -3000, 1000 ]   // 局収支
};

牌譜の形式と同じ。上記の例は西家のみがノー聴でノー聴罰符を支払う。

和了 (hule)
type = 'hule';
data = {
    l:         3,                             // 和了者
    shoupai:   'm78p405667s34577m9',          // 手牌
    baojia:    null,                          // 放銃者
    fubaopai:  ['s2','m4'],                   // 裏ドラ表示牌
    fu:        20,                            // 符
    fanshu:    5,                             // 翻数
    defen:     8000,                          // 供託をのぞいた和了点
    hupai: [                                  // 和了役名と翻数のリスト
        { name: '立直', fanshu: 1 },
        { name: '門前清自摸和', fanshu: 1 },
        { name: '平和', fanshu: 1 },
        { name: '赤ドラ', fanshu: 1 },
        { name: '裏ドラ', fanshu: 1 },
    ],
    fenpei:    [ -4200, -2200, -2200, 9600 ]  // 局収支
};

牌譜の形式と同じ。

終局 (jieju)
type = 'jieju';
data = {
    defen: [ 36700, 25300, 19700, 18300 ],  // 半荘終了時の得点
    rank:  [ 1, 2, 3, 4 ],                  // 順位
    point: [ 47, 5, -20, -32 ]              // オカ・ウマを含めたポイント数
};

defen
牌譜のそれと同じ。
rank
牌譜のそれと同じ。
point
牌譜のそれと同じ。電脳麻将のウマは +20, +10 -10, -20。

Playerからの応答メッセージ

基本形式

Gameからの通知メッセージ同様 typedata からなるが、data は構造を持たないただの文字列である。また、Player側の動作がない場合(表中の「−」のパターン)は

type = ''; data = null;

で応答する。

打牌 (dapai)
type = 'dapai'; data = 'm3_*';

打牌を麻雀の手牌の文字列表現 - koba::blogの「単独の牌」の形式で返す。上記の例は三萬自摸切り立直。

副露 (fulou)
type = 'fulou'; data = 'p78-9';

副露面子を「面子」の形式で返す。上記の例は 横八筒七筒九筒 という副露。

槓 (gang)
type = 'gang'; data = 's505+5';

暗槓または加槓した面子を「面子」の形式で返す。上記の例は 五索赤五索横五索 に対して 五索 を加槓。

和了 (hule)
type = 'fule'; data = null;

和了する意思だけ示す。Game側は場の状況から和了牌、放銃者を判断する。

流局 (pingju)
type = 'pingju'; data = null;

流局する意思だけ示す。Playerの意思で流局できるケースは九種九牌のみ。

次回は、Game、Player 間通信の実際の実装方法を説明する予定。

*1:将来的にはそれも実装したいのだが、Node.jsはCommonJSスタイルなので変更が大きそう

*2:天鳳のように開局画面があるのであれば、開局も同期メッセージになる

*3:将来的には対局者名に応じた打牌を選択... ということはないでしょう

*4:赤牌の数を知らなくても牌譜は再生可能

*5:プログラム実装上は赤牌なしや赤牌2枚ずつでも動作可能