電脳麻将UI 〜 対局者(対AI戦)

Majiang.UI.Player では 電脳麻将UI 〜 対局者(基盤) - koba::blog で説明したように 第3層 応答送信層 のみを実装します。第1層 通知受信層 で受信する 通知メッセージ と第3層で応答する 応答メッセージ の対応は以下の通りです。

通知メッセージ 応答メッセージ 補足
開局 (kaiju) ゲームの開始
配牌 (qipai) 一局の開始
自摸 (zimo) 手番 打牌 (dapai)
槓 (gang) 暗槓・加槓
和了 (hule) ツモ和了
倒牌 (daopai) 九種九牌
手番以外
打牌 (dapai) 手番
倒牌 (daopai) テンパイ宣言
手番以外
和了 (hule) ロン和了
副露 (fulou) チー・ポン・大明槓
倒牌 (daopai) テンパイ宣言
副露 (fulou) 手番 打牌 (dapai)
大明槓の直後
手番以外
槓 (gang) 手番
手番以外
和了 (hule) ロン和了(槍槓)
槓自摸 (gangzimo) 手番 打牌 (dapai)
槓 (gang) 連続した暗槓・加槓
和了 (hule) ツモ和了(嶺上開花)
手番以外
開槓 (kaigang) (なし)
和了 (hule)
流局 (pingju)
終局 (jieju) ゲームの終了

選択UI

上記の表の応答を対局者は以下の3つの選択UIを使って選択します。

打牌選択UI

手牌をクリックして切る牌を選択します。リーチ宣言の際はテンパイとなる打牌のみを選択可能とし、その牌を点滅させます。

行動選択UI

打牌以外の行動はボタンで選択します。

面子選択UI

鳴き方が複数ある場合は、面子選択UIを併用します。

DOM構成

Majiang.UI.Plaer が期待するDOM構成は以下の通りです。

root
  .select-action.hide   /* 行動選択UI */
    .button.cansel          /* キャンセル */
    .button.daopai          /* ノー聴 */
    .button.chi             /* チー   */
    .button.peng            /* ポン   */
    .button.gang            /* カン   */
    .button.lizhi           /* リーチ */
    .button.rong            /* ロン   */
    .button.zimo            /* ツモ   */
    .button.pingju          /* 流局   */

  .select-mianzi        /* 面子選択UI */

  .shoupai.main
    .bingpai            /* 打牌選択UI */
      .pai
      /* ...... */
      .pai.zimo

  .dialog               /* 和了・流局ダイアログ */
    .submit

  .summary              /* 集計表 */
    .submit

Majiang.UI.Player

コンストラクタ

    constructor(root, pai) {
        super();                        // 基底クラス Majiang.Player の
                                        // コンストラクタを呼び出す
        this._root = root;              // 描画領域
        this._mianzi = mianzi(pai)      // 面子生成関数を生成

        this.clear_handler();           // すべてのUIをまず無効にする
    }

root で指定された表示領域に打牌選択UI、行動選択UI、面子選択UIを配置します。pai電脳麻将UI 〜 牌 - koba::blog で生成した関数で、面子選択UIに面子を描画するために 電脳麻将UI 〜 面子 - koba::blog で説明した関数を生成するために使用します

第3層 応答送信層 の実装

第3層のメソッドは通知メッセージと1対1に対応しています(槓自摸だけは自摸と同様に処理するため同一のメソッドを使う)。第5層 行動提示層 のメソッドを呼び出して得た行動を選択UIで提示し、ユーザーの選択から応答を決定し、応答メッセージとして送信します。

開局・配牌
    action_kaiju(kaiju) { this.callback() }

    action_qipai(qipai) { this.callback() }

空の応答を送信します。

自摸・槓自摸
    action_zimo(zimo, gangzimo) {

        /* 他の対局者の自摸のときは空応答を返す */
        if (zimo.l != this._menfeng) return this.callback();

        /* 和了可能なときは「ツモ」ボタンに和了応答を設定する */
        if (this.allow_hule(this.shoupai, null, gangzimo)) {
            this.add_action('zimo', ()=> this.callback({ hule: '-' }));
        }

        /* 流局(九種九牌)可能なときは「流局」ボタンに倒牌応答を設定する */
        if (this.allow_pingju(this.shoupai)) {
            this.add_action('pingju', ()=> this.callback({daopai: '-'}));
        }

        /* カン可能なときは「カン」ボタンに次の動作を設定する */
        let gang = this.get_gang_mianzi(this.shoupai);
        if (gang.length == 1) {         // 候補が1つ → 槓応答
            this.add_action('gang', ()=> this.callback({ gang: gang[0] }));
        }
        else if (gang.length > 1) {     // 候補が複数 → 面子選択UI
            this.add_action('gang', ()=> this.select_mianzi(gang));
        }

        /* リーチ後の場合はデフォルトの動作にツモ切り応答を指定して、行動選択UI
           を表示し、終了する */
        if (this.shoupai.lizhi) {
            this.select_action(()=> this.callback({ dapai: zimo.p + '_' }));
            return;
        }

        /* リーチ可能なときは「リーチ」ボタンにリーチ打牌選択UIを設定する */
        let lizhi = this.allow_lizhi(this.shoupai);
        if (lizhi.length) {
            this.add_action('lizhi', ()=> this.select_dapai(lizhi));
        }

        /* デフォルトの動作に打牌選択UIを指定して行動選択UIを表示する */
        this.select_action(()=> this.select_dapai());
    }

可能な行動があれば行動選択UIのボタンを設定します。ボタンが設定されていない場合、行動選択UIはデフォルトの動作に移るため、リーチ前ならば打牌選択UIが有効になります。暗槓や加槓は同時に複数できることがあるので、その場合は面子選択UIに移ります。

打牌
    action_dapai(dapai) {

        /* ノーテン宣言可能なときは「ノー聴」ボタンに空応答を設定する */
        if (this.allow_no_daopai(this.shoupai)) {
            this.add_action('daopai', ()=> this.callback());
        }

        /* 自身の打牌のときは、以下のデフォルトの動作を指定して行動選択UIを
           表示し、終了する */
        if (dapai.l == this._menfeng) {
            this.select_action(
                this.allow_no_daopai(this.shoupai)
                    ? ()=> this.callback({ daopai: '-' })
                                                // ノーテン宣言可能 → 倒牌
                    : ()=> this.callback()      // その他          → 空応答
            );
            return;
        }

        let d = ['','+','=','-'][(4 + this._model.lunban - this._menfeng) % 4];
        let p = dapai.p + d;

        /* 和了可能なときは「ロン」ボタンに和了応答を設定する */
        if (this.allow_hule(this.shoupai, p)) {
            this.add_action('rong', ()=> this.callback({ hule: '-' }));
        }

        /* 大明槓可能なときは「カン」ボタンに副露応答を設定する */
        let gang = this.get_gang_mianzi(this.shoupai, p);
        if (gang.length) {
            this.add_action('gang', ()=> this.callback({ fulou: gang[0] }));
        }

        /* ポン可能なときは「ポン」ボタンに次の動作を設定する */
        let peng = this.get_peng_mianzi(this.shoupai, p);
        if (peng.length == 1) {         // 候補が1つ → 副露応答
            this.add_action('peng', ()=> this.callback({ fulou: peng[0] }));
        }
        else if (peng.length > 1) {     // 候補が複数 → 面子選択UI
            this.add_action('peng', ()=> this.select_mianzi(peng));
        }

        /* チー可能なときは「チー」ボタンに次の動作を設定する */
        let chi = this.get_chi_mianzi(this.shoupai, p);
        if (chi.length == 1) {          // 候補が1つ → 副露応答
            this.add_action('chi', ()=> this.callback({ fulou: chi[0] }));
        }
        else if (chi.length > 1) {      // 候補が複数 → 面子選択UI
            this.add_action('chi', ()=> this.select_mianzi(chi));
        }

        /* 以下のデフォルトの動作を指定して行動選択UIを表示する */
        this.select_action(
            this.allow_no_daopai(this.shoupai)
                ? ()=> this.callback({ daopai: '-' })
                                                // ノーテン宣言可能 → 倒牌
                : ()=> this.callback()          // その他          → 空応答
        );
    }

可能な行動があれば行動選択UIのボタンを設定します。デフォルトの動作は通常は空応答ですが、ノーテン宣言可能なときは倒牌つまりテンパイ宣言となります*1。チーやポンは複数の鳴き方ができる場合があるので、その場合は面子選択UIに移ります。

副露
    action_fulou(fulou) {

        /* 他の対局者の副露のときは空応答を返す */
        if (fulou.l != this._menfeng) return this.callback();

        /* 大明槓のときは空応答を返す */
        if (fulou.m.match(/^[mpsz]\d{4}/)) return this.callback();

        /* 打牌選択UIを起動する */
        this.select_dapai();
    }

副露後の通常の動作は打牌選択ですが、大明槓にかぎり槓自摸となるため空応答を返します。

    action_gang(gang) {

        /* 自身の槓のときは空応答を返す */
        if (gang.l == this._menfeng) return this.callback();

        /* 暗槓のときは空応答を返す */
        if (gang.m.match(/^[mpsz]\d{4}$/)) return this.callback();

        let d = ['','+','=','-'][(4 + this._model.lunban - this._menfeng) % 4];
        let p = gang.m[0] + gang.m.slice(-1) + d;

        /* 和了可能なときは「ロン」ボタンに和了応答を設定する */
        if (this.allow_hule(this.shoupai, p, true)) {
            this.add_action('rong', ()=> this.callback({ hule: '-' }));
        }

        /* デフォルトの動作を指定ぜず行動選択UIを表示する */
        this.select_action();
    }

槓の後に可能な動作は槍槓だけです。

和了・流局・終局
    action_hule() {
        /* 和了・流局ダイアログをクリックしたときに空応答を返す */
        setSelector($('.dialog .submit', this._root), 'dialog',
                    { prev: null, next: null });
        $('.dialog', this._root).on('click', ()=> this.callback());
    }

    action_pingju() { this.action_hule() }

    action_jieju() {
        /* 集計表をクリックしたときに空応答を返す */
        setSelector($('.summary .submit', this._root), 'summary',
                    { prev: null, next: null });
        $('.summary', this._root).on('click', ()=> this.callback());
    }

可能な動作は空応答のみですが、ダイアログを閉じたタイミングで送信します。ダイアログ上には「OK」ボタンがありますが、これはダミーでダイアログは盤面全体を覆っており、どこを押しても反応します。

選択UIの実装

続いて各選択UIの実装を見てみましょう。

打牌選択UI

Majiang.UI.Board が描画 した 手牌 にイベントを「貼り付ける」ことで実装いています。

    select_dapai(lizhi) {

        /* 表示領域を特定する */
        const bingpai = $('.shoupai.main .bingpai', this._root);

        /* すべての打牌可能な牌について以下を繰り返す。リーチ宣言時は lizhi で
           指定された牌のみを対象とする */
        for (let p of lizhi || this.get_dapai(this.shoupai)) {
            let pai = p.slice(-1) == '_'
                        ? $(`.pai.zimo[data-pai="${p.slice(0,2)}"]`, bingpai)
                        : $(`.pai[data-pai="${p}"]`, bingpai)
                                            // ツモ切りの場合、ツモ牌を使う
            if (lizhi) {              // リーチ宣言時
                pai.addClass('blink');      // 打牌可能な牌を点滅させる
                p += '*';                   // リーチ宣言として打牌する
            }
            /* 打牌する牌を role="button" とし、イベントハンドラを設定する */
            pai.attr('role','button').on('click', (ev)=>{
                this.clear_dapai();                 // イベントハンドラをクリアし
                $(ev.target).addClass('dapai');     // 牌を dapai とマークし
                return this.callback({ dapai: p }); // 応答を送信する
            });
        }
        /* selector の効果を追加する */
        setSelector($('.pai[role="button"]', bingpai), 'dapai', { focus: -1 });
    }

電脳麻将の今回のリファクタリングでは スクリーンリーダー 対応を目指しています。スクリーンリーダー対応する場合

  1. クリック可能な要素は button 要素とするか、role="button" とし、スクリーンリーダー利用者がキーボードでもクリック動作ができるようにする
  2. ユーザーが操作する要素には tabindex を設定し、タブ移動可能とする

の対応が必要です。1 についてはアプリケーション側で「イベントハンドラ設定時に role="button" の指定をする」対応とし、2 については 電脳麻将UI 〜 selector - koba::blog で説明した selector の機能で対応しています。selector を使用すると、キーボードでボタン間の移動およびクリック、さらにはタッチデバイスで 選択 → 確定 のUIが可能となります。

恒久的でないイベントは、利用を終えたらハンドラを取り除く必要があるので、その処理もメソッド化しています。

    clear_dapai() {
        /* すべての牌の role を削除し、点滅も止める */
        $('.shoupai.main .bingpai .pai', this._root).removeAttr('role')
                                                    .removeClass('blink');
        /* selector の効果をキャンセルする */
        clearSelector('dapai');
    }
行動選択UI

各ボタンにその後の動作を行うハンドラを設定します。

    add_action(type, callback) {
        show($(`.select-action .button.${type}`, this._root)
                                                // 対象のボタンを特定し
                    .attr('role','button')      // role="button" とし 
                    .on('click', ()=>{          // イベントハンドラを設定する
                        this.clear_action();        // イベントハンドラをクリア
                        callback();                 // 指定された行動を実行
                        return false;               // イベントは伝播させない
                    }));
    }

type で指定されたボタンに callback で指定されたハンドラを設定します。ハンドラを設定したボタンは role="button" とします。

ボタンの設定が終わったら、それらを表示します。

    select_action(callback = ()=>this.callback()) {

        /* 表示領域を特定する */
        const buttons = $('.select-action', this._root);

        /* ボタンが設定されていないときはデフォルトの動作をして処理を終える */
        if (! $('.button[role="button"]', buttons).length) return callback();

        /*「キャンセル」ボタンにデフォルトの動作を設定する */
        this.add_action('cansel', callback);

        /* 背景をクリックされたときは「キャンセル」ボタンを押したとみなす */
        this._root.on('click', ()=>
            $('.select-action .button.cansel', this._root).trigger('click'));

        /* 手牌の幅に合わせて行動選択UIを表示する */
        show(buttons.width($('.shoupai.main .bingpai', this._root).width()));

        /* selector の効果を追加する */
        setSelector($('.button[role="button"]', buttons), 'action',
                    { focus: -1, touch: false });
    }

callback には「デフォルトの動作」を指定します。ボタンが1つも設定されなかった場合と、「キャンセル」ボタンが押された場合にデフォルトの動作を実行します。ここでも selector を使っています。

行動選択UIについてもハンドラの削除をメソッド化しておきます。

    clear_action() {
        const buttons = $('.select-action', this._root);
        this._root.off('click');        // 背景のクリックをキャンセルする
        clearSelector('action');        // selector の効果をキャンセルする
        hide($('.button', buttons).off('click').removeAttr('role'));
                                        // すべてのボタンのハンドラと role を
                                        // 削除し、非表示にする
        hide(buttons);                  // 行動選択UIを非表示にする
    }
面子選択UI

実際に候補となる面子をすべて表示し、選択を促します。

    select_mianzi(mm) {

        /* 表示領域を特定する */
        const mianzi = $('.select-mianzi', this._root);

        /* 面子を順に追加する */
        for (let m of mm) {
            let msg = m.match(/\d/g).length == 4 ? { gang: m } : { fulou: m };
                                            // 4枚なら槓応答、3枚なら副露応答
            mianzi.append(
                this._mianzi(m).attr('role','button')   // role="button" とし
                               .on('click', ()=>{       // ハンドラを設定
                                   this.clear_mianzi();     // ハンドラをクリア
                                   return this.callback(msg);
                               }));                         // 応答を送信
        }

        /* 手牌の幅に合わせて面子選択UIを表示する */
        show(mianzi.width($('.shoupai.main .bingpai', this._root).width()));

        /* selector の効果を追加する */
        setSelector($('.mianzi', mianzi), 'mianzi',
                    { forcus: null, touch: false });
        return false;
    }

mm で指定された面子を順に面子選択UIに追加します。面子の表示は 電脳麻将UI 〜 面子 - koba::blog で説明した関数で行います。

    clear_mianzi() {
        const mianzi = $('.select-mianzi', this._root);
        clearSelector('mianzi');            // selector の効果をキャンセルする
        $('.mianzi', mianzi).off('click');  // ハンドラをクリアする
        hide(mianzi);                       // 行動選択UIを非表示にする
        mianzi.empty();                     // 設定した面子を取り除く
    }

同様にハンドラの削除もメソッド化します。

応答送信

応答送信は指定のコールバック関数 this._callback() をラップしたメソッド callback()*2 で行います。

    callback(msg) {
        this.clear_handler();   // すべてのハンドラをクリアする
        this._callback(msg);    // 応答を送信する
        return false;
    }

以下のメソッドで設定したハンドラをすべてクリアしています。これにより、メソッド callback() で応答する限りハンドラのキャンセルが保証されます。

    clear_handler() {
        this.clear_action();            // 行動選択UIをクリアする
        this.clear_mianzi();            // 面子選択UIをクリアする
        this.clear_dapai();             // 打牌選択UIをクリアする
        $('.dialog, .summary', this._root).off('click');
                                        // 和了・流局ダイアログと集計表の
                                        // イベントをキャンセルする
        clearSelector('dailog');        // selector をキャンセルする
    }

デモ

以下で実際の表示を確認できます。

*1:ややこしいですが「ノー聴」ボタンを押したときの動作は「倒牌しない」ですので、その逆となります

*2:ややこしいですね