Power Apps でゲームを作る~ボールとブロックの衝突判定~

こんにちは!サトハルです。

連載モノは完結させられたためしがないので避けたかったのですが気づいたらシリーズ化してしまった本投稿です。

前回はブロックを好き勝手に並べることができるようになりました。

今回は並べたブロックと移動するボールの衝突判定ロジックを作成していきます。

厳密に正確な挙動をするかどうかよりもなるべく単純にそれっぽい挙動をすることを目指していきます。

表記方法について

前回同様に一つのフィールドに大量にロジックを書く場合があります。

その時には以下のように表記します。

Clear(Blocks);
ForAll([1,2,3,4,5] As I_x,
    ForAll([1,2,3] As I_y,
        Collect(Blocks,{
            life:RoundUp(Rand()*3,0) ,
            num:I_y.Value + (I_x.Value-1)*3,
            x:Block_width*(I_x.Value-1)+Block_padding*I_x.Value,
            y:Block_height*(I_y.Value-1)+Block_padding*I_y.Value
            })
    )
);
Navigate(ゲーム画面);

前提条件の振り返り

全体

・座標系はゲームエリアコンテナー内で完結している

・コンテナーの左上が(0,0)

・Timerコンポーネントが指定した期間を数え終えるたびにロジックが走る

ブロック

・縦横余白の長さはグローバル変数で定義されている

・X,Y座標はコレクションで定義されている

・整数のlifeパラメータを持っている

・lifeパラメータが0以下になるとブロックは無効になる(表示が消え接触判定も行わなくなる)

ボール

・縦横の大きさはボール丸コンポーネントのプロパティで直接定義されている

・X,Y座標、X方向,Y方向の移動量はコンテキスト変数で保持している

・ボールの座標は左上の隅を指している

・壁にぶつかると進行方向が壁を軸として反転する

衝突とは

・いろいろ考えられるけど今回は簡単のために「物体と物体が重なること」と定義してます

縦方向の衝突判定

まずは縦方向の衝突判定にのみ注目して考えます。

ボールがブロックに対して縦方向にぶつかるときは「ボールが上方向に移動していてブロックの下の端に当たる」か「ボールが下方向に移動していてブロックの上の端に当たる」かの2パターンしかありません。

それぞれを図示すると以下のようになります。

見た目上はバリエーションがありそうですが、縦方向に注目すると2パターンしかないです。

比較する場所は同じ色で塗ってあります。

  • ボールの右辺がブロックの左辺(=衝突する辺の左端)より右側にあること(水色)
  • ボールの左辺がブロックの右辺(=衝突する辺の右端)より左側にあること(黄色)
  • ボールの上辺がブロックの衝突する辺より上側にあること(緑色)
  • ボールの下辺がブロックの衝突する辺より下側にあること(赤色)

上記の4つの条件を全て満たせば衝突していると言えます。

全ての有効なブロックの中から条件を満たすものをFIlter関数を使って取り出すとすると、以下のようなコードになります。

~前略~
//上下判定
    If(
        //上向きに移動かつ下の端に接触
        ball_vy < 0,
        ClearCollect(
            Y_Hit_Blocks,
            Filter(
                Blocks,
                ThisRecord.life > 0,
                ThisRecord.y+ Block_height < ball_posy + ボール丸.Height,
                ThisRecord.y + Block_height > ball_posy,
                ThisRecord.x + Block_width > ball_posx,
                ThisRecord.x < ball_posx + ボール丸.Width
                
            )
        ),
        //下向きに移動かつ上の端に接触
        ClearCollect(
            Y_Hit_Blocks,
            Filter(
                Blocks,
                ThisRecord.life > 0,
                ThisRecord.y < ball_posy + ボール丸.Height,
                ThisRecord.y > ball_posy,
                ThisRecord.x + Block_width > ball_posx,
                ThisRecord.x < ball_posx + ボール丸.Width                
            )
        )
    );
~後略~

これで、縦方向に衝突したブロックがY_Hit_Blocksに格納されました。

横方向の衝突

縦方向と同様に考えていくと、横方向の衝突判定は以下のようなコードで表現できます。

~前略~
    //左右判定
    If(ball_vx >0,
        //右向きに移動かつ左端に接触
        ClearCollect(
            X_Hit_Blocks,
            Filter(
                Blocks,
                ThisRecord.x <ball_posx+ボール丸.Width,
                ThisRecord.x > ball_posx,
                ThisRecord.y < ball_posy+ ボール丸.Height,
                ThisRecord.y + Block_height > ball_posy,
                ThisRecord.life > 0
            )
        ),
        //左向きに移動かつ右端に接触
        ClearCollect(
            X_Hit_Blocks,
            Filter(
                Blocks,
                ThisRecord.x +Block_width <ball_posx+ボール丸.Width,
                ThisRecord.x + Block_width> ball_posx,
                ThisRecord.y < ball_posy+ ボール丸.Height,
                ThisRecord.y + Block_height > ball_posy,
                ThisRecord.life > 0
            )
        )
    ); 
~後略~

これによって、横方向に衝突したブロックがX_Hit_Blocksに格納されました。

カドに衝突した場合の判定

ここまでで縦方向と横方向それぞれの判定は作れたように思えます。

しかし、このままそれらを組み合わせると違和感の大きい挙動をしてしまいます。

以下の図を見てください。

右上方向に進んでいるボールがブロックに衝突した結果、左下方向に進んでしまうという例です。

直感的には下から当たってると判定して右下に跳ね返って欲しいですが、これまでのロジックだけではブロックの下辺と同時に左辺にも当たっていると判定してしまいます。

そこで、カド周辺に当たった場合にはさらに上下左右のどちらの面にぶつかっているのかを改めて判断することにします。

まず、カド周辺にぶつかったことを識別します。

~前略~
    //角にあたった場合の処理
    Clear(XY_Hit_Blocks);
    //角にあたったブロックを抽出
    ForAll(X_Hit_Blocks As X_block,
        ForAll(Filter(Y_Hit_Blocks,ThisRecord.num = X_block.num) As XY_Block,
            Collect(XY_Hit_Blocks,XY_Block)
        )
    );
~後略~

これでXY_Hit_Blocksにカド周辺で衝突したブロックが格納されます。

続いて、どちらの面から衝突したかを判定します。

「どちら」というのがどの2択になるかは4つのカドのそれぞれのパターンである4種類しかありません。

肝心の判定ロジックですが、ボールを囲う四角形とブロックの重なっている部分である四角形の縦と横の大きさを比較して大きい方から衝突したとします。

逆に両者を比較して小さい方からは衝突しなかったものとします。

仮に縦と横の大きさがぴったり一緒だった場合には両方の面と衝突したとします。

これらの判定ロジックを表現すると以下のようなコードになります。

~前略~
    If(
        CountRows(XY_Hit_Blocks)>0,
        //右上向き
        If(ball_vx>0 && ball_vy<0,
            ForAll(XY_Hit_Blocks,
                If((ball_posx+ボール丸.Width-ThisRecord.x )-(ThisRecord.y+Block_height -ball_posy)>=0,
                    Remove(X_Hit_Blocks,ThisRecord),
                    If((ball_posx+ボール丸.Width-ThisRecord.x )-(ThisRecord.y+Block_height -ball_posy)<0,
                        Remove(Y_Hit_Blocks,ThisRecord)
                    )
                )                
            ),
            //左上向き
            If(ball_vx<0 && ball_vy<0,
                ForAll(XY_Hit_Blocks,
                    If((ThisRecord.x +Block_width - ball_posx)-(ThisRecord.y+Block_height -ball_posy)>=0,
                        Remove(X_Hit_Blocks,ThisRecord),
                        If((ThisRecord.x +Block_width - ball_posx)-(ThisRecord.y+Block_height -ball_posy)<0,
                            Remove(Y_Hit_Blocks,ThisRecord)
                        )
                    )                
                ),
                //左下向き
                If(ball_vx<0 && ball_vy>0,
                    ForAll(XY_Hit_Blocks,
                        If((ThisRecord.x +Block_width - ball_posx)-(ball_posy+ボール丸.Height -ThisRecord.y)>=0,
                            Remove(X_Hit_Blocks,ThisRecord),
                            If((ThisRecord.x +Block_width - ball_posx)-(ball_posy+ボール丸.Height -ThisRecord.y)<0,
                                Remove(Y_Hit_Blocks,ThisRecord)
                            )
                        )                
                    ),
                    //右下向き
                    If(ball_vx>0 && ball_vy>0,
                        ForAll(XY_Hit_Blocks,
                            If((ball_posx+ボール丸.Width-ThisRecord.x)-(ball_posy+ボール丸.Height -ThisRecord.y)>=0,
                                Remove(X_Hit_Blocks,ThisRecord),
                                If((ball_posx+ボール丸.Width-ThisRecord.x)-(ball_posy+ボール丸.Height -ThisRecord.y)<0,
                                    Remove(Y_Hit_Blocks,ThisRecord)
                                )
                            )                
                        )
                    )
                )
            )
        )
    );
~後略~

中々膨大な分岐に見えますが、行っている中身は前述の通り単純なものです。

このロジックはvxとvyの絶対値の差が大きいと厳密には正しくない挙動をしますが、細かいことは気にしない方針です。

衝突した後の処理

これまでのロジックにより、どのブロックにどの向きで当たったのかを判定することができました。

それらを使って挙動を変更していきます。

最初に、当たったブロックに対してlifeを1減らします。

次に、縦横それぞれについて一つでも衝突したブロックがあればその向きの速度を反転させます。

最後に、当たったブロックの数に応じてスコアを上昇させます。

せっかくなので横向きから当てた時の点数を高くしてみます。

これらを表現すると以下のようなコードになります。

~前略~
    If(
        CountRows(Y_Hit_Blocks) > 0,
        ForAll(Y_Hit_Blocks,
            Patch(
                Blocks,
                ThisRecord,
                {life: ThisRecord.life - 1}
            );
        );
        UpdateContext(
            {
                ball_vy: -ball_vy,
                score:score+(100* CountRows(Y_Hit_Blocks))
            }
        );
    );
    If(
        CountRows(X_Hit_Blocks) > 0,
        ForAll(X_Hit_Blocks,
            Patch(
                Blocks,
                ThisRecord,
                {life: ThisRecord.life - 1}
            );
        );
        UpdateContext(
            {
                ball_vx: -ball_vx,
                score:score+(150* CountRows(X_Hit_Blocks))
            }
        );
    )    
~後略~

まとめ

今回行ったのは簡易的な衝突判定ロジックですが、Power Apps で表現するとこんな感じになりました。

Filter関数を用いて衝突したブロックを取れる部分なんかはC#だとLINQとか使って表現するのに近いのかなぁと思ったりします。

UI関係ないガッツリロジックも意外といけると評判(?)の Power Apps を皆さんぜひとも触ってみてください!

次回はようやくユーザーが操作できる部分を作ろうと思います。

シリーズリンク

1,Power Apps でゲームを作る~アニメーション編~ #Power Platformリレー

2,Power Apps でゲームを作る~ブロックを自在に並べる~ #Power Platformリレー

3,Power Apps でゲームを作る~ボールとブロックの衝突判定~ (←今ココ!)

4,Power Apps でゲームを作る~ユーザーの操作を受け付ける~ #Power Platformリレー

おまけ(コード全文)

//移動する
UpdateContext(
    {
        ball_posx: ball_posx + ball_vx,
        ball_posy: ball_posy + ball_vy
    }
);
//壁判定
If(
    ball_posx < 0,
    UpdateContext({
        ball_vx: -ball_vx,
        ball_posx:0
    }),
    If(
        ball_posx + ボール丸.Width >= ゲームエリアコンテナー.Width,
        UpdateContext({
            ball_vx: -ball_vx,
            ball_posx:ゲームエリアコンテナー.Width - ボール丸.Width
            })
    )
);
If(
    ball_posy < 0,
    UpdateContext({
        ball_vy: -ball_vy,
        ball_posy:0
        }),
    If(
        ball_posy + ボール丸.Height >= ゲームエリアコンテナー.Height,
        UpdateContext({
            ball_vy: -ball_vy,
            ball_posy:ゲームエリアコンテナー.Height - ボール丸.Height
            })
    )
);
//ブロック判定
If(
    ball_posy < 200,
    //上下判定
    If(
        //上向きに移動かつ下の端に接触
        ball_vy < 0,
        ClearCollect(
            Y_Hit_Blocks,
            Filter(
                Blocks,
                ThisRecord.life > 0,
                ThisRecord.y+ Block_height < ball_posy + ボール丸.Height,
                ThisRecord.y + Block_height > ball_posy,
                ThisRecord.x + Block_width > ball_posx,
                ThisRecord.x < ball_posx + ボール丸.Width
                
            )
        ),
        //下向きに移動かつ上の端に接触
        ClearCollect(
            Y_Hit_Blocks,
            Filter(
                Blocks,
                ThisRecord.life > 0,
                ThisRecord.y < ball_posy + ボール丸.Height,
                ThisRecord.y > ball_posy,
                ThisRecord.x + Block_width > ball_posx,
                ThisRecord.x < ball_posx + ボール丸.Width                
            )
        )
    );
    //左右判定
    If(ball_vx >0,
        //右向きに移動かつ左端に接触
        ClearCollect(
            X_Hit_Blocks,
            Filter(
                Blocks,
                ThisRecord.x <ball_posx+ボール丸.Width,
                ThisRecord.x > ball_posx,
                ThisRecord.y < ball_posy+ ボール丸.Height,
                ThisRecord.y + Block_height > ball_posy,
                ThisRecord.life > 0
            )
        ),
        //左向きに移動かつ右端に接触
        ClearCollect(
            X_Hit_Blocks,
            Filter(
                Blocks,
                ThisRecord.x +Block_width <ball_posx+ボール丸.Width,
                ThisRecord.x + Block_width> ball_posx,
                ThisRecord.y < ball_posy+ ボール丸.Height,
                ThisRecord.y + Block_height > ball_posy,
                ThisRecord.life > 0
            )
        )
    ); 
    //角にあたった場合の処理
    Clear(XY_Hit_Blocks);
    //角にあたったブロックを抽出
    ForAll(X_Hit_Blocks As X_block,
        ForAll(Filter(Y_Hit_Blocks,ThisRecord.num = X_block.num) As XY_Block,
            Collect(XY_Hit_Blocks,XY_Block)
        )
    );
    If(
        CountRows(XY_Hit_Blocks)>0,
        //右上向き
        If(ball_vx>0 && ball_vy<0,
            ForAll(XY_Hit_Blocks,
                If((ball_posx+ボール丸.Width-ThisRecord.x )-(ThisRecord.y+Block_height -ball_posy)>=0,
                    Remove(X_Hit_Blocks,ThisRecord),
                    If((ball_posx+ボール丸.Width-ThisRecord.x )-(ThisRecord.y+Block_height -ball_posy)<0,
                        Remove(Y_Hit_Blocks,ThisRecord)
                    )
                )                
            ),
            //左上向き
            If(ball_vx<0 && ball_vy<0,
                ForAll(XY_Hit_Blocks,
                    If((ThisRecord.x +Block_width - ball_posx)-(ThisRecord.y+Block_height -ball_posy)>=0,
                        Remove(X_Hit_Blocks,ThisRecord),
                        If((ThisRecord.x +Block_width - ball_posx)-(ThisRecord.y+Block_height -ball_posy)<0,
                            Remove(Y_Hit_Blocks,ThisRecord)
                        )
                    )                
                ),
                //左下向き
                If(ball_vx<0 && ball_vy>0,
                    ForAll(XY_Hit_Blocks,
                        If((ThisRecord.x +Block_width - ball_posx)-(ball_posy+ボール丸.Height -ThisRecord.y)>=0,
                            Remove(X_Hit_Blocks,ThisRecord),
                            If((ThisRecord.x +Block_width - ball_posx)-(ball_posy+ボール丸.Height -ThisRecord.y)<0,
                                Remove(Y_Hit_Blocks,ThisRecord)
                            )
                        )                
                    ),
                    //右下向き
                    If(ball_vx>0 && ball_vy>0,
                        ForAll(XY_Hit_Blocks,
                            If((ball_posx+ボール丸.Width-ThisRecord.x)-(ball_posy+ボール丸.Height -ThisRecord.y)>=0,
                                Remove(X_Hit_Blocks,ThisRecord),
                                If((ball_posx+ボール丸.Width-ThisRecord.x)-(ball_posy+ボール丸.Height -ThisRecord.y)<0,
                                    Remove(Y_Hit_Blocks,ThisRecord)
                                )
                            )                
                        )
                    )
                )
            )
        )
    );
    //衝突結果の反映
    If(
        CountRows(Y_Hit_Blocks) > 0,
        ForAll(Y_Hit_Blocks,
            Patch(
                Blocks,
                ThisRecord,
                {life: ThisRecord.life - 1}
            );
        );
        UpdateContext(
            {
                ball_vy: -ball_vy,
                score:score+(100* CountRows(Y_Hit_Blocks))
            }
        );
    );
    If(
        CountRows(X_Hit_Blocks) > 0,
        ForAll(X_Hit_Blocks,
            Patch(
                Blocks,
                ThisRecord,
                {life: ThisRecord.life - 1}
            );
        );
        UpdateContext(
            {
                ball_vx: -ball_vx,
                score:score+(150* CountRows(X_Hit_Blocks))
            }
        );
    )    
);