以前にも書きましたが、PowerAppsでゲームを作ることはExcelでゲームを作ることに似ていて、本来の目的とは若干異なるかもしれません。
勉強のモチベーションを上げる1つとして考えていただければと思います。
ハノイの塔
子どもがパズルに興味を持ち始めたのでハノイの塔を作成して遊んでもらおうと考えました。
出来上がりイメージ
アプリ
以下のコミュニティーでファイルを配布しています。
Microsoft Power Apps Community
https://powerusers.microsoft.com/t5/Community-App-Samples/Tower-of-Hanoi/td-p/431514
解説
ポイントだけいくつかまとめます。
また、説明のために一部のコードを省略したり簡略化している部分があります。
詳細はアプリをダウンロードしてご確認ください。
3つの塔の座標を定義
塔のデータをコレクションで管理
リングのデータをあらかじめコレクションに設定しておきます。データには座標情報と色、表示状態を持たせます。
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
各四角を意図した座標に置くためには1行分の表示スペース(破線部分)が邪魔なのでサイズを0にします。
Gallery_Tower.TemplateSize = 0
Gallery_Tower.TemplatePadding = 0
↓
【補足】仕様上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は微調整用
これでringsコレクションのposXやposYを更新するとリングの表示位置が変わるようになります。
Drag & Dropでリングをつまんだり置いたりする
Power Apps の標準コントロールではドラッグアンドドロップが出来ませんので、スライダーコントロールを使って疑似的に再現します。
スライダーはギャラリーの前面に重なるように配置しておきます。
では、スライダーをタップしたらリングを持ち上げ、手を離したら置く動作を作ります。
スライダーのOnSelectは押した直後、OnChangeは離した直後に動きますのでこれを利用します。
※スライダーをResetするとOnChangeが勝手に動くことがあるため使い方注意です。
持ち上げるといっても実際にはringsコレクションから該当のリングを非表示にして、浮かべているリングを表示に切り替えているだけになります。
↓スライダー(Slider_DD)と宙に浮かんだリング(Rectangle_PinchingRing)を配置した直後のイメージ
↓
スライダーを押したときの動作
スライダーをタップした位置(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);
);
置けないときに表示されるバツマークの処理
バツマークはスライダーの動きに合わせリアルタイムに判定しないといけませんが、前述した「スライダーを離したときの動作」と同じように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
)
)
音を鳴らす
前回のゲームと同じ処理になりますので省略します。
その他
デバイスの違いによりコントロールの仕様が若干異なるケースがあります。その違いで想定通りに動かないことがあります。
今回の場合ですと、モバイル端末においてスライダーはハンドル以外の部分をタップしてもドラッグできません。
つまりこのままではモバイル端末で遊べません。
これを回避するため、ハンドルのサイズを大きくしてどこを触ってもハンドルに触れるようにします。
Slider_DD.HandleSize = 10000
まとめ
ゲームを作るといろんな気づきがあって面白いですね。皆さんも息抜きに作ってみてはいかがでしょうかー。