技術ブログ | 株式会社アイプランニング IPlanning corporation

アイプランニング社員が調査したこと、学んでいることが具体的にどんなものなのかを披露します。 Here is what the IPlanning corp employees surveyed and what they learned.

Vue.js で麻雀聴牌判定ゲームを作る

f:id:iplcojp:20180731135837p:plain

2年目若手が新人研修のときに作ったC#の麻雀ゲームを Web版として移植してもらいました。

ゲームはスマートフォンでもプレイできるようになっておりますので、 ぜひ遊んでみてください!

フレームワークにはVue.jsを利用しています。
また、聴牌判定ロジックにこだわりがあるとのことで、 ロジックについても解説記事を書いてもらいました。

ゲームを遊ぶ

清一色の練習をするためのゲームです。

こちらで遊べます。

Chromeで動作確認を行っております。IEなどのレガシーブラウザでは動作いたしません。

作成物概要

f:id:iplcojp:20180731144406p:plain f:id:iplcojp:20180731135837p:plain

麻雀とは

34 種 136 牌を使用するゲームです。手牌 13 枚から始まり、1 枚引いては捨てるを繰り返して和了(あがり)を競います。細かいルールは記載しきれないので省略します。

f:id:iplcojp:20180731135848p:plain

画像拝借元

用語

手牌(てはい): 各プレイヤーの所有する牌。基本的に 13 枚。
雀頭(じゃんとう): 同じ牌が 2 枚の組。
面子(めんつ): 同じ牌が 3 つの組、又は連続した数字が 3 つ揃ったもの。
和了(あがり): 雀頭 1 つ、面子 4 つを揃えた形。
聴牌(てんぱい): あと 1 枚で和了形になる状態。
待ち牌(まちはい): 聴牌の状態から和了するための牌。待ち牌の個数で 1 面待ち、2 面待ちなどと表現する。
清一色(ちんいつ、ちんいーそー): 和了形が 1 色の牌のみで構成されているもの。

和了

f:id:iplcojp:20180731135936p:plain

聴牌

f:id:iplcojp:20180731135944p:plain

清一色聴牌

待ち牌を探すのが難しいのが特徴です。

f:id:iplcojp:20180731135953p:plain

作成背景

麻雀の牌の組み合わせに興味があったためです。

清一色には 1~9 面待ちの手牌の組が存在します。

n 面待ちは何通りあるのか、n 面待ちをランダムに表示して遊ぶにはどうするのがよいか、 というような思いから作成に至りました。

計算ロジック

聴牌の判定方法や、手牌をどう格納するかといったことを記述してあります。

聴牌判定ロジック

13 枚の手牌に 1~9 の牌を加えて、それが和了の形となっているか判定する方法を使っています。

雀頭となりうる牌を除外して、他の牌が全て面子の形となっていれば OK です。

雀頭となりうる牌*2 ≡ 14 枚の牌の合計値 (mod 3) という性質も利用することで、雀頭候補を絞れて無駄な計算を省けます。

n 面待ちランダム取得ロジック

予め 1~9 面待ちの全ての手牌の状態を 2 次元リストに格納しておき、定数時間で n 面待ち k 種目の手牌を取り出せるようにしました。

「n 面待ちが出るまで手牌をランダムに生成し続ける方法はダメなのか?」という点については、 1,2 面待ち程度なら大丈夫ですが、7,8 面待ちになると時間がかかってしまうときがあります。 下の図は 13 枚の手牌が 1 色に染まっているときの n 面待ちの組み合わせ(種類)と個数です。

待ち数 種類 確率%(種類) 個数 確率%(個数)
ALL 93,600 100.000 2,310,789,600 100.000
0 53,404 57.056 1,188,283,736 51.423
1 14,193 15.163 366,605,432 15.865
2 14,493 15.484 413,556,512 17.897
3 6,739 7.200 225,438,896 9.756
4 2,948 3.150 68,889,152 2.981
5 1,335 1.426 32,848,640 1.422
6 392 0.419 12,497,408 0.541
7 79 0.084 2,176,256 0.094
8 16 0.017 231,424 0.010
9 1 0.001 262,144 0.011

この図より、ランダムに手牌を生成して 7,8 面待ちを得るためには、1 万回ほどの試行を必要とするのがわかります。 運が悪いときは 10 万回手牌を生成しても得られないときがあります。

「手牌リストに格納した場合、メモリ使用量は大丈夫か?」という疑問が湧きますが、これについては400KB あれば足ります。先ほどの図から、種類は全部で 93600 通りです。手牌の状態は 4byte で表現可能(後述)なため、使用するメモリ(概算)は 93600*4 ≒ 365KB で済みます。

もし仮に 1,2 面待ちの種類が多く、メモリを占有しすぎてしまう場合は、3~9 面待ちだけをリストに格納して、他はランダムに生成に期待する方法が良いと思います。

手牌の格納方法

13 枚の牌を表現する方法です。副露した牌については考慮していませんので注意してください。

  1. 純粋にリストで表現
    手牌の i 番目の数字を list[i]に格納するシンプルな方法です。必要なリストのサイズは 13 です。
  2. 各牌の個数をリストで格納
    数字が i の牌の個数を list[i]に格納する方法です。必要なリストのサイズは 9 ですが、手牌の並びの情報は失われます。
  3. 各牌の個数を int 型(32bit)に詰める
    方法 2 の各牌の個数をリストで格納する方法において、list に格納される数字は 0~4 のみで、使用する bit サイズが 3 であるのがわかります。そのため、1~3bit 目に 1 の牌の個数、4~6bit 目に 2 の牌の個数 ... 25~27bit 目に 9 の牌の個数とすれば、int 型で格納できることになります。本 Web アプリではこの格納方法を使っています。
  4. 手牌+待ち牌を int 型で表現
    方法 3 とは別のやり方で、ソート済の手牌を表現でき、且つ使用 bit 領域を 21 にまで減らせます。 牌の個数分 1 を並べて、数字の区切り部分を 0 とすれば OK です。例えば 1112355666889 という手牌についてみてみます。数字の区切りを"|"で表すと 111|2|3||55|666||88|9 となります。数字を全て 1 に、"|"を 0 に変換すると 111010100110111001101 になります。
    待ち牌は 9bit あれば表現可能(その牌の番号の bit を立てる)です。残りの 11bit 部分を使えば待ち牌の情報も加わった手牌を int 型で表せます。

ソースコードの一部を掲載します。

const TILE_SUM_BLOCK_BIT = 3; //同じ牌の合計を記録するためのbit領域
const TILE_SUM_MASK = 0b111;  //同じ牌の合計を記録するためのbit領域(mask用)

class TenpaiJudge {
  constructor() {
  }
  //[0, 1, 4, 1, 1] のような手牌をsumTile形式にする(0~2bit目で牌1の個数、3~5bit目で牌2の個数、・・・24~26bit目で牌9の個数)
  // 同じ牌が5個以上ある場合はreturn -1
  toSumTile(aryTile) {
    let sumTile = 0;
    let sumTileList = []; //i+1の牌が何個あるか
    for (let i = 0; i < 9; i++) sumTileList.push(0);
    for (let i = 0; i < aryTile.length; i++) {
      let tileNum = aryTile[i];
      sumTileList[tileNum]++;
      if (sumTileList[tileNum] > 4) return -1;
      sumTile += 1 << (tileNum * TILE_SUM_BLOCK_BIT);
    }
    return sumTile;
  }

  //sumTileは 3bitを1ブロックとして、
  //0~2bit目で牌1の個数、3~5bit目で牌2の個数、・・・24~26bit目で牌9の個数を表す
  //戻り値は和了牌、和了可能bitを立てる(牌1で和了可のときは0bit目、・・・9で和了可のときは8bit目を立てる)
  calcFullFlashWinTile(sumTile) {
    //筋合計mod3算出
    let sujiMod3 = [0, 0, 0];
    for (let suji = 0; suji < 3; suji++) {
      let sujiSum = 0;
      for (let shiftBlock = suji; shiftBlock < 9; shiftBlock += TILE_SUM_BLOCK_BIT) {
        sujiSum += (sumTile >> (shiftBlock * TILE_SUM_BLOCK_BIT)) & TILE_SUM_MASK;

      }
      sujiMod3[suji] = sujiSum % 3;
    }

    //牌の数字合計mod3
    let numSumMod3 = (sujiMod3[1] + sujiMod3[2] * 2) % 3;

    let winTile = 0;//和了牌
    //sumTileにmayWintileを加えて和了形か判定
    //手牌14枚に対し、頭mod3は必然に決まる
    for (let mayWintile = 0; mayWintile < 9; mayWintile++) {
      //加える牌が5枚目のときはスルー
      if (((sumTile >> (mayWintile * TILE_SUM_BLOCK_BIT)) & TILE_SUM_MASK) === 4) continue;

      let addedSumTile = sumTile + (1 << (TILE_SUM_BLOCK_BIT * mayWintile));
      let headTileMod3 = (12 - numSumMod3 - mayWintile) % 3;
      for (let headTile = headTileMod3; headTile < 9; headTile += 3) {
        // 頭候補の牌が2枚未満のとき, 何もしない
        if (((addedSumTile >> (headTile * TILE_SUM_BLOCK_BIT)) & TILE_SUM_MASK) < 2) continue;
        //14牌から頭を抜く
        let addedSumTile_remove_head = addedSumTile - (2 << (headTile * TILE_SUM_BLOCK_BIT));
        if (this.isMentu(addedSumTile_remove_head)) {
          winTile |= 1 << mayWintile;
          break;
        }
      }
    }

    //和了牌無なら七対子判定
    if (winTile === 0) {
      //0枚牌、1枚牌、・・・4枚牌は何個か
      let nPerSum = [0, 0, 0, 0, 0];
      let mayWintile;
      for (let i = 0; i < 9; i++) {
        let maisuu = (sumTile >> (i * TILE_SUM_BLOCK_BIT)) & TILE_SUM_MASK;
        nPerSum[maisuu]++;
        if (maisuu === 1) mayWintile = i;
      }
      if (nPerSum[1] === 1 && nPerSum[2] === 6) winTile |= 1 << mayWintile;
    }
    return winTile;
  }

  //端牌から消していく、sumTileもシフトする
  isMentu(sumTile) {
    while (sumTile > 0) {
      let termSum = sumTile & TILE_SUM_MASK;
      if (termSum === 0 || termSum === 3) {
        sumTile >>= TILE_SUM_BLOCK_BIT;
        continue;
      }
      let nextSum = (sumTile >> TILE_SUM_BLOCK_BIT) & TILE_SUM_MASK;
      let afterNextSum = (sumTile >> (TILE_SUM_BLOCK_BIT * 2)) & TILE_SUM_MASK;
      if (termSum === 2) {
        if (nextSum >= 2 && afterNextSum >= 2) sumTile -= (2 << TILE_SUM_BLOCK_BIT) + (2 << (TILE_SUM_BLOCK_BIT * 2));
        else return false;
      }
      // if (termSum === 1 || termSum === 4)
      else {
        if (nextSum >= 1 && afterNextSum >= 1) sumTile -= (1 << TILE_SUM_BLOCK_BIT) + (1 << (TILE_SUM_BLOCK_BIT * 2));
        else return false;
      }
      sumTile >>= TILE_SUM_BLOCK_BIT;
    }
    return true;
  }
}

Vue.jsについて

今回はVue.jsで初めてアプリを作成するということもあり、CDN版Vue.jsを利用することを選択しました。

アプリケーションの分割方法は、 一般的に推奨されているSingle File Componentは利用せず、 一つのコンポーネントを一つのJSファイルに記述する方式を選択しました。

基本的には下記のように記述します。

Vue.component("my-component", {
  template: `
  <div>
    <!--  ここにテンプレートを記載する -->
  </div>
  `,
  methods: {
    //TODO
  },
  mounted() {
    //TODO
  },

  data() {
    return {
      // TODO
    }
  }
})

以上のように記載したテンプレートを、new Vue()をしているスクリプトより前に読み込むことで、コンポーネントを利用することができます。

  <script src="src/my-component.js"></script>

この方式の利点としては、下記が挙げられます。

  • index.htmlの肥大化が防げる
  • プレーンなJavaScriptとして記述できること
  • テンプレートとスクリプトを同じファイル内に書くと、Single File Componentと似た構成になるため、SFC採用時に移行がスムーズになること

結果

Vue を使うことで画像表示やイベント処理を手軽に記述できました。

こちらで遊べます。

スマートフォンでは横画面でのプレイを推奨します。

ソースコード

ここからダウンロードできます

感想

計算ロジックにこだわりすぎて、見た目や使いやすさに力を入れられていないです。あと Vue は便利です。