電脳麻将UI 〜 面子 - koba::blog に続いて、今回は手牌全体の表示について説明していきます。
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
![]()
上記の手牌の場合、電脳麻将 では以下の構成*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 〜 牌 - koba::blog で生成した関数、shoupai は表示対象の手牌を表す Majiang.Shoupai のインスタンスです。open が偽の場合は、伏せた状態の手牌を表示します。手牌内の面子の表示には 電脳麻将UI 〜 面子 - koba::blog で説明した関数を使います。インスタンスを生成しただけでは手牌は表示しません。次に説明するメソッド redraw() の呼び出しが必要です。
Majiang.UI.Shoupai は root が以下の構成であることを期待します。
root .bingpai /* 打牌可能な牌 */ .fulou /* 副露面子 */
.bingpai は打牌可能な牌の表示領域、.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を見てみましょう。面子 と同様に 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 〜 面子 - koba::blog で説明した mianzi-size と pai-size をここでは部品として使用しています。副露面子は .fulou を float で右寄せにし、さらに .fulou .mianzi を float で逆順に右から並べています。
手牌の表示領域には以下の3つの表示方法があります。
- $width に具体的な値を指定すると手牌の表示領域が 固定 される

- $width に autoを指定すると手牌の長さに応じて 伸び縮み する

- $heright が十分に高いと上段に打牌可能な手牌、下段に副露面子と 2段 で表示する

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