Home

電脳麻将UI 〜 手牌

電脳麻将UI 〜 面子 に続いて、今回は手牌全体の表示について説明していきます。

m1m2m3p4s8s8s8 p0 s7-s6s8 z1z1=z1-z1

上記の手牌の場合、電脳麻将 では以下の構成*1でHTMLを生成します*2


root
  .bingpai
    .pai(data-pai="m1")
    .pai(data-pai="m2")
    .pai(data-pai="m3")
    .pai(data-pai="p4")
    .pai(data-pai="s8")
    .pai(data-pai="s8")
    .pai(data-pai="s8")
    .pai.zimo(data-pai="p0")
  .flou
    span.mianzi
      span.rotate
        .pai(data-pai="m7")
      .pai(data-pai="m6")
      .pai(data-pai="m8")
    span.mianzi
      .pai(data-pai="z1")
      span.rotate
        .pai(data-pai="z1")
        .pai(data-pai="z1")
      .pai(data-pai="z1")

Majiang.UI.Shoupai

手牌のHTMLの出力は @kobalab/majiang-ui のクラス Majiang.UI.Shoupai で実装しています。 Majiang.UI.Shoupai はHTMLの生成のみを担当する MVC でいう V に相当するクラスです*3。 まず、コンストラクタ で表示領域と M に相当するデータ(手牌)を結びつけます。

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

const $ = require('jquery');
const label = require('./label')('shoupai');

const mianzi = require('./mianzi');

module.exports = class Shoupai {

    constructor(root, pai, shoupai, open) {
        this._root    = root;
        this._pai     = pai;
        this._mianzi  = mianzi(pai);
        this._shoupai = shoupai;
        this._open    = open;
    }

    /* ...... */
}

root で指定したDOMノードに手牌を表示するインスタンスを生成します。 pai電脳麻将UI 〜 牌 で生成した関数、shoupai は表示対象の手牌を表す Majiang.Shoupai のインスタンスです。 open が偽の場合は、伏せた状態の手牌を表示します。 手牌内の面子の表示には 電脳麻将UI 〜 面子 で説明した関数を使います。 インスタンスを生成しただけでは手牌は表示しません。 次に説明するメソッド redraw() の呼び出しが必要です。

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

root
  .bingpai  /* 打牌可能な牌 */
  .fulou    /* 副露面子 */

.bingvpai は打牌可能な牌の表示領域、.fulou は副露面子の表示領域です。

redraw() を呼び出すと、手牌全体を表示します。

    redraw(open) {

        if (open != null) this._open = open;

        /*「伏せた状態」のときは表示関数を伏せた牌の表示に差し替える */
        const pai = this._open ? this._pai : ()=> this._pai('_');

        $('.bingpai', this._root).empty();      // 手牌をいったん空にする
        /* ツモ牌以外の手牌を順に追加していく */
        let zimo = this._shoupai._zimo;

        for (let s of ['m','p','s','z']) {
            let bingpai = this._shoupai._bingpai[s];
            let n_hongpai = bingpai[0];
            for (let n = 1; n < bingpai.length; n++) {
                let n_pai = bingpai[n];
                if (s+n == zimo)           { n_pai--              }
                if (n == 5 && s+0 == zimo) { n_pai--; n_hongpai-- }
                for (let i = 0; i < n_pai; i++) {
                    let p = (n ==5 && n_hongpai > i) ? s+0 : s+n;
                    $('.bingpai', this._root).append(pai(p));
                }
            }
        }
        /* 不明な牌は伏せた状態で追加する */
        let n_pai = this._shoupai._bingpai._ + (zimo == '_' ? -1 : 0);
        for (let i = 0; i < n_pai; i++) {
            $('.bingpai', this._root).append(this._pai('_'));
        }
        /* ツモ牌を追加する */
        if (zimo && zimo.length <= 2) {
            $('.bingpai', this._root).append(pai(zimo).addClass('zimo'));
        }

        /* 面子を順に追加する */
        $('.fulou', this._root).empty();
        for (let m of this._shoupai._fulou) {
            $('.fulou', this._root).append(this._mianzi(m));
        }

        return this;
    }

open が指定されると手牌を見せる/伏せるの状態を変更します。 HTMLには牌が並べられているだけなことに注意してください。 牌を回転させるなどの視覚効果はCSSで行います。

dapai() を呼び出すと打牌の様子を表示します。

    dapai(p) {

        /* 打牌対象の牌を決定する。まずは class="dapai" の牌を候補とする */
        let dapai = $('.bingpai .pai.dapai', this._root);
        if (! dapai.length) {       // 候補がない場合
            /* ツモ切りが指定されているならツモ牌を打牌する */
            if (p[2] == '_') dapai = $('.bingpai .pai.zimo', this._root);
        }
        if (! dapai.length) {       // まだ候補がない場合
            if (this._open) {           // 伏せていない場合
                /* p で指定された牌と一致する牌を打牌する */
                dapai = $(`.bingpai .pai[data-pai="${p.slice(0,2)}"]`,
                          this._root).eq(0);
            }
            else {                      // 伏せている場合
                /* ツモ牌以外の牌からランダムに選択する */
                dapai = $('.bingpai .pai', this._root);
                dapai = dapai.eq(Math.random()*(dapai.length - 1)|0);
            }
        }
        dapai.addClass('deleted');

        return this;
    }

p で指定された打牌対象の牌に .deleted のマークをつけているだけなことに注意してください。 打牌のアニメーションはCSSで行います。 .dapai はUIで打牌を指定するときに使用します。 これは同一の牌が複数あるときでも「クリックした牌」を選択するためです。

電脳麻将のUIはこのように伝統的な MVC のモデル にしたがって実装しています。 V には全体を再表示するメソッド(redraw())と部分表示のメソッド(dapai() など)があります。 全体を再表示するときには M を参照しますが、部分表示はパラメータにしたがいます。 C は人による操作または自律的に動作して M を変更し、V に適切な再表示の仕方を指示します。 Reactなどのように宣言型で M の値の変化だけをトリガに描画するのとは異なり、柔軟に描画を実装することが可能です。

再表示の際に手牌をいったん空にし、牌を差し込み直していることにも注意してください。 このような実装は表示を乱すとして React は「仮想DOM」を導入しましたが、電脳麻将を実際に見ていただければ分かる通り、表示は少しも乱れていません。 おそらくはブラウザ自身がDOMの差分を検知し、差分描画を行う実装になっているのではないかと推測します。 GPUに処理を任せない仮想DOMという発想が正しいのか疑問を感じてしまいます。

電脳麻将におけるMVCの実装ポリシーについては過去に記事を書いているので、こちらもご参照いただけると幸いです*4

CSS

続いてCSSを見てみましょう。 面子 と同様に StylusMixin で実装しています。

shoupai-size($width, $height, $bingpai-height, $fulou-height)
    display: table
    width: $width
    height: $height
    .bingpai
        line-height: 1
        if ($height < $bingpai-height + pai-width($fulou-height) * 2)
            margin-top: $height - $bingpai-height
            float: left
        else
            margin-top: 0
            float: none
        .pai
            pai-size: $bingpai-height
        .pai[role="button"]
            cursor: pointer
            &:focus
                transform: translate(0, - $bingpai-height / 8)
                outline: none
        .pai.zimo
            margin-left: pai-width($bingpai-height) * 0.1
        .pai.deleted
            transition: width 0.3s ease-out 0.1s
            opacity: 0
            width: 0
    .fulou
        line-height: 1
        if ($height < $bingpai-height + pai-width($fulou-height) * 2)
            margin-top: $height - $fulou-height
            float: right
        else
            margin-top: $height - $bingpai-height - $fulou-height
            float: none
        .mianzi
            margin-left: pai-width($bingpai-height) * 0.1
            float: right
            mianzi-size: $fulou-height
            &:last-child
                margin-left: pai-width($bingpai-height) * 0.2

$width$height は手牌表示領域のサイズです。 $bingpai-height は(打牌可能な)手牌の高さ、$fulou-height には副露牌の高さを指定します。 電脳麻将UI 〜 面子 で説明した mianzi-size と pai-size をここでは部品として使用しています。 副露面子は .fulou を float で右寄せにし、さらに .fulou .mianzi を float で逆順に右から並べています。

手牌の表示領域には以下の3つの表示方法があります。

先ほど打牌のアニメーションはCSSで行うと説明しましたが、.bingpai .pai.deleted で opacity を 0 にした 0.1 秒後に width を 0.3 秒かけて 0 に変動させるアニメーションを行う指定(牌が消えたあとにスライドするように見える)をしています。

デモ

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

  1. ^ Pug の記法で表記しました
  2. ^ 現在リファクタリング中の実装です
  3. ^ 打牌選択のUIは C に相当するクラスが担当します
  4. ^ 5年前の記事なのでファイル構成が変わっていますが、ポリシーに変更はありません