電脳麻将 は HTML5 + CSS3 + JavaScript で動作する SPA です。 こういったアプリを実装する場合、現在は React や Vue を使って宣言的に書くのがあたりまえで、jQueryはオワコン といわれています。 ですが電脳麻将はあえて jQuery を使って MVC に基づいて実装しています。その理由は jQueryでないと美しく実装できない と考えるからです。
電脳麻将はどのように実装されているのか、以下の場面で具体的に説明します。
このシナリオにおいて MVC それぞれを担当するのは以下のクラスです。
クラス | 説明 | |
---|---|---|
M | Majiang.Shoupai | 手牌を表現するクラス |
Majiang.He | 捨て牌を表現するクラス | |
Majiang.Shan | 牌山を表現するクラス | |
V | Majiang.View.Shoupai | 手牌を描画するクラス |
Majiang.View.He | 捨て牌を描画するクラス | |
C | Majiang.Game | 局進行を司るクラス |
Majiang.View.Game | 麻雀卓全体の描画を司るクラス | |
Majiang.View.Player | 対戦時のUIを実装するクラス |
まず Majiang.Game
のメソッド zimo()
が呼ばれて以下の処理を行います*1。
M を変更するのは C である Majiang.Game
の役割という訳です。
let zimo = model.shan.zimo(); // Majiang.Shan から1枚ツモる
model.shoupai[model.lunban].zimo(zimo); // ツモった牌を Majiang.Shoupai
// に加える
let paipu = { zimo: { l: model.lunban, p: zimo } };
// 東家が二萬ツモ →
// { zimo: { l: 0, p: 'm2' } }
this.call_players('zimo', paipu); // Majiang.View.Player を含めた
// プレーヤーに非同期に通知
this._view.update(paipu); // Majiang.View.Game に通知
先に通知を受けた Majiang.View.Game
ではメソッド update(data)
の中で以下の処理を行います。
if (data.zimo) {
this._view.shoupai[data.zimo.l].redraw();
// Majiang.View.Shoupai のメソッド
// を呼出し手牌を再描画する
}
Majiang.View.Shoupai
のメソッド redraw()
では、牌に対応するDOMノードをいったん全部取り除き、新しい手牌を差し込みなおすという乱暴な操作を行って「再描画」しています。
まさに「仮想DOM」が威力を発揮するはずの場面ですが、実際に見れば分かるようにこのやり方でも画面にちらつきなどはありませんね*2。
非同期で通知を受けた Majiang.View.Player
では牌に対応するDOMノードにイベントハンドラを設定します。
これでユーザがどの牌をクリックしたかイベントハンドラで判別できるようになります。
ユーザがマウスクリックで打牌を選択するとイベントが発生します。 イベントハンドラ内ではまずクリックされた牌に対応するDOMノードに class="dapai" の印をつけます。 これは手牌内に同種の牌がある場合、どちらがクリックされたかを示すためです*3。
次にイベントハンドラの延長で Majiang.Game
のメソッド dapai(dapai)
が呼ばれ、以下の処理でツモのときと同等に M を変更します。
model.shoupai[model.lunban].dapai(dapai); // 打牌した牌を Majiang.Shoupai
// から取り除く
model.he[model.lunban].dapai(dapai); // 打牌した牌を Majiang.He に加える
let paipu = { dapai: { l: model.lunban, p: dapai } };
// 東家が六筒を打牌 →
// { dapai: { l: 0, p: 'p6' } }
this.call_players('dapai', paipu); // Majiang.View.Player を含めた
// プレーヤーに非同期に通知
this._view.update(paipu); // Majiang.View.Game に通知
先に通知を受けた Majiang.View.Game
ではメソッド update(data)
の中で以下の処理を行います。
else if (data.dapai) {
this._view.shoupai[data.dapai.l].dapai(data.dapai.p);
// Majiang.View.Shoupai のメソッド
// を呼出し、打牌のアニメーションを
// 行う
this._audio.dapai[data.dapai.l].play();
// 打牌音を出す
this._view.he[data.dapai.l].dapai(data.dapai.p);
// Majiang.He のメソッドを呼出し
// 打牌された牌のみを描画する
}
Majiang.View.Shoupai
のメソッド dapai(p)
では M を一切参照せず、DOM操作のみで打牌の「差分描画」を実装しています。
具体的には class="dapai" の印のあるDOMノードに class="deleted" を追加し、CSSの機能を使って牌が消えるアニメーションを実行します。
Majiang.View.He
のメソッド dapai(p)
でも同様に M を一切参照していません。打牌された牌を追加するという「差分描画」で実装しています。打牌された牌の位置に注目してください。微妙にそろっていないことで打牌を表現しています。「再描画」するとこのズレは取り除かれます。
非同期で通知を受けた Majiang.View.Player
では、副露や和了が可能ならそれを促すボタンを表示します。
果たしてこれを React で美しく実装できるのでしょうか? 私自身 React での実装経験がないため誤っているかもしれませんが、おそらくは無理ではないでしょうか。 無理と考える理由は以下の通りです。
Majiang.Shoupai
は現在の手牌構成を示しているだけで、「打牌中」などという中間状態を有しません。
素直に React 的考え方で実装すると、ツモ後の牌姿からいきなり打牌後の理牌*4された牌姿に変化してしまい、およそ打牌したようには見えないでしょう。これは Majiang.He
も同様です。さらに言うと打牌音はどのクラスが鳴らしますか?Majiang.Shoupai
はAIの思考ルーチンでも使用します。ここに描画の都合の「打牌中」などという状態を持ち込むとしたら、それは設計として誤っています。Majiang.View.Shoupai
は手牌の表示だけをすべきであり、ここにイベントハンドラ設定を持ち込むことは設計として誤っています。
なぜなら対戦相手の手牌にイベントハンドラは不要だし、牌譜再生*5にも打牌のためのイベントハンドラは不要です。
どのようなイベントハンドラが必要かは実行コンテキストによって決まります。イベントハンドラ設定は表示とは分離すべきものなのです。「jQueryはオワコン」などという戯言に惑わされず、これからも jQuery を積極的に利用して行こうと思います!😎