電脳麻将UI 〜 面子 に続いて、今回は手牌全体の表示について説明していきます。
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
上記の手牌の場合、電脳麻将 では以下の構成*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")
手牌の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を見てみましょう。 面子 と同様に Stylus の Mixin で実装しています。
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 に変動させるアニメーションを行う指定(牌が消えたあとにスライドするように見える)をしています。
以下で実際の表示を確認できます。