電脳麻将UI 〜 対局者(ネット対戦)

電脳麻将UI 〜 対局者(対AI戦) - koba::blog に続いて、今回はネット対戦です。変更は以下の4点です。

  • WebSocket通信
  • 描画担当の変更
  • 時間切れ対応
  • 開局画面への応答

対AI戦は スタンドアロン型 の実装でしたが、ネット対戦は クライアント・サーバ型 の実装になります。

WebSocket通信

スタンドアロン型では対局エンジン(Majiang.Game)と対局者(Majiang.UI.Player)は非同期に互いにメソッド呼出ししていましたが、クライアント・サーバ型では WebSocket で通信します(実装には Socket.IO を使っています)。ですが、クライアント側では スタートアップルーチン に WebSocket のセットアップと操作を任せており、Majiang.UI.Player 自体には変更はありません。

        let players = [], seq = 0;
        sock.removeAllListeners('GAME');    // 過去のハンドラを無効にする
        sock.on('GAME', (msg)=>{            // 新たにハンドラを設定する
            if (msg.players) {                  // 対局者通知の場合
                players = msg.players;              // 対局者の情報を保存する
            }
            else if (msg.seq) {                 // 対局に関する通知の場合
                if (seq && msg.seq != seq) location.reload();
                                                    // 通信漏れを検知したら
                                                    // 再接続する
                player.action(msg, (rep = {})=>{    // 応答方法をWebSocketとして
                                                    // action() を呼び出す
                    rep.seq = msg.seq;
                    sock.emit('GAME', rep);             // WebSocketで応答する
                    seq = msg.seq + 1;
                });
            }
            else if (msg.say) {                 // 発声に関する通知の場合
                player._view.say(msg.say.name, msg.say.l);  // 発声する
            }
            else {                              // 再接続の場合
                player.action(msg);                 // 初期設定後
                if (msg.kaiju && msg.kaiju.log) {   // 盤面を復元する
                    let log = msg.kaiju.log.pop();
                    for (let msg of log) {
                        player.action(msg);
                    }
                }
            }
            player._view.players(players);  // 対局者の情報を再描画する
        });

action() 呼び出しの第2引数のコールバック関数に注目してください。コールバック関数内で Socket.IO を使用した応答を行っており、Majiang.UI.Play はスタンドアロン型 / クライアント・サーバ型を意識する必要がありません。

回線遅延などで通知/応答のタイミングがずれることがあるため、通知メッセージ / 応答メッセージ にプロパティ seq を追加しています。これでどの通知に対する応答か明確になり、サーバ側では遅れてきた応答を捨てることが可能になります。クライアント側では対応のずれを検知した場合はブラウザをリロードします。再接続の際にサーバ側からはそれまでの牌譜が送られてくるので、クライアント側はそれを使って画面や状態を回復します。

他の対局者の回線切断をクライアント側でも把握できるよう、通知メッセージに players を追加しました。この中に現在の接続状態が含まれています*1

描画担当の変更

スタンドアロン型では対局エンジンが自ら盤面描画の指示を出していますが、クライアント・サーバ型では対局エンジンはサーバ側にあり指示は出せません。代わりに対局者UI側で指示します(第2層 で行っています)が、描画自体は Majiang.UI.Board が行うため変更はありません。

時間切れ対応

スタンドアロン型ではAIとしか対戦しませんでしたが、クライアント・サーバ型では人間同士で対局するため「牛歩戦術」に備えて 持ち時間 を設定できるようにしました。持ち時間は以下の3つの要素から構成されます。

秒読み
全ての操作に最低限保証される時間。
持ち時間
秒読み後に使われる時間。使用した分だけ消化される。持ち時間を使い切った後は秒読み内に操作しなければならない。
応答時間
開始画面、和了画面、流局画面への応答時間。

これを見ると対局中は 秒読み + 持ち時間 で時間切れになることが分ります。持ち時間はサーバ側で決定し、通知メッセージの追加プロパティ timer に秒読みと持ち時間を送ってくる(和了画面などでは応答時間を秒読みとして使用する)ので、クライアント側では残り時間の表示と、時間切れの応答処理を行います*2

時間切れの検知はインターバルタイマで行っています。まず、現在時刻 + 秒読み + 持ち時間 で時間切れの時刻を決定し、時間切れまでの秒数が持ち時間未満となったときに残り時間の表示を開始し、最後の5秒には警告のbeep音も鳴らします。時間切れとなったときは空応答を返せば、サーバ側で「適切なデフォルトの動作」*3を選択するのですが、リーチの打牌選択と副露の面子選択中に時間切れになったときはデフォルトの動作を選択されるとリーチや鳴き自体がキャンセルされてしまいます。なので、これらのケースではこれぞれのケースでのデフォルトの応答を決めてそれで応答しています。

    constructor(root, pai, audio) {     // 引数に audio を追加
        super();
        this._root = root;
        this._mianzi = mianzi(pai)

        /* this.beep() で beep音が鳴るよう設定 */
        let beep = audio('beep');
        this.sound_on = true;
        this.beep = ()=>{
            if (this.sound_on) {
                beep.currentTime = 0;
                beep.play();
            }
        };
    }

コンストラクタに時間切れ警告のbeep音を鳴らすための処理を追加します。

    clear_handler() {
        this.clear_timer();
        /* ...... */
    }

    clear_timer() {
        delete this._default_reply;                 // デフォルトの応答をクリアする
        hide($('.timeout', this._root).text(''));   // 残り時間の表示を隠す
        this._timer_id = clearInterval(this._timer_id);
                                                    // インターバルタイマを停止する
    }

デフォルトの応答はインスタンス変数 _default_reply に設定します。ソースコードの説明は省略しますが、リーチ打牌選択中は可能な打牌の最初の1つ、副露面子選択中は可能な面子の最初の1つとしています。

    action(msg, callback) {
        this.clear_handler();           // ハンドラをクリアする
        if (msg.timer) {                // 持ち時間の指定がある場合は
            this.set_timer(msg.kaiju || msg.hule || msg.pingju, ...msg.timer);
                                        // 時間切れ監視のインターバルタイマを起動
        }
        super.action(msg, callback);    // 親クラスのメソッドを呼び出す
    }

第1層 のメソッド action() をオーバーライドし、時間切れ監視のインターバルタイマを起動します。

    set_timer(dialog, limit = 0, allowed = 0) {

        show($('.timeout', this._root).text(''));
                                                // 残り時間の表示を初期化
        if (dialog) hide($('.timeout.main', this._root));
                                                // ダイアログの応答待ちのときは
                                                // 手牌付近の残り時間の表示は不要
        let time_last;
        let time_limit = Date.now() + (limit + allowed) * 1000;
                                                // 時間切れとなる時刻を決定する
        /* 0.2秒間隔でインターバルタイマを起動する */
        this._timer_id = setInterval(()=>{
            let time_count = Math.ceil((time_limit - Date.now()) / 1000);
                                                // 時間切れまでの秒数を決定する
            if (time_count <= 0) {              // 時間切れの場合
                this.callback(this._default_reply); // デフォルトの応答を返す
                return;
            }

            /* 残り時間の表示をする場合 */
            if (time_count <= limit || time_count <= allowed) {
                if (! dialog) {
                    $('.timeout.main', this._root).width(
                        $('.shoupai.main .bingpai', this._root).width() + 20);
                                        // 手牌付近の残り時間の表示位置を調整
                }
                /* 秒が進んだときだけ残り時間を表示し警告音を鳴らす */
                if (time_last != time_count) {
                    $('.timeout', this._root).text(time_count);
                    if (time_count <= 5 && ! dialog) this.beep();
                    time_last = time_count;
                }
            }
        }, 200);
    }

インターバルタイマの処理では持ち時間の消費がはじまったタイミング、もしくは持ち時間を消費し切った後は秒読み開始のタイミングで残り時間を表示します。警告音を鳴らすのは最後の5秒間です。

開局画面への応答

ネット対戦では開局画面を表示するので、これに対する応答UIも追加しました。

    action_kaiju(kaiju) {
        if (! this._view) return this.callback();   // ネット対戦以外では空応答
        setTimeout(()=>{
            setSelector($('.kaiju .submit', this._root), 'kaiju',
                        { prev: null, next: null });
            $('.kaiju', this._root).on('click', ()=> this.callback());
        }, 500);                    // 前の画面との競合を避けるため 0.5 秒後に
                                    //イベントハンドラを設定
    }

デモ

以下で実際の動作を確認できます。

*1:アイコン画像も含まれていますが未使用です

*2:電脳麻将 ではサーバ側でも時間切れの処理を行うため、クライアントの動作不良で待たされることはありません

*3:例えば自摸ならツモ切りとなる