Help us understand the problem. What is going on with this article?

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

以前にも書きましたが、PowerAppsでゲームを作ることはExcelでゲームを作ることに似ていて、本来の目的とは若干異なるかもしれません。
勉強のモチベーションを上げる1つとして考えていただければと思います。

ハノイの塔

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

ハノイの塔とは

出来上がりイメージ

PowerApps ハノイの塔

アプリ

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

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

解説

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

3つの塔の座標を定義

リングの位置をXY座標と考え位置を定義します。
image.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)のように記述すると初期化ボタンを押した時と同じイベントが発生するようになります。

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コレクションを紐づけます。
そのままだとデフォルトの四角が並ぶだけです。

Gallery_Tower.Items = rings

image.png

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

Gallery_Tower.TemplateSize = 0
Gallery_Tower.TemplatePadding = 0

全ての四角形が重なりました。
image.png

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

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

// 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

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

Drag & Dropでリングをつまんだり置いたりする

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

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

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

↓スライダー(Slider_DD)と宙に浮かんだリング(Rectangle_PinchingRing)を配置した直後のイメージ
image.png

スライダーを押したときの動作

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

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を比較することで置ける置けないが分かりますね。

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が実行されても何もしないように分岐させます。

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

Slider_DD.OnChange =
  If(DDPressed,

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

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

置けないときに表示されるバツマークの処理

image.png

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

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
      )
  )

音を鳴らす

前回のゲームと同じ処理になりますので省略します。

その他

デバイスの違いによりコントロールの仕様が若干異なるケースがあります。その違いで想定通りに動かないことがあります。
今回の場合ですと、モバイル端末においてスライダーはハンドル以外の部分をタップしてもドラッグできません。
つまりこのままではモバイル端末で遊べません。
ドラッグできない動画2.gif

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

Slider_DD.HandleSize = 10000

まとめ

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away