「ツモ！」 - Power Apps で麻雀の和了判定を行う

# はじめに

[【JPPGB】ゲーム作成コンテスト #1](https://jppgb.connpass.com/event/317001/) の応募作品として、一人用ですがPower Apps で麻雀が遊べるゲームを作成しました。

<blockquote class="twitter-tweet" data-media-max-width="560"><p lang="ja" dir="ltr">頭こんがらがるで！<br> <a href="https://twitter.com/hashtag/JPPGB?src=hash&amp;ref_src=twsrc%5Etfw">#JPPGB</a> <a href="https://t.co/lTPUL98n3S">pic.twitter.com/lTPUL98n3S</a></p>&mdash; おいしみ (@ksgiksg) <a href="https://twitter.com/ksgiksg/status/1800886206118703120?ref_src=twsrc%5Etfw">June 12, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

アプリはこちらからダウンロードして遊ぶことができます。
（中身が見られるので詳しいロジックもご確認いただけます）

https://koruneko.github.io/JPPGB-GameContest-1/

# 根幹ロジックの解説

アプリの細かい部分は実際のアプリを確認してもらうとして、肝となる和了判定のロジックを解説しようと思います。

## 和了の形とは？
詳しくは麻雀そのものの遊び方・解説を参照していただきたいのですが、前提知識となるので簡単に説明しておきます。

全部で14枚の手牌で、以下の決められた形を作ることができればOKです。
1. 2枚組の`雀頭`という組み合わせが1組
1. 3枚組の`面子`という組み合わせが4組


:::note info
4枚組となる`槓子`の説明は省略しています
:::


`雀頭`は2枚の同じ牌で構成されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2572227/bf9f281f-3339-fff7-3966-4cef145b7a23.png)

`面子`は同じ牌を3枚(`刻子`)揃えるか、もしくは同じ種類の牌で連番となる3枚(`順子`)で構成されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2572227/1dd6dd8d-18f6-db80-fb2e-793971021c48.png)
&nbsp;  
&nbsp;  
よって、以下のように14枚集めることができれば和了となります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2572227/f012cef0-9790-d653-83d9-eecbf2a33040.png)

簡単ですね。
&nbsp;  
&nbsp;  
&nbsp;  
&nbsp;  

:::note alert
といっても、一色の場合はどこがどうなっているのか見分けるのが大変なんですけど。。。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2572227/646ced06-a0d9-cc8d-9a2e-199a26ebfe53.png)
:::

## 判定の手順

考え方はこの記事を参考…というかほとんどそのまま使用させていただきました。

https://www.engineer-log.com/entry/2018/06/14/mahjong-algorithm

1. 手牌のうち同じ種類が2枚以上あるものを、`雀頭`候補とする
1. `雀頭`候補を取り除いた12枚の組を、候補の数だけ作成する
1. `雀頭`候補を取り除いた12枚から、同じ種類が3枚以上あるものを、`刻子`候補とする
1. `刻子`候補それぞれに対して、`刻子`と見做すか見做さないかの2通りを考える
1. `刻子`としてみなす場合は取り除き、残った牌がすべて`順子`になっているか確認する
1. 以上の組み合わせのうち、1つでも最後まで取り除ける場合があれば和了とする


言葉にするとなんとなくわかった気になりますね。
ただし、これをPower Apps の関数で行うのは、「言うは易し行うは難し」でした。

# Power Apps での実装

## 手牌の生成
まずは大本となる山(牌のセット)を生成します。
牌は各種類同じものが4枚ずつ使用されます。

```powerfx:牌のセット
牌=[1,2,3,4,5,6,7,8,9];
//山の生成
Clear(山);
ForAll(Sequence(4),Collect(山,牌));
```

:::note info
簡単のため、ここから牌は1色の想定で解説します。
多色の場合も牌の種類が増えるだけで、ロジックは変わりません。
:::

次に、山からランダムに14枚を取り出して手牌とします。
手牌として取り出すと同時に、山からは取り除いておきます。

```powerfx:手牌の生成
ClearCollect(
    col牌山,
    Shuffle(Filter(山,Value<10||IsModeAll))
);
//手牌の生成
Clear(col手牌);
ForAll(
    Sequence(14),
    Collect(
        col手牌,
        First(col牌山)
    );
    Remove(
        col牌山,
        First(col牌山)
    )
);
```


## 雀頭候補の作成と手牌の整理
14枚のうち、同じ種類のものが2枚以上あるものを雀頭候補とします。
雀頭候補を調べるために、牌の種類すべてに対して繰り返し処理を行います。

```powerfx:雀頭候補
//手牌分析：頭になりそうな牌を探す
ClearCollect(
    col雀頭候補,
    Distinct(
        Filter(
            牌 As x,
            CountIf(
                col手牌 As y,
                x.Value=y.Value
            )>=2
        ),
        Value
    )
);
```

と同時に単純に牌の集まりから、牌の種類と枚数をセットにしたオブジェクト配列に変換します。
```powerfx:手牌の整理
//上がり判定の準備：各種類の牌を所持枚数でまとめる
ClearCollect(
    col数値化,
    ForAll(
        牌 As x,
        {
            牌種:x.Value,
            枚数:CountIf(
                col手牌 As y,
                x.Value=y.Value
            )
        }
    )
);
```

## 雀頭と刻子の除去
ここから複雑になってきます。
全体はこうなりますが、ながいので折りたたんで、部分部分で解説していきます。

<details><summary>雀頭と刻子の除去(全体)</summary>

```powerfx:雀頭と刻子の除去
//判定開始
Clear(col順子のみ);
ForAll(
    col雀頭候補 As a,
    //まずは雀頭候補それぞれを取り除いたパターンを生成    
    With(
        {
            雀頭候補除去:ForAll(
                col数値化 As b,
                {
                    牌種: b.牌種,
                    枚数: b.枚数 - If(
                        b.牌種 = a.Value,
                        2,
                        0
                    )
                }
            )
        },
        //雀頭を取り除いたものの中から3枚以上の刻子候補を探す
        With(
            {
                刻子候補:With(
                    {
                        三枚以上:Distinct(
                            Filter(
                                雀頭候補除去,
                                枚数>=3
                            ),
                            牌種
                        )
                    },
                    //インデックスを付与
                    ForAll(
                        Sequence(CountRows(三枚以上)) As y,
                        {
                            枚数:y.Value,
                            牌種:Index(三枚以上,y.Value).Value
                        }
                    )
                )
            },
            //刻子候補ごとにループし、雀頭を取り除いた手配から刻子を取り除く
            //刻子候補がn個があれば、それを刻子っと扱わないかの２通り
            //つまり2^n乗通りの組み合わせ
            ForAll(
                Sequence(Max(1,2^CountRows(刻子候補),0)) As DEC,
                With(
                    {
                        刻子除去:ForAll(
                            雀頭候補除去 As d,
                            {
                                牌種: d.牌種,
                                枚数: d.枚数 - If(
                                    //刻子候補となる牌
                                    d.牌種 in Distinct(刻子候補,牌種)
                                    &&
                                    //二進数フラグでON/OFF
                                    With(
                                        {x:LookUp(刻子候補,牌種=d.牌種).枚数},
                                        (Mod(DEC.Value,Power(2,x))>=Power(2,x-1))
                                        &&
                                        (Mod(DEC.Value,Power(2,x))<Power(2,x))
                                    ),
                                    3,
                                    0
                                )
                            }
                        )
                    },
                    //刻子候補を取り除いた全パターンをコレクションに登録
                    //雀頭・刻子を取り除いたので、残りは順子のみになっているはず
                    Collect(
                        col順子のみ,
                        {Value:刻子除去}
                    )
                )
            )
        )
    )
);
```

</details>

### 雀頭候補の除去
14枚から雀頭候補を除き12枚の組を候補の数だけ生成します。
雀頭候補でForAll繰り返し処理を行い、手牌の枚数から雀頭として2枚削除します。

```powerfx:雀頭候補の除去
ForAll(
    col雀頭候補 As a,
    //まずは雀頭候補それぞれを取り除いたパターンを生成    
    With(
        {
            雀頭候補除去:ForAll(
                col数値化 As b,
                {
                    牌種: b.牌種,
                    枚数: b.枚数 - If(
                        b.牌種 = a.Value,
                        2,
                        0
                    )
                }
            )
        },
```

### 刻子候補の抽出
雀頭候補を取り除いた12枚のうち、それでもまだ3枚以上あるものを刻子候補とします。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2572227/c118a96c-222a-ad89-1b3b-b1ec744d0ffa.png)

```powerfx:刻子候補 
        //雀頭を取り除いたものの中から3枚以上の刻子候補を探す
        With(
            {
                刻子候補:With(
                    {
                        三枚以上:Distinct(
                            Filter(
                                雀頭候補除去,
                                枚数>=3
                            ),
                            牌種
                        )
                    },
```

### 刻子候補の除去
刻子候補ごとに、刻子として扱う扱わないの2通りを考えます。
つまり、先の例では刻子候補が2種あったので、組み合わせは2^2=4通りとなります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2572227/6e7b235c-a247-7bf7-ed18-a15164686020.png)

刻子候補をnとすると、2^n通りのパターンが生成されます。

それらをうまく組み合わせるために、2進数の考え方を使ってON/OFFを切り替える処理をしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2572227/b7ff2c18-041d-9b90-2fa0-31d28ae981ae.png)


ここではHiroさんの10進数を2進数に変換するロジックを参考にしました。

https://mofumofupower.hatenablog.com/entry/pa_dec2bin

```powerfx:刻子候補の除去
            //刻子候補ごとにループし、雀頭を取り除いた手配から刻子を取り除く
            //刻子候補がn個があれば、それを刻子っと扱わないかの２通り
            //つまり2^n乗通りの組み合わせ
            ForAll(
                Sequence(Max(1,2^CountRows(刻子候補),0)) As DEC,
                With(
                    {
                        刻子除去:ForAll(
                            雀頭候補除去 As d,
                            {
                                牌種: d.牌種,
                                枚数: d.枚数 - If(
                                    //刻子候補となる牌
                                    d.牌種 in Distinct(刻子候補,牌種)
                                    &&
                                    //二進数フラグでON/OFF
                                    With(
                                        {x:LookUp(刻子候補,牌種=d.牌種).枚数},
                                        (Mod(DEC.Value,Power(2,x))>=Power(2,x-1))
                                        &&
                                        (Mod(DEC.Value,Power(2,x))<Power(2,x))
                                    ),
                                    3,
                                    0
                                )
                            }
                        )
                    },

```

## 順子の除去
最後に残ったものから順子、連番になっているものを削除します。

今回は連番かどうかを考えず、残った牌のうち一番小さいものを探し、その牌と⁺1の牌、⁺2の牌の3枚を除く方法を取りました。

順子を取り除く処理は、残った牌の枚数÷3回(12枚残っていれば、3枚の組を4回)除かなければならないのですが、今までのようにForAllで処理することが難しいです。

「1組目を取り除いた結果を引き継いで、2回目の次の順子を取り除く」というReduce的な処理が必要だからです。
3枚取り除く→残っていればまた同じ処理をする　といった再帰処理がPower Appsでは難しい点も、この処理を難しくさせています。

結果を引き継ぐためにUpdateIfを使用しています。
ForAllの中でコレクションを編集する手法は、PPログさんの記事を参考にしています。

https://powerplatformnikki.com/postteamswithimage/


```powerfx:順子の除去
//順子のみになっているかを判定するために、ループ用インデックス付きコレクションを生成
//UpdateIfを使いたいので、特定用のid
Clear(colItems_Indexed);
ForAll(
    Sequence(CountRows(col順子のみ)) As x,
    Collect(
        colItems_Indexed,
        {
            Value:Index(col順子のみ,x.Value).Value,
            id:x.Value
        }
    )
);


//順子のみかどうかを判定する
ClearCollect(col順子除去,colItems_Indexed);

//雀頭・刻子を取り除いた全パターンでループ
ForAll(
    //ループ用にidのみの配列
    Distinct(col順子除去,id) As x,
    ForAll(
        //残った牌の枚数÷3が面子(順子)の数
        Sequence(Sum(LookUp(col順子除去,id=x.Value).Value,枚数)/3),
        UpdateIf(
            col順子除去,
            id=LookUp(col順子除去,id=x.Value).id,
            {
                id:x.Value,
                Value:ForAll(
                    LookUp(col順子除去,id=x.Value).Value,
                    With(
                        //端牌から確認していけば順子が取り除けるはず
                        {min:Min(Filter(LookUp(col順子除去,id=x.Value).Value,枚数>0),牌種)},
                        {
                            牌種:牌種,
                            //一番端牌から＋２の範囲の３枚が順子になっているはずなので、１枚ずつ減らす
                            枚数:枚数-If((牌種>=min)&&(牌種<=min+2),1,0)
                        }
                    )
                )
            }
        )
    )
);
```


連番になっていない場合は、無い牌を除いてしまうのでマイナスの枚数になってしまいますが、最終的にすべての牌の枚数が0かどうかで判断するようにしたため、マイナスになる場合はそもそも連番になってないのだから成り立っていないとして処理することができました。

```powerfx:最終判定
//最終判定
UpdateContext(
    {
        result:Or(
            //雀頭・刻子・順子の順に処理し、きれいに0になっていたらOK
            true in ForAll(
                col順子除去 As x,
                IsEmpty(
                    Filter(x.Value,枚数<>0)
                )
            ),
            //七対子判定
            CountRows(
                    Filter(col数値化,枚数=2)
            )=7,
            //国士無双判定
            CountRows(
                Filter(
                    col数値化,
                    牌種 in [1,9,21,29,41,49,60,65,70,75,80,85,90],
                    枚数>0,
                    枚数<3
                )
            )=13
        )
    }
)
```
特殊系の和了である七対子や国士無双の判定も行っていますが、あまり難しい処理ではないので割愛します。


# おわりに
これでPower Apps で麻雀の和了を判定することができました！
和了かどうかを判定することはできましたが、どんな形で和了ったか？役はあるか？などの判定や、4枚使いの槓子を含んだ判定ができていないので、今後の課題としています！