電脳麻将UI 〜 牌山と河

今回は牌山と河(捨て牌)の表示について説明します。

牌山

天鳳など牌山をすべて表示する麻雀アプリもありますが、電脳麻将 ではドラ表示牌と残りツモ枚数だけを表示しています*1。このため実装は単純です。

Majiang.UI.Shan

牌山のHTMLの出力は @kobalab/majiang-ui のクラス Majiang.UI.Shan で実装しています。まず、コンストラクタ で表示領域と牌山データを結びつけます。

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

module.exports = class Shan {

    constructor(root, pai, shan) {
        this._root = root;
        this._pai  = pai;
        this._shan = shan;
    }

    /* ...... */
}

root で指定したDOMノードに牌山を表示するインスタンスを生成します。pai電脳麻将UI 〜 牌 - koba::blog で生成した関数、shan は表示対象の牌山を表す Majiang.Shan のインスタンスです。

Majiang.UI.Shan は root が以下の構成であることを期待します。

root
  .baopai       /* ドラ表示牌 */
  .fubaopai     /* 裏ドラ表示牌 */
  .paishu       /* 残りツモ数 */

.baopai.fubaopai はドラ表示牌・裏ドラ表示牌の表示領域、.paishu は残りツモ数の表示領域です。これらの領域がなくてもエラーにはならず、その領域のHTML出力をスキップします。例えば、和了点の表示画面など残りツモ数が不要なときは .paishu を省略すればよい訳です。

redraw() で全体を、update() で残りツモ数のみを再表示します。

    redraw() {

        /* ドラ表示牌を表示する */
        let baopai = this._shan.baopai;
        $('.baopai', this._root).empty();
        for (let i = 0; i < 5; i++) {
            $('.baopai', this._root).append(this._pai(baopai[i] || '_'));
        }

        /* 裏ドラ表示牌を表示する */
        let fubaopai = this._shan.fubaopai || [];
        $('.fubaopai', this._root).empty();
        for (let i = 0; i < 5; i++) {
            $('.fubaopai', this._root).append(this._pai(fubaopai[i] || '_'));
        }

        /* 残りツモ数を表示する */
        return this.update();
    }

    update() {
        /* 残りツモ数を表示する */
        $('.paishu', this._root).text(this._shan.paishu);
        return this;
    }

デモ

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

河は基本的に牌を並べるだけですが、6枚ごとに区切りを入れ、リーチ宣言牌は横向きにします。

Majiang.UI.He

河のHTMLの出力は @kobalab/majiang-ui のクラス Majiang.UI.He で実装しています。まず、コンストラクタ で表示領域と河のデータを結びつけます。

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

/* ...... */

module.exports = class He {

    constructor(root, pai, he, type = 0) {
        this._root = root;
        this._pai  = pai;
        this._he   = he;
        this._type = type;
        hide($('.chouma', this._root));     // リーチ棒を非表示にする
    }

    /* ...... */
}

root で指定したDOMノードに河を表示するインスタンスを生成します。pai電脳麻将UI 〜 牌 - koba::blog で生成した関数、he は表示対象の河を表す Majiang.He のインスタンスです。type で河の表示方法を指定できます。0 は通常の表示、1 はツモ切りを暗転表示、2 ではさらに鳴かれた牌も表示に加えます。

Majiang.UI.He は root が以下の構成であることを期待します。

root
  .chouma   /* リーチ棒 */
  .dapai    /* 捨て牌 */

.chouma にはリーチしていることを示す表示(千点棒の画像など)を期待し、リーチがない間は非表示にします。.dapai は捨て牌の表示領域です。

redraw() で河全体を再表示します。

    redraw(type) {

        if (type != null) this._type = type;

        /* 捨て牌をいったん空にする */
        $('.dapai', this._root).empty();
        let lizhi = false;
        let i = 0;

        /* 捨て牌を順に追加していく */
        for (let p of this._he._pai) {

            if (p.match(/\*/)) {    // リーチがあった場合
                lizhi = true;
                show($('.chouma', this._root));     // リーチ棒を表示する
            }

            /* type が 2 でない場合、鳴かれた牌は表示しない */
            if (this._type != 2 && p.match(/[\+\=\-]$/)) continue;

            let pai = this._pai(p);
            if (this._type != 0 && p[2] == '_') {   // ツモ切りの場合
                add_label(pai.addClass('zimo'), label.zimo);
            }
            if (p.match(/[\+\=\-]$/)) {             // 鳴かれた牌の場合
                add_label(pai.addClass('fulou'), label.fulou);
            }
            if (lizhi) {                            // リーチ宣言後最初の捨て牌
                pai = $('<span>').addClass('lizhi')
                                 .attr('aria-label',label.lizhi)
                                 .append(pai);
                lizhi = false;
            }
            $('.dapai', this._root).append(pai);

            /* 6枚ごとに区切りを入れる */
            i++;
            if (i < 6 * 3 && i % 6 == 0) {
                $('.dapai', this._root).append($('<div>').addClass('break'));
            }
        }
        return this;
    }

.dapai で示されたDOMノードに牌を追加していきます。リーチがあった場合はコンストラクタで非表示にしていた .chouma を再表示します。ツモ切りの場合はその牌に .zimo、鳴かれた場合は .fulou のマークを追加します。リーチ宣言後最初の捨て牌は横向きにする必要があるため、.lizhi で囲みます。この牌はリーチ宣言牌とは限らないことに注意してください*2。6枚ごとの区切りは .break でマークした div 要素を入れることで示しています。

dapai() で打牌を表示します。

    dapai(p) {
        let pai = this._pai(p).addClass('dapai');
        if (p[2]== '_') pai.addClass('zimo');
        if (p.match(/\*/)) pai = $('<span>').addClass('lizhi')
                                            .attr('aria-label',label.lizhi)
                                            .append(pai);
        $('.dapai', this._root).append(pai);
        return this;
    }

打牌した直後の牌には .dapai のマークがついています。これを目印に「まだ河に並びきっていない」状態の表示をすることが可能です。ツモ切りの場合はその牌に .zimo を追加します。リーチ宣言牌は .lizhi で囲みます。

CSS

CSSも見てみましょう。やはり StylusMixin で実装しています。

he-size($width, $pai-height, $type = block)
    width: $width
    >:has(.chouma)
        line-height: 0
        width: pai-width($pai-height) * 6
        height: ($pai-height / 4)
        text-align: $type == line ? left : center
        .chouma
            width: @width * 0.6
            max-height: @height
    >.dapai
        line-height: 0
        height: $type == line ? $pai-height : $pai-height * 3
        .pai
            pai-size: $pai-height
        .pai.dapai
            transform: translate($pai-height / 12, $pai-height / 24)
        .pai.zimo
            opacity: 0.8
        .pai.zimo.dapai
            opacity: 0.6
        .pai.fulou
            opacity: 0.4
        .lizhi
            width: $pai-height
            display: inline-block
            text-align: left
            transform: rotate(270deg)
            .pai.dapai
                transform: translate(- $pai-height / 24, $pai-height / 12)
        .break
            display: $type == line ? inline-block : block
            width: pai-width($pai-height) * 0.1

$width で表示領域の幅、$pai-height で捨て牌の高さを指定します。$type は表示形式の指定です。block を指定すると6つ切りで縦に並べる一般的な表示、line だと横一列の表示になります。デフォルト値は block です。

この2つの形式の違いはCSSだけで実現できることに注意してください。6つ切りの間にはさんだ .break のマークのある要素をブロック要素にすれば縦に並べる表示、インライン要素にすれば横一列の表示になる訳です。

.dapai .pai.zimo はツモ切りした牌、.dapai .pai.zimo.dapai はツモ切り直後の牌、.dapai .pai.fulou は鳴かれた牌ですが、これらは opacity の値を変えることで見分けられるようにしています*3

.dapai .lizhi はリーチ宣言牌を囲む領域でしたが、幅を牌の高さに合わせて正方形の領域にして牌を左寄せにした後、その中心を基準に反時計回りに90°回転(rotate(270deg))させることで横向きにしています。

デモ

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

*1:雀魂でも表示してないし、要らないですよね

*2:type が 2 以外でリーチ宣言牌を鳴かれた場合は次の捨て牌を横向きにします

*3:ツモ切りした牌を鳴かれた場合に区別がつきませんが、これは他の麻雀アプリでも同じだと思います