Power Appsでテトリス風ゲーム(Tetrapps)を作ってみた


[【JPPGB】ゲーム作成コンテスト #1](https://jppgb.connpass.com/event/317001/)へのエントリー作品として、Power Appsでテトリス風ゲーム(Tetrapps)を作ってみました。

https://twitter.com/PowerPlatform4/status/1798926471358205969

[こちら](https://koruneko.github.io/JPPGB-GameContest-1/)からダウンロードして遊べます。

## 機能

### ボード

以前紹介した[2048](https://powerplatformnikki.com/powerapps2048/)や[リバーシ(オセロ)](https://powerplatformnikki.com/reversiwithchatgpt2/)と同様の方法でボードを作成しました。  
回転処理でバグってしまうので、Tetrappsはマイナスのマスを2行分用意しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/152f43b5-5a4e-5e69-1aad-07eead4af4fb.png)

```
ClearCollect(
    ColBoard,
    AddColumns(
        Sequence(216, -17),
        Column,
        Mod(ThisRecord.Value - 1, 9) + 1,
        Row,
        RoundUp(ThisRecord.Value / 9, 0),
        MinoStatus,
            {
                Status:"", Direction:0
            },
        MinoType,
        0
    )
);
```

マスの行列の値に加えて、そのマスにブロックがあるかやブロックの種類といった情報もコレクションで定義しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/489a4764-bc97-497a-3461-38684c511a4b.png)

https://powerplatformnikki.com/powerapps2048

### ブロック

使用するブロックの種類は14種類で、Microsoftのサービスの色を使用しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/f88b5b56-3c9c-a624-257a-7c7a15675cb4.png)


```
ClearCollect(
    ColBlocks,
    {Type:1, Space:[4, 6, 14, 23], Color:ColorValue("#0b556a")}, // Y型: Copilot Studio
    {Type:2, Space:[5, 6, 14, 15], Color:ColorValue("#742774")}, // O型: Apps
    {Type:3, Space:[5, 13, 14, 15], Color:ColorValue("#28ab81")}, // T型: PP
    {Type:4, Space:[5, 6, 13, 14], Color:ColorValue("#4b44c1")}, // S型: Pages
    {Type:5, Space:[4, 5, 14, 15], Color:ColorValue("#d04677")}, // Z型: Fx
    {Type:6, Space:[4, 13, 14, 15], Color:ColorValue("#fbbf08")}, // J型: BI
    {Type:7, Space:[6, 13, 14, 15], Color:ColorValue("#0066ff")}, // L型: Automate
    {Type:8, Space:[4, 22, 23, 24], Color:ColorValue("#2461ca")}, // SJ型: Word
    {Type:9, Space:[5, 22, 23, 24], Color:ColorValue("#107c41")}, // ST型: Excel
    {Type:10, Space:[6, 22, 23, 24], Color:ColorValue("#c43e1c")}, // SL型: PowerPoint
    {Type:11, Space:[5, 6, 22, 23], Color:ColorValue("#038387")}, // SS型: SharePoint
    {Type:12, Space:[4, 5, 23, 24], Color:ColorValue("#4f52b2")}, // SZ型: Teams
    {Type:13, Space:[5, 13, 15, 23], Color:ColorValue("#0f6cbd")}, // +型: Outlook
    {Type:14, Space:[4, 6, 22, 24], Color:ColorValue("#117865")} // SO型: Fabric
);
```

### 落下処理

タイマーコントロールのOnTimerEndに以下の数式を設定しており、タイマーのカウントごとに以下の処理を実行しています。

- 次の行が設置済みまたは最終行の場合は設置済み状態にする
- それ以外は移動可能なので次の行のマスを移動中にして、元々のマスのステータスを空白にする

```plaintext
With(
    {Moving:AddColumns(MovingMino, NextValue, ThisRecord.Value + 9)},
    If(
        //置く
        Or(
            CountIf(Filter(ColBoard, Value in Moving.NextValue) As NextValue, "Placed" in NextValue.MinoStatus.Status) <> 0,
            22 in Moving.Row
        ),
        Set(_SE, 設置);
        UpdateContext({_chain:_chain + 1});
        UpdateIf(
            ColBoard,
            Value in Moving.Value,
            {MinoStatus:{Status:"Placed", Direction:0}, MinoType:First(Moving).MinoType}
        );,

        //移動
        UpdateIf(
            ColBoard,
            Value in Moving.NextValue,
            {MinoStatus:First(Moving).MinoStatus, MinoType:First(Moving).MinoType},
            Value in Moving.Value,
            {MinoStatus:{Status:"", Direction:0}, MinoType:0}
        );
    )
)
```

AddColumnsとWithを使用してかなり綺麗に書けていると思います。

### 横移動処理

スライダーコントロールで十字キーを再現しており、水平方向のスライダーのOnChangeには以下の数式を設定しています。

スライダーの値が50より大きいか小さいかに応じて左右移動するようにしており、移動後のマスが行を超えておらず、設置済みマスもない場合に横移動できるような処理です。

```plaintext
If(
    Self.Value <> 50,
    With(
        {Moving:AddColumns(MovingMino, NextValue, ThisRecord.Value + If(Self.Value > 50, 1, -1))},
        If(
            And(
                //移動後のマスが行を超えていないか
                CountIf(
                    Moving As moving,
                    LookUp(ColBoard, Value = moving.NextValue, Row) <> moving.Row
                ) = 0,
                //移動後のマスに設置済みマスがないか
                CountIf(
                    Filter(ColBoard, Value in Moving.NextValue),
                    MinoStatus.Status = "Placed"
                ) = 0
            ),
            UpdateIf(
                ColBoard,
                Value in Moving.NextValue,
                {MinoType:First(Moving).MinoType, MinoStatus:{Status:"Moving", Direction:First(Moving).MinoStatus.Direction}},
                Value in Moving.Value,
                {MinoType:0, MinoStatus:{Status:"", Direction:0}}
            );
        )
    );
    Reset(Self);
    Reset(TextInputKeyBoard);
    SetFocus(TextInputKeyBoard);
);
```

最後にReset(Self)を入れることで移動後はスライダーの値がデフォルトの50に戻ります。

### 回転処理

このゲームで最も苦労した箇所です。

https://twitter.com/PowerPlatform4/status/1796925875239448932

```
Set(_SE, 回転);
With(
    {
        NextValue:
        AddColumns(
            AddColumns(
                MovingMino, 
                NextRow, 
                ThisRecord.Column - First(MovingRng).Column + First(MovingRng).Row, 
                NextColumn,
                2 - (ThisRecord.Row - First(MovingRng).Row) + First(MovingRng).Column
            ) As Next,
            NextValue,
            LookUp(ColBoard, Row = Next.NextRow && Column = Next.NextColumn, Value)
        ).NextValue
    },
    If(
        //回転可能なブロックの種類かつ回転可能か
        And(
            First(MovingMino).MinoType <> 2,
            CountIf(Filter(ColBoard, Value in NextValue), MinoStatus.Status = "Placed") = 0,
            Count(NextValue) = 4
        ),
        UpdateIf(
            ColBoard,
            Value in NextValue,
            {MinoStatus:{Status:"Moving", Direction:If(First(MovingMino).MinoStatus.Direction = 4, 1, First(MovingMino).MinoStatus.Direction + 1)}, MinoType:First(MovingMino).MinoType},
            Value in MovingMino.Value,
            {MinoStatus:{Status:"", Direction:0}, MinoType:0}
        );
        SetFocus(TextInputKeyBoard)
    )
)
```

左に90度回転するケースでブロックの存在する範囲を以下のように区切ったとき、回転前後のx, y座標はそれぞれ以下のようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/c93361e0-962f-2e57-58cd-cd481b37df0d.png)


つまり、x'=y, y'=2-xをそれぞれのマスで計算すれば回転後の座標を取得できます。

これまでもAddColumnsでNextValue列(移動後、回転後のValue)を計算してからUpdateIfで更新していましたが、こうすることでForAllよりも高速な処理ができます。

ここがForを使用できる普通の言語の考え方と最も大きく異なる部分で、Power Apps独自の数式を組む必要がありゲーム作成者を悩ませるポイントです。

### キーボード操作

テトリスは終盤に高速なコントローラー操作が求められますよね。

https://youtu.be/L_UPHsGR6fM?t=1585

マウスの操作だけでは操作の速度に限界があり、本物のテトリスのようなスピード感のあるゲームプレイができません。

なので、キーボード操作でゲームをプレイできるようにしてみました。

https://twitter.com/PowerPlatform4/status/1797623431762264374

ゲームスタート時やボタン選択時にSetFocus関数で1×1のテキスト入力コントロール(Visible=falseだとフォーカスできない)にフォーカスし、その入力に応じて十字キーのDefaultを変化させます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/4e211104-8444-1aa5-3731-c4cd1d918c50.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/fa2f43dd-0d0a-ac66-44e7-e0234f28d4e2.png)

### 次のブロックを選択する

オリジナル要素である、次の4ブロックを表示・選択するエリアです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/2fa0286f-e3e9-f1e8-5f05-102568f22493.png)

ギャラリー in ギャラリーを使用しているので、As演算子を使用して数式の可読性を向上しています。

### スコアに応じた落下速度の変化

スコアに応じた落下速度の変化には、tanh(ハイパーボリックタンジェント)を使用しています。  
最初は緩やかに、慣れてきたころに急激に速度が変化し、最後には一定の速度になる都合のいい数式です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/00636d18-78aa-a6c2-eac5-7f7bd18e9a3f.png)

Power Appsにはハイパーボリックタンジェント関数は存在しないので、eの数式に変換してExp関数で代替しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/35973d23-0dc4-036e-ebfd-f10e7b6be501.png)

### 妥協/実装を諦めた機能

#### ゴーストブロック・ハードドロップ

ゴーストブロックはその時点から何も操作をしなかったときの最終的なブロックの位置を表すブロック、ハードドロップはゴーストブロックの位置にブロックを設置する機能です。

ゴーストブロックの計算は回転、横移動の操作ごとに行う必要があり、実装してみたらかなりレスポンスが悪くなってしまったので実装を諦めました。

#### 特殊な条件での回転処理

T-Spinや壁際での回転処理は、ロジックを組むのがしんどいので諦めました。

#### ホールド

次のブロックが4つ先まで4つの選択肢から選べるようになっているので、ホールドを多用する優柔不断なプレーヤーにはゲームオーバーになってもらいます。

## 小ネタ

### スライダーの押しっぱなしを防ぐ

スライダーを選択したままにしていると次のブロックも高速で移動してしまうため、ブロックを置くたびにスライダーを元に戻す必要があります。

この動作を実装するために、設置後一瞬だけスライダーのVisibleをfalseにしています。

### 複雑な構造のテーブルでの含む検索

ColBoardのMinoStatus列はレコード型でStatus列とDirection列があり、このような構造ではin検索ができません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/930b1204-618b-9644-6325-0d50494e5151.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/4c2d1bd0-b9f0-036b-60f4-53c49b2aae60.png)

この場合にJSON関数を使うことでMinoStatus列をJSONに変換することでin検索を可能にしました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618051/30a3cae6-ee48-4481-43b8-20183df85ac7.png)

列名に検索対象の文字列が含まれていないことが前提です。

### X投稿リンク

Xへの記事投稿ボタンのリンクは以下記事を参考にさせていただきました。  
[https://qiita.com/bm0521/items/39ae81e2ba01c9d3e0bd](https://qiita.com/bm0521/items/39ae81e2ba01c9d3e0bd)

近日Launch関数についてまとめます。

## 音楽

bgm&SE：[魔王魂](https://maou.audio/)

## 元記事

https://powerplatformnikki.com/tetrapps/