[Power Apps]ハノイの塔を作ってみた

[以前](https://qiita.com/tomikiya/items/a9095bfb74c9b342bd0f#%E3%81%8A%E3%81%93%E3%81%A8%E3%82%8F%E3%82%8A)にも書きましたが、PowerAppsでゲームを作ることはExcelでゲームを作ることに似ていて、本来の目的とは若干異なるかもしれません。
勉強のモチベーションを上げる1つとして考えていただければと思います。

# ハノイの塔
子どもがパズルに興味を持ち始めたのでハノイの塔を作成して遊んでもらおうと考えました。

[ハノイの塔とは](https://ja.wikipedia.org/wiki/%E3%83%8F%E3%83%8E%E3%82%A4%E3%81%AE%E5%A1%94)

# 出来上がりイメージ
[![PowerApps ハノイの塔](http://img.youtube.com/vi/K2JvvHBsnHQ/0.jpg)](https://youtu.be/K2JvvHBsnHQ)

# アプリ
以下のコミュニティーでファイルを配布しています。

Microsoft Power Apps Community
https://powerusers.microsoft.com/t5/Community-App-Samples/Tower-of-Hanoi/td-p/431514

# 解説
ポイントだけいくつかまとめます。
また、説明のために一部のコードを省略したり簡略化している部分があります。
詳細はアプリをダウンロードしてご確認ください。

## ３つの塔の座標を定義
リングの位置をXY座標と考え位置を定義します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97601/5db35215-d67d-a6dc-12cf-5ce36a8cea10.png)


## 塔のデータをコレクションで管理
リングのデータをあらかじめコレクションに設定しておきます。データには座標情報と色、表示状態を持たせます。

| id   | posX | posY | color_r | color_g | color_b | visible |
| ---- | ---- | ---- | ------- | ------- | ------- | ------- |
| 1    | 1    | 5    | 217     | 96      | 70      | true    |
| 2    | 1    | 4    | 219     | 132     | 55      | true    |
| 3    | 1    | 3    | 237     | 195     | 49      | true    |
| 4    | 1    | 2    | 85      | 181     | 154     | true    |
| 5    | 1    | 1    | 101     | 194     | 113     | true    |

id=1が赤いリングになります。
この設定をアプリの`OnStart`や画面の`OnVisible`で直接登録してもよいですがデバックがつらくなることが多いので、初期化用ボタンを用意して`OnSelect`で登録した方が良かったです。OnStartやOnVisibleには`Select(Button1)`のように記述すると初期化ボタンを押した時と同じイベントが発生するようになります。

```c#:
Button_Init.OnSelect =
  ClearCollect(rings,
    {id:1,posX:1,posY:0,color_r:217,color_g: 96,color_b: 70,visible:true},
    {id:2,posX:1,posY:0,color_r:219,color_g:132,color_b: 55,visible:true},
    {id:3,posX:1,posY:0,color_r:237,color_g:195,color_b: 49,visible:true},
    {id:4,posX:1,posY:0,color_r: 85,color_g:181,color_b:154,visible:true},
    {id:5,posX:1,posY:0,color_r:101,color_g:194,color_b:113,visible:true}
  );

Screen_Game.OnVisible = Select(Button_Init)
```

## ギャラリーでリングを表示
ギャラリーはテーブルのデータを一覧で表示してくれるコントロールです。ギャラリー内にRectangleコントロールを追加して先ほどのringsコレクションを紐づけます。
そのままだとデフォルトの四角が並ぶだけです。

```c#:
Gallery_Tower.Items = rings
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97601/9d5b7779-aa2a-d2ae-a0fe-d56a095d2566.png)

各四角を意図した座標に置くためには1行分の表示スペース（破線部分）が邪魔なのでサイズを0にします。

```c#:
Gallery_Tower.TemplateSize = 0
Gallery_Tower.TemplatePadding = 0
```

↓

全ての四角形が重なりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97601/0c2724af-f8e4-a739-2048-981ab85a13cb.png)

【補足】仕様上`Templatesize=0`にしても1行ごとに1ピクセルずつずれます。これはYプロパティで微調整します。


あとはRectangleプロパティにコレクションの情報を設定していき、リングの形になるようにします。

```c#:
// ringHeight と ringWidth は 20の固定値です。Button_Initで設定しておきます。
Rectangle_Ring.Fill = RGBA(ThisItem.color_r, ThisItem.color_g, ThisItem.color_b, 1)
Rectangle_Ring.BorderColor =  RGBA(ThisItem.color_r * 0.8, ThisItem.color_g * 0.8, ThisItem.color_b * 0.8, 1)
Rectangle_Ring.Height = ringHeight
Rectangle_Ring.Width = ThisItem.id * ringWidth
Rectangle_Ring.X = Gallery_Tower.Width/3 * (ThisItem.posX-1) + (Gallery_Tower.Width/3 - ThisItem.id * ringWidht)/2
Rectangle_Ring.Y = Gallery_Tower.Height - 100 - (ThisItem.posY) * ringHeight - ThisItem.id+1 // ThisItem.id+1は微調整用
```

↓リングの塔が出来上がりました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97601/c5e13a5e-661c-0ccc-1412-d7605d6dc578.png)

これでringsコレクションのposXやposYを更新するとリングの表示位置が変わるようになります。

## Drag & Dropでリングをつまんだり置いたりする
Power Apps の標準コントロールではドラッグアンドドロップが出来ませんので、スライダーコントロールを使って疑似的に再現します。
スライダーはギャラリーの前面に重なるように配置しておきます。

では、スライダーをタップしたらリングを持ち上げ、手を離したら置く動作を作ります。
**スライダーのOnSelectは押した直後、OnChangeは離した直後に動きます**のでこれを利用します。
※スライダーをResetするとOnChangeが勝手に動くことがあるため使い方注意です。

持ち上げるといっても実際にはringsコレクションから該当のリングを非表示にして、浮かべているリングを表示に切り替えているだけになります。

↓スライダー(Slider_DD)と宙に浮かんだリング(Rectangle_PinchingRing)を配置した直後のイメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97601/0c58f5e6-6898-e71f-7963-c38bfea701ef.png)

↓

### スライダーを押したときの動作
スライダーをタップした位置(Value)からposXを計算し、ringsコレクションを検索して一番上のリングを特定します。

```c#:
Slider_DD.OnSelect =
  // 持ち上げるリングを検索
  UpdateContext(
      {PinchingRing:
          First(
              Sort(
                  Filter(rings,posX = Min(3,RoundDown(Slider_DD.Value / (Slider_DD.Max/3),0) + 1)), //スライダーのValueからposXを特定
                  posY,
                  Descending
              )
          )
      }
  );
  // 持ち上げるリングを非表示
  UpdateIf(
      rings,
      posX = PinchingRing.posX && posY = PinchingRing.posY,
      {visible: false}
  );

// 宙に浮いているリングはPinchingRingに依存、PinchingRingがBlankだと表示されません。
Rectangle_PinchingRing.Fill = RGBA(PinchingRing.color_r, PinchingRing.color_g, PinchingRing.color_b, 1)
Rectangle_PinchingRing.Width = PinchingRing.id*ringWidht
Rectangle_PinchingRing.Height = ringHeight
Rectangle_PinchingRing.X = Slider_DD.X + Slider_DD.Value/Slider_DD.Max * Slider_DD.Width - Rectangle_PinchingRing.Width/2 //スライダーの値を直接参照することでリアルタイムに移動ができるようになります。
Rectangle_PinchingRing.Y = Gallery_Tower.Y + Gallery_Tower.Height*0.4
```

### スライダーを離したときの動作
離した位置から置く塔を計算し、置けるようならそのまま置き、置けないようなら元の位置に戻します。
リングのidが小さければ小さいリングと定義しましたので、idを比較することで置ける置けないが分かりますね。

```c#:
Slider_DD.OnChange =
  // 置く塔を計算
  UpdateContext({putPosX:Min(3,RoundDown(Slider_DD.Value/(Slider_DD.Max/3),0)+1)});   

  // 置く塔の一番上のリングを取得
  UpdateContext(
      {topRing:
          First(
              Sort(
                  Filter(rings,posX=putPosX,visible=true),
                  posY,
                  Descending
              )
          )
      }
  );

  // 一番上のリングのidを取得、塔にリングが無かったら最大id+1を取得
  If(IsBlank(topRing),
      UpdateContext({topRingId:countOfRing+1}),
      UpdateContext({topRingId:topRing.id})
  );

  // 置いてもよいか計算し置き場所を決定  ※idが小さい=小さいリング=置ける
  If(PinchingRing.id<topRingId,
       UpdateContext({putPosY:topRing.posY+1,putOK:true}),
       UpdateContext({putPosX:PinchingRing.posX,putPosY:PinchingRing.posY,putOK:false})
  );
 
  // 置く = ringsコレクションの更新
  UpdateIf(rings,posX=PinchingRing.posX && posY=PinchingRing.posY ,{posX:putPosX,posY:putPosY,visible:true});
  UpdateContext({PinchingRing:{});
```

#### 注意点
スライダーのOnChangeはValueが変わらないと動作しません。つまりスライダーをつかんでから動かさずに離すとOnChangeが実行されません。これまでの実装だけだとつまんだリングを元の位置に戻せなくなります。
このため、Valueが同じ値にならないような仕掛けが必要になります。

##### 仕掛1: スライダーのMaxを大きくする
Maxの値を大きくすることで同じValueになる確率を下げます。
またMaxを大きくすると範囲が大きくなってスライダーでValueの調整が難しくなるため、例えばValue=1はUI上選択できなくなります。

##### 仕掛2: スライダーを毎回リセットする
スライダーのDefaultを1にして離すたびにリセットします。仕掛1と合わさることで必ずOnChangeが実行されるようになります。

ただしスライダーをリセットする瞬間にもOnChangeが実行されるため回避処理も追加します。
押している状態を管理する変数`DDPressed`を追加して、「ユーザが意図して操作していない時」にOnChangeが実行されても何もしないように分岐させます。

```c#:
Slider_DD.OnSelect = UpdateContext({DDPressed:true})

Slider_DD.OnChange =
  If(DDPressed,

      //----
      //ここにスライダーを離したときの処理を書く
      //----

      UpdateContext({DDPressed:false});
      Reset(Slider_DD);
  );
```

## 置けないときに表示されるバツマークの処理
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97601/9a0532d2-1c6b-ef50-ba66-6efb47558971.png)

バツマークはスライダーの動きに合わせリアルタイムに判定しないといけませんが、前述した「スライダーを離したときの動作」と同じようにIF等を使っていくつかのプロセスを踏む必要があります。
しかし変数を1度でも経由するとスライダーの値をリアルタイムに反映できなくなります。
これを回避するために[With](https://docs.microsoft.com/ja-jp/powerapps/maker/canvas-apps/functions/function-with)を使います。With経由だとリアルタイムに反映されます。

```c#:
Icon_NavigateRing_NG.Visible = 
  With(
      {tRing: // 一番上のリングを取得
          First(
              Sort(
                  Filter(rings,posX=Min(3,RoundDown(Slider_DD.Value/(Slider_DD.Max/3),0))+1,visible=true),
                  posY,
                  Descending
              )
          )
      }, 
      With(
          {targetId:If(IsBlank(tRing),99,tRing.id)},
           PinchingRing.id > targetId
      )
  )
```

## 音を鳴らす
[前回のゲーム](https://qiita.com/tomikiya/items/a9095bfb74c9b342bd0f#bgmse%E3%81%AE%E8%BF%BD%E5%8A%A0)と同じ処理になりますので省略します。

## その他
デバイスの違いによりコントロールの仕様が若干異なるケースがあります。その違いで想定通りに動かないことがあります。
今回の場合ですと、モバイル端末においてスライダーはハンドル以外の部分をタップしてもドラッグできません。
つまりこのままではモバイル端末で遊べません。
![ドラッグできない動画2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/97601/18171ca3-3f57-686f-5fc0-66ebab2a1ede.gif)

これを回避するため、ハンドルのサイズを大きくしてどこを触ってもハンドルに触れるようにします。

```c#
Slider_DD.HandleSize = 10000
```

# まとめ
ゲームを作るといろんな気づきがあって面白いですね。皆さんも息抜きに作ってみてはいかがでしょうかー。