Arduboyで麻雀ゲーム作る話(2)

今回は和了判定の一歩手前、メンツが完成しているかどうかの判定ロジックを作成しました。

なお、現在進行中でプログラムを書いているため、バグっているかもしれませんのでご注意ください。

データ定義

ラリコン作っている時に知ったのですが、実はAVRマイコンは8bitマイコンだったのでした。
つい「今どきふつーならintは32bitの16bitマイコンだろ?JK」と思い込んでいてハマってました^^;
ということはですよ、アセンブラちゃんと見てないので良くわかりませんが、レジスタの都合など考えると8bitで処理したほうが何かとお得なような気もします。
そこでプログラム中では

u8(unsigned char) s8(signed char)

を多用しています。
オーバーフローに注意しましょうm(_ _)m

あと、牌については素直に

  • 萬子(1~9)
  • 筒子(10~18)
  • 索子(19~27)
  • 風牌(28~31)
  • 三元牌(32~34)
    ※0は未定義ということで、セオリー通りやってます。

とコードするということで。
0~34なので、6bitで十分表せるということですな。
また、黙ってソートしてやれば理牌できるっちゅうことでもあります。

メンツ完成している?

さて、麻雀1
麻雀は14枚2の牌で役を作るゲームです。
役は七対子国士無双などの例外を除き、

にて構成されています。
最低限、この組み合わせになってないと役として成立しません
ということは、まずは手牌がこの組み合わせになっているか調べる必要があります。
みんな大好き正規表現を使うと、

[TKS][TKS][TKS][TKS][TKS]

みたいな感じか?

ただし、対子は1つの役に1つしか現れません。
しかも都合が悪いことに、対子は牌2枚、刻子順子は牌3枚で構成されています。
素直に頭から読んでってもダメっちゅうことですな。 まあ、

111 222 333 44 555

みたいなヤツだと、

123 123 123 44 555

だったり、

123 11 234 234 555
11 123 234 234 555

だったりしますから、総当りするしか無さそうですね。
みんな大好きツリー構造図で書くと

となりますわな。
これが辿れれば成立、辿れなければ不成立ということで、簡単に再帰で書けそうです。
再帰で書くとメモリ食って遅いというイメージですが(そうでもない?)今回はたかだか最大5段程度のネストなので良いことにしましょう。

ちなみに昔私が買っていじってたPC-9801DX12MHz駆動の80286CPUを搭載し、2.6MIPS程度の性能だったそうです(ちなみに318k円)。
こんなちっこいArduboy16MHzのATmega32U4CPUを搭載して、16MIPSで動作するという、CISC/RISCの違いはあれど隔世の感がありますね・・・(←ジジィの昔話)

Anyway, コーディングを始めましょう。
長いので構造のみこちらで。

// 解析中手牌
u8 g_analyze[TEHAI_NUM];

// 1メンツ判定
#define PI_ANALYZE_MAX  64      // 最大バッファ(最大126=0x7e)
#define PI_LEAF   0x80          // 末端マーク
#define PI_ROOT   0xff          // ルートマーク

u8 g_pindex[PI_ANALYZE_MAX];    // 解析結果
u8 g_piRoot[PI_ANALYZE_MAX];    // 親ノード保持
u8 g_bToitChk;   // 対子チェック済みマーク
u8 g_piLast;    // g_pindexの末尾

bool recIndex (u8 root, u8 index) { ... }
void initAnalyzeMent(const u8* pai = NULL) { ... }

// 最大5段程度の再帰にしかならないので深度優先検索を採用します
// root g_pindexに対するインデックス(初期値PI_ROOT)
// now  g_analyzeに対する牌解析位置(初期値0)
bool analyzeOneMent (u8 root, u8 now)
{
  if (now >= TEHAI_NUM) {
    // 1ルート探索終了
    // 末端マーク
    g_piRoot[root] |= PI_LEAF;
    return true;
  }

  // 残り1枚か?
  if (now == TEHAI_NUM - 1) {
    // もはやメンツは作れない
    return false;
  }

  // 対子か?(対子は1解析セットに雀頭1つしかない)
  bool res = false;
  if (!g_bToitChk && isToit(now)) {
    // インデックス記録
    if (!recIndex(root, PI_TOIT | PAI_KIND(g_analyze[now]))) {
      return false;
    }

    // 深度優先検索
    g_bToitChk = true;
    if (!analyzeOneMent(g_piLast - 1, now + 2)) {
      // このルートは無い
      g_piLast--;   // 記録を消す
    } else {
    // このルートで最後まで解析できた
      res = true;
    }
    // 探索を続ける
    g_bToitChk = false;
  }

  // 刻子か?
  if (isKot(now)) {
    // インデックス記録
    if (!recIndex(root, PI_KOT | PAI_KIND(g_analyze[now]))) {
      return false;
    }

    // 深度優先検索
    if (!analyzeOneMent(g_piLast - 1, now + 3)) {
      // このルートは無い
      g_piLast--;   // 記録を消す
    } else {
    // このルートで最後まで解析できた
      res = true;
    }
    // 探索を続ける
  }

  // 順子か?
  if (now >= TEHAI_NUM - 2) {
    // 2枚組なので違う
    return res;
  }


  // そもそもMAN/PIN/SOUの1~7でないと駄目
  if (!isShuntTop(now)) {
    return res;
  }

  // スタック節約のために分かりにくいループにしてあります
  // 2枚目を探す
  // 同じ数字は省く
  u8 p2, p3;
  for (p2 = now + 1;;) {
    u8 a = PAI_KIND(g_analyze[now]);
    u8 p = PAI_KIND(g_analyze[p2]);
    if (a == p) {
      if (++p2 >= TEHAI_NUM - 1) {
        // 残り牌が無い
        return res;
      }
      // else continue;
    } else {
      // a != p
      if (a + 1 == p) {
        break;    // 三枚目を探す
      } else {
        // 順子ではない
        return res;
      }
    }
  }

  // 3枚目を探す
  // 同じ数字は省く
  for (p3 = p2 + 1;;) {
    u8 a = PAI_KIND(g_analyze[p2]);
    u8 p = PAI_KIND(g_analyze[p3]);
    if (a == p) {
      if (++p3 >= TEHAI_NUM) {
        // 残り牌が無い
        return res;
      }
      // else continue;
    } else {
      // a != p
      if (a + 1 == p) {
        break;    // 順子だ
      } else {
        // 順子ではない
        return res;
      }
    }
  }

  // 順子だ
  // インデックス記録
  if (!recIndex(root, PI_SHUNT | PAI_KIND(g_analyze[now]))) {
    return false;
  }

  // 面倒だけど牌を入れ替える
  swapPaiForShunt(now, p2, p3);

  // 深度優先検索
  if (!analyzeOneMent(g_piLast - 1, now + 3)) {
    // このルートは無い
    g_piLast--;   // 記録を消す
  } else {
  // このルートで最後まで解析できた
    res = true;
  }

  // 牌を戻す
  restorePaiForShunt(now, p2, p3);

  return res;
}

ソースはgithubのmajan.inoにあります。

やっているのは、ツリーに沿って片っ端(左端)からこの牌が対子になるか、刻子になるか、順子になるかをチェックしているだけです。
そして、メンツになったならばg_pindex[]というバッファに記録して、再帰で下層を探索に行きます。

ちょっとトリッキーなのは順子の探索でしょうか。 手牌はソートされている前提なので、

333444555

みたいな形がありえます。
そこで、同じ数牌はスキップしながら345という順子を見つけたならば、

345 334455

強引に並べ替えて下層の探索に行き、帰ってきたら元の形(333444555)に戻しています!

まるで、80286のプロテクトモードからリアルモードに復帰してくるのにCPUリセットするようなノリで、インテルの人に

「なんと野蛮な・・・」

と呆れられてしまうことでしょう3

動かしてみる

とりあえずできたんで、動かしてみます。
確認はデバッグシリアルを使いましょう。
じゃあ、サンプルにさっきの、

111 222 333 44 555

を使いましょう。
動かしてみると

と表示がされます。
おお、動いてる。 トシちゃんかんげきー(棒)

下の4行+1行が解析結果です。

S1S1S1T4K5
S1T1S2S2K5
K1K2K3T4K5
T1S1S2S2K5
4pattern

彼が言ってることを翻訳すると、

123-123-123-44-555
123-11-234-234-555
111-222-333-44-555
11-123-234-234-555
の4パターン

ということですね。確かに合ってます。
const unsigned char test_tehai[] をいじって他のパターンも試してみましょう。

111 222 333 44 556

K1K2S3T3S4  
K1K2T3S3S4  
2pattern

111 222 333 44 557 だと

no ten

と、にべもなく回答してくれます。

まだバグっているかもしれません。
他にもいろいろ試してみましょう。

次どうする

ここまでできたら、ほとんどできたようなものです!(本当か?)
次は、役になっているかどうかの判定を作りたいと思っています。
ハードコーディングしてやれば簡単に実装できそうに思うのですが、それではあまり面白くないので別の(正規表現を使うとかそういった類の)アプローチが無いかな?と考えています。

しばしお時間をいただけますと幸いです。


  1. 麻雀の基本的なルールや単語は適当に知ってください!

  2. 槓子は後で考えます!(2度め)

  3. 嘘か本当か知らないが、80286用にプロテクトメモリを使うEMS規格か何かを策定する時に、インテルから「どうやってリアルモードに戻るのか?」と聞かれたマイクロソフトプログラマが迷わず「CPUをリセットする」と答えたら「ソフト屋は野蛮ですな」と言われたとか何とか