電脳麻将におけるMVCの実装

電脳麻将 は HTML5 + CSS3 + JavaScript で動作する SPA です。こういったアプリを実装する場合、現在は ReactVue を使って宣言的に書くのがあたりまえで、jQueryはオワコン といわれています。ですが電脳麻将はあえて jQuery を使って MVC に基づいて実装しています。その理由は jQueryでないと美しく実装できない と考えるからです。

シナリオ

電脳麻将はどのように実装されているのか、以下の場面で具体的に説明します。

  1. 東家が二萬をツモるf:id:xlc:20210325202000p:plain:w400:right
  2. 六筒を打牌するf:id:xlc:20210325202012p:plain:w400:right

MVCを担当するクラス

このシナリオにおいて 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 で美しく実装できるのでしょうか?私自身 React での実装経験がないため誤っているかもしれませんが、おそらくは無理ではないでしょうか。無理と考える理由は以下の通りです。

  1. 宣言的アプローチでは「打牌中」の状態を描画できない
    M である Majiang.Shoupai は現在の手牌構成を示しているだけで、「打牌中」などという中間状態を有しません。素直に React 的考え方で実装すると、ツモ後の牌姿からいきなり打牌後の理牌*4された牌姿に変化してしまい、およそ打牌したようには見えないでしょう。これは Majiang.He も同様です。さらに言うと打牌音はどのクラスが鳴らしますか?
    Majiang.Shoupai はAIの思考ルーチンでも使用します。ここに描画の都合の「打牌中」などという状態を持ち込むとしたら、それは設計として誤っています。

  2. イベントハンドラ設定は描画処理と分離すべきである
    Majiang.View.Shoupai は手牌の表示だけをすべきであり、ここにイベントハンドラ設定を持ち込むことは設計として誤っています。なぜなら対戦相手の手牌にイベントハンドラは不要だし、牌譜再生*5にも打牌のためのイベントハンドラは不要です。どのようなイベントハンドラが必要かは実行コンテキストによって決まります。イベントハンドラ設定は表示とは分離すべきものなのです。

  3. JSXを使う局面がない
    ソースコードを見ていただければわかるのですが「HTMLを書く」という処理はほとんどありません。牌の描画は HTML を書くのではなく、静的 HTML に雛形として埋め込まれた「牌を表現するDOMノード」をコピーし差し込むことで実現しています。このやり方なら、DOM構成も含めた「見た目の調整」は HTML と CSS だけで完全に制御でき、デザイナーと仕事を分離しやすくなるはずです。

結論

「jQueryはオワコン」などという戯言に惑わされず、これからも jQuery を積極的に利用して行こうと思います!😎

*1:通知部分は実際にはツモったプレーヤー以外にはツモ牌をマスクしています

*2:おそらくはブラウザ自体の機能に差分描画が実装されているのだと思います

*3:これを行わないとクリックしていない方の牌が打牌されたりする不自然な動きになってしまいます

*4:手牌をきれいに並び替えること

*5:対局を振り返る機能。棋譜の如きもの