電脳麻将UI 〜 盤面

電脳麻将 では盤面を 卓情報 と呼ぶオブジェクトで表します。これを表示するのが @kobalab/majiang-uiMajiang.Ui.Board です。

卓情報は現在は以下の3つの場面で生成されます*1

  1. 対局中に対局エンジン(Majiang.Game)が生成
  2. 牌譜再生中に牌譜ビューア(Majiang.UI.Paipu)が牌譜から復元
  3. ネット対局中にクライアント(Majiang.UI.Player)が通信内容から復元

1 では対局エンジン自身が完全な卓情報を生成するのに対し、2 では牌譜を読みながら完全な情報を復元します。3 の場合は完全な卓情報はサーバー側にあり、クライアント側では相手の手牌は分からないまま不完全な卓情報しか生成できないところが異なります。Majiang.UI.Board は不完全な情報であっても表示しなければならない訳です*2

MVCモデル

上記の対局エンジン、牌譜ビューア、クライアントは、いずれも MVC モデルC (コントローラ)に相当するクラスです。C の役割は M (モデル)を更新し、その変化を V (ビュー)である Majiang.UI.Board に通知することですが、電脳麻将では通知方法を以下に定めています。

局面 呼出すメソッド 引数
開局(kaiju) kaiju() (なし)
配牌(qipai) redraw() (なし)*3
自摸(zimo) update() 自摸
打牌(dapai) 打牌
副露(fulou) 副露
槓(gang)
槓自摸(gangzimo) 槓自摸
開槓(kaigang) 開槓
和了(hule) 和了
流局(pingju) 流局
和了・流局後 (なし)
終局(jieju) summary() 牌譜
発声時 say() 種類, 手番

基本的には配牌時に redraw() で盤面全体を再描画し、その後は摸打が行われる度に update() で差分描画します。盤面の描画とチーなどの発声はタイミングを分けたいと思うので、発声時は独立して say() を呼ぶ設計としました。

DOM構造

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

root
  .score        /* 局数・本場・ドラ・持ち点 */
    .jushu          /* 局数 */
    .changbang      /* 本場 */
    .lizhibang      /* 供託 */
    .shan           /* 牌山 */
    .defen          /* 持ち点 */
      .main

  /* 対局者ごとの表示領域 */
  .player.main      /* 対局者名 */
  .shoupai.main     /* 手牌 */
  .he.main          /* 河 */
  .say.main         /* 発声 */

  .kaiju        /* 開局画面 */
    .title          /* 対局名 */
    .player         /* 対局者名 */
      .main

  .dialog       /* 和了・流局情報 */
  .summary      /* 全局の集計表 */

.player.say は対局者ごとの表示領域で、.main は視点の当たっている対局者、.xiajia はその下家、.duimian は対面、shangjia は上家を表します。.say は「チー」、「ポン」などの発声を表示する領域です。

Majiang.UI.Board

では Majiang.UI.Board の実装を見てみましょう。

/*
 *  Majiang.UI.Board
 */
"use strict";

const $ = require('jquery');

const Shoupai    = require('./shoupai');    // 手牌
const Shan       = require('./shan');       // 牌山
const He         = require('./he');         // 河
const HuleDialog = require('./dialog');     // 和了・流局ダイアログ
const summary    = require('./summary');    // 集計表

const { hide, show, fadeIn, fadeOut } = require('./fadein');
                                            // ユーティリティ関数

/* 定数定義 */
const class_name  = ['main','xiajia','duimian','shangjia'];
const feng_hanzi  = ['東','南','西','北'];
const jushu_hanzi = ['一局','二局','三局','四局'];

const say_text   = { chi:   'チー',
                     peng:  'ポン',
                     gang:  'カン',
                     lizhi: 'リーチ',
                     rong:  'ロン',
                     zimo:  'ツモ'    };

/* ...... */

module.exports = class Board {

    /* ...... */
}

ます、使用するクラスや関数を require() し、定数を定義しておきます。

construcor()

Majiang.UI.Board のインスタンスを生成します。

    constructor(root, pai, audio, model) {

        this._root  = root;     // 表示領域
        this._model = model;    // 表示対象の卓情報
        this._pai   = pai;      // 使用する牌表示関数
        this._audio = {};       // 使用する音声データを得る関数
        this._view  = {         // 表示に使用する部品群
            shoupai: [],            // Majiang.UI.Shoupai
            he:      [],            // Majiang.UI.He
            say:     [],            // 音声データ
            dialog:  null           // 和了・流局ファイアログ
        };

        /* 外部インタフェースとなるインスタンス変数 */
        this.sound_on     = true;       // 音声を再生するか
        this.open_shoupai = false;      // 手牌を伏せて表示するか
        this.he_type      = 0;          // 河の表示方法
        this.dummy_name   = [];         // 表示用の仮名

        this.set_audio(audio);      // 音声を対局者ごとにセットアップする
    }

まだ描画は行わず、root で指定された表示領域と model で指定された卓情報を紐づけるだけです。

redraw()

盤面全体を再描画します。

    redraw(viewpoint) {

        /* 視点を決定する */
        if (viewpoint != null) this._viewpoint = viewpoint;
        else                   viewpoint = this._viewpoint;

        const model = this._model, view = this._view;

        hide($('.kaiju'), this._root);  // 開局画面を閉じる
        view.dialog = new HuleDialog($('.dialog', this._root), this._pai,
                                        model, viewpoint);
                                        // 和了・流局ダイアログを初期化する
        this.summary();                 // 集計表を閉じる

        /* 局数・本場・持ち点を表示する */
        score($('.score', this._root), model, viewpoint);

        /* 牌山表示のインスタンス初期化し、再描画する */
        view.shan = new Shan($('.score .shan', this._root), this._pai,
                                model.shan).redraw();

        /* 対局者ごとの情報を表示する */
        for (let l = 0; l < 4; l++) {
            let id = model.player_id[l];            // 対局者の席順を判定
            let c  = class_name[(4 + id - viewpoint) % 4];
                                                    // 対局者の表示位置を判定
            /* 対局者名を表示する */
            let name = this.dummy_name[id] ||
                            model.player[id].replace(/\n.*$/,'');
            $(`.player.${c}`, this._root).text(name);

            /* 手牌表示のインスタンスを初期化し、再描画する */
            let open = model.player_id[l] == viewpoint;
            view.shoupai[l]
                    = new Shoupai($(`.shoupai.${c}`, this._root),
                                    this._pai, model.shoupai[l]
                                ).redraw(open || this.open_shoupai);

            /* 河表示のインスタンスを初期化し、再描画する */
            view.he[l] = new He($(`.he.${c}`, this._root),
                                    this._pai, model.he[l]
                                ).redraw(this.he_type);

            /* 発声の表示を初期化する */
            view.say[l] = hide($(`.say.${c}`, this._root).text(''));
        }
        this._lunban = model.lunban;    // 卓情報の手番を反映する

        return this;
    }

既に説明済みの 牌山 (Majiang.UI.Shan) と対局者4人分の 手牌 (Majiang.UI.Shoupai])、 (Majiang.UI.He) のインスタンスを生成し、それらに再描画を指示することで卓情報を再描画します。

局数・本場・持ち点の表示は内部関数 score() に処理を任せます*4

viewpoint で視点を指定することもできます。これは牌譜ビューアとネット対戦向けの機能なので、AIとの対戦では 0 固定(人間視点)となります。

update()

盤面を差分描画します。

    update(msg = {}) {

        const model = this._model, view = this._view;

        view.dialog.hide();             // 和了・流局ダイアログを閉じる
        this.summary();                 // 集計表を閉じる

        /* リーチが成立したら持ち点を再描画する */
        if (this._lizhi) {
            score($('.score', this._root), model, this._viewpoint);
            this._lizhi = false;
        }

        /* 手番が移動したら、元の手番の手牌と河を再描画する */
        if (this._lunban >= 0 && this._lunban != model.lunban) {
            view.he[this._lunban].redraw();
            view.shoupai[this._lunban].redraw();
        }

        if (msg.zimo) {                         // 自摸
            view.shan.update();                         // 残りツモ数を更新
            view.shoupai[msg.zimo.l].redraw();          // 手牌を再描画
        }
        else if (msg.dapai) {                   // 打牌
            fadeOut(view.say[msg.dapai.l]);             // 発声を消す
            view.shoupai[msg.dapai.l].dapai(msg.dapai.p);
                                                        // 手牌を打牌する差分描画
            this.play_audio(this._audio.dapai);         // 打牌音を鳴らす
            view.he[msg.dapai.l].dapai(msg.dapai.p);    // 河に牌を置く差分描画
            this._lizhi = msg.dapai.p.slice(-1) == '*'; // リーチ宣言を記憶する
        }
        else if (msg.fulou) {                   // 副露
            view.shoupai[msg.fulou.l].redraw();         // 手牌を再描画
        }
        else if (msg.gang) {                    // 槓
            view.shoupai[msg.gang.l].redraw();          // 手牌を再描画
        }
        else if (msg.gangzimo) {                // 槓自摸
            fadeOut(view.say[msg.gangzimo.l]);          // 発声を消す
            view.shan.update();                         // 残りツモ数を更新
            view.shoupai[msg.gangzimo.l].redraw();      // 手牌を再描画
        }
        else if (msg.kaigang) {                 // 開槓
            view.shan.redraw();                         // 牌山を再描画
        }
        else if (msg.hule) {                    // 和了
            fadeOut($('.say', this._root));             // 全員の発声を消す
            setTimeout(()=>{                            // 400ms「タメ」を作る
                view.shoupai[msg.hule.l].redraw(true);  // 手牌を開けて再描画
                view.dialog.hule(msg.hule);             // 和了ダイアログを表示
                if (msg.hule.damanguan) this.play_audio(this._audio.gong);
                                                        // 役満ならドラを鳴らす
            }, 400);
        }
        else if (msg.pingju) {                  // 流局
            fadeOut($('.say', this._root));             // 全員の発声を消す
            let duration = 0;
            if (msg.pingju.name.match(/^三家和/))        // 三家和のときは和了
                    duration = 400;                     // 同等のタメを作る
            else    view.he[this._lunban].redraw();     // それ以外は河を再描画
            setTimeout(()=>{
                for (let l = 0; l < 4; l++) {
                    let open = model.player_id[l] == this._viewpoint
                            || msg.pingju.shoupai[l];   // 手牌を開くか伏せるか
                                                        // 判定し、
                    view.shoupai[l].redraw(open);       // 適切な状態で再描画する
                }
                view.dialog.pingju(msg.pingju);         // 流局ダイアログを表示
            }, duration);
        }
        else {                                  // 和了・流局後
            score($('.score', this._root), model, this._viewpoint);
                                                        // 持ち点を再描画する
        }

        /* 現在の手番を反映する */
        class_name.forEach(c => $(`.${c}`, this._root).removeClass('lunban'));
        if (model.lunban >= 0) {
            let id = model.player_id[model.lunban];
            let c  = class_name[(4 + id - this._viewpoint) % 4];
            $(`.${c}`, this._root).addClass('lunban');
        }
        this._lunban = model.lunban;

        return this;
    }

msg で指定された 摸打情報 にしたがい、すでに作成済みの 牌山手牌 のインスタンスを使って卓情報を差分描画します。

局面 牌山 手牌
自摸(zimo)
打牌(dapai)
副露(fulou)
槓(gang)
槓自摸(gangzimo)
開槓(kaigang)
和了(hule)
流局(pingju)
◯: 再描画、△: 差分描画

牌山 は自摸・槓自摸のときに残りツモ枚数のみを差分描画し、開槓のとき(ドラが増えたとき)に再描画します。

手牌 はほとんどの場合に再描画しますが、唯一打牌のときのみ差分描画します。この場合の差分描画とは「ツモった牌はまだ右端にいるが、切った牌は残っていない」という状態です。この状態は手番が移動したとき(通常は下家のツモ)に元の手番の手牌を再描画することで解消されます(ツモ牌を手牌に入れたように見える)。

は打牌のときのみ差分描画します。「牌は河に放たれたがまだ安定していない(少し離れた場所にあり、リーチ棒も置かれていない)」という描画です。この状態はやはり手番が移動したときの再描画で解消されます。

和了・流局ダイアログ の表示は Majiang.UI.HuleDialog が担当します*5

summary()

終局時に全局の得点のやり取りを示した 集計表 を表示します。

    summary(paipu) {
        if (paipu) fadeIn(summary($('.summary', this._root), paipu,
                                                        this._viewpoint));
                                    // 牌譜が指定されたときは集計表を表示する
        else       hide($('.summary', this._root));
                                    // 牌譜の指定がなければ集計表を閉じる
    }

牌譜を引数に majiang-ui の内部関数 summary() を呼び出すだけです。

say()

音声出力とそのキャプション文字列(「チー」とか)を表示します。

    say(name, l) {
        this.play_audio(this._audio[name][l]);          // 音声を出力
        show(this._view.say[l].text(say_text[name]));   // キャプションを表示
        return this;
    }

name で指定した音声とそのキャプションを l で指定した手番のものとして出力・表示します。audio 要素はコンストラクタの延長で対局者ごとにコピーしています。これはダブロンなどのときに同じ音声を同時に発声するためです。

表示したキャプションはどこかのタイミングで消す必要がありますが、副露とリーチは打牌、カンは槓自摸のときに非表示(実際はフェードアウト)にしています。ロンとツモの発声は和了・流局のタイミングで非表示にしますが、リーチとカンのキャプションが残っている場合がある(リーチ宣言牌での放銃と槍槓)ので全員のキャプションを非表示にしています。

CSS

盤面のCSSは構成要素が多いため長大ですが、そのほとんどが座標指定のため重要な部分だけ紹介します。

#board .board
    box-sizing: border-box;
    background: #154
    > *
        position: absolute
        transform-origin: 0% 0%

#board .board 直下のすべての要素を position: absolute で座標指定を前提とした配置にします。移動の起点は左上(transform-origin: 0% 0%)です。

デモ

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

*1:これ以外にも「牌譜解析ツール」も牌譜から卓情報を復元しています

*2:この部分は ver.2.0.0 のリファクタリングで実装しました

*3:引数に「視点」を与えれば盤面を回転できます

*4:現在は内部クラスになっていますがリファクタリングしました

*5:説明は割愛します