0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Power Apps】いつでもどこでも耕したい!開墾ミニゲーム開発

Posted at

概要

太閤立志伝Ⅴの開墾師事のミニゲームを Power Apps で再現しました。自分への備忘録も兼ねて、ロジックの解説や気づきをまとめました。

以前、同ゲームの礼法師事のミニゲームを Power Apps で再現し、開墾ミニゲームにも挑戦してみた次第です。

画面

image.png

中央のボードの空いているマスをクリックすると、画面左下に赤枠表示されている水路マスがそこに配置されます。ボード左の取水口(?)から出てくる水がボード全体にいきわたるように、マスの水路をつなげていくことが目的です。

プレイ動画

主な仕様

  • ボードの縦横サイズは、可変(4~8)
  • 取水口の位置は 3行目で固定
  • スタート時、いくつかのマスをランダムに配置しておく
  • 左下のスコップボタンを押すと撤去モードに移行。ボード内のマスを取り除き、任意の場所に再配置できる(初心者救済の独自機能)
  • すべてのマスを配置したあと、水路の通水判定を行う

ロジック

初期設定(スタートボタン)

ボード作成

まずはコレクションでボードを作成します。ドロップダウンで選択された縦・横のサイズを掛け算し、その回数だけ ForAll を回して Collect でマスを追加していきます。

各マスには、配置済みか(tile)、上下左右の各方向に水路が開いているか(?_path)、通水状態か(water)を示す列を作っています。また、ForAll の中で Value を使うと、何回目のループ実行かを表す整数が返され、それを id 列として利用しています。

Clear(colBoard);
ForAll(Sequence(locRow * locCol),
    Collect(colBoard,
        {id:Value, tile:0, t_path:0, b_path:0, l_path:0, r_path:0, water:0}
    )
);

手札作成

続いて、プレイヤーが配置することになるマスの集まり=手札をコレクションで作成します。

マスは必ず2方向以上に水路が開いているので、4方向開き1種類(十字)、3方向開き4種類(ト・T系)、2方向開き6種類(L・|系)の、計11種類あります。これら11種類が最低1つずつ含まれるように、まずは力ワザで作成します。

ClearCollect(colStockTemp,
    {t_path:1, b_path:1, l_path:1, r_path:1},
    {t_path:0, b_path:1, l_path:1, r_path:1},
    {t_path:1, b_path:0, l_path:1, r_path:1},
    {t_path:1, b_path:1, l_path:0, r_path:1},
    {t_path:1, b_path:1, l_path:1, r_path:0},
    {t_path:1, b_path:1, l_path:0, r_path:0},
    {t_path:1, b_path:0, l_path:1, r_path:0},
    {t_path:1, b_path:0, l_path:0, r_path:1},
    {t_path:0, b_path:1, l_path:1, r_path:0},
    {t_path:0, b_path:1, l_path:0, r_path:1},
    {t_path:0, b_path:0, l_path:1, r_path:1}
);

ボードのマス総数に満たない分については、11種類のうちからランダムで追加していきます。RandBetween(1,11) でランダムな数値を得て、Switch で分岐させて対応するマスを作成します。先ほどの力ワザで作ったモノを流用。

ForAll(Sequence(CountRows(colBoard) - 11),
    Collect(colStockTemp,
        Switch(RandBetween(1,11),
            1, {t_path:1, b_path:1, l_path:1, r_path:1},
            2, {t_path:0, b_path:1, l_path:1, r_path:1},
            3, {t_path:1, b_path:0, l_path:1, r_path:1},
            4, {t_path:1, b_path:1, l_path:0, r_path:1},
            5, {t_path:1, b_path:1, l_path:1, r_path:0},
            6, {t_path:1, b_path:1, l_path:0, r_path:0},
            7, {t_path:1, b_path:0, l_path:1, r_path:0},
            8, {t_path:1, b_path:0, l_path:0, r_path:1},
            9, {t_path:0, b_path:1, l_path:1, r_path:0},
            10,{t_path:0, b_path:1, l_path:0, r_path:1},
            11,{t_path:0, b_path:0, l_path:1, r_path:1}
        )
    )
);

上記で作成した手札になるマスのコレクション(colStockTemp)を Shuffle して、ゲームで使用する手札(colStock)とします。

ClearCollect(colStock,Shuffle(colStockTemp));

これでプレイヤーが配置すべき手札は作成できましたが、さらに3マス分、ダミーのマスを手札の末尾に追加します。この処理の必要性については、「次のマス」のところで解説します。

Collect(colStock,
    {t_path:0, b_path:0, l_path:0, r_path:0},
    {t_path:0, b_path:0, l_path:0, r_path:0},
    {t_path:0, b_path:0, l_path:0, r_path:0}
);

ランダムに初期配置

初期設定の仕上げとして、いくつかのマスをボード上にランダムで配置します。初期配置の細かい仕様は以下のとおり。

  • ボードの四辺には置かない(どこにも接続されないマスの発生や、取水口の右のマスが塞がれるのを抑制するため)
  • ボードのマス総数から四辺を除いたマスの、3分の1(切り上げ)の数だけ配置する
    • 4×4の場合、(16 - 12) / 3 = 1.33... →2マス配置
    • 8×8の場合、(64 - 28) / 3 = 12 →12マス配置

ボードのコレクションを Shuffle して、初期配置処理用のコレクションを作成します。

ClearCollect(colBoardTemp, Shuffle(colBoard));

そこから、四辺にあたるマスを RemoveIf で除外します。四辺の判定は下記の関数をご確認ください。

RemoveIf(colBoardTemp,
    id <= locCol || //上辺
    id > locCol * (locRow - 1) || //下辺
    Mod(id, locCol) = 1 || //左辺
    Mod(id, locCol) = 0 //右辺
);

残ったマスの3分の1(切り上げ)の回数、手札のマスをボードに配置していきます。手札は First で上から順番に使い、使ったら Remove。配置する場所は、初期配置処理用のコレクションの id を参照しています。

ForAll(Sequence(RoundUp(CountRows(colBoardTemp) / 3, 0)),
    With({st: First(colStock)},
        UpdateIf(colBoard,
            id = Index(colBoardTemp, Value).id,
            {tile:1, t_path:st.t_path, b_path:st.b_path, l_path:st.l_path, r_path:st.r_path}
        );
        Remove(colStock, st)
    )    
)

処理のロジックとしてはガバガバで、初期配置の時点でどこにも接続できないマスが発生する可能性があります。下記の画像では、初期配置の時点で3行目が3マスも塞がってますね…

image.png

この問題は、後述の「撤去モード」の採用で対処しています。2か所以上が塞がってたら、諦めてください、です。

※id が奇数のところにだけ初期配置する、とかでもかなり回避できる?

各マスの画像作成

各マスの画像は、四角形を組み合わせて生成しています。PNGなどで画像を用意して表示するより、準備も表示もこの方が早いはず。

1マスを9分割して考え、中央とその上下左右にまたがる四角形を4つ作成します。上方向にまたがる四角形だとこんな感じ。

image.png

この四角形の Fill プロパティを、マスの状態によって変化させます。

If(ThisItem.t_path && ThisItem.water,
    locColorWater,
    ThisItem.t_path && !ThisItem.water,
    locColorDitch,
    RGBA(0,0,0,0)
)

上方向に水路が開いている (t_path = 1) ことを前提に、通水状態 (water = 1) のときは水が通っている色 (locColorWater) に、通水していない (water = 0) ときは溝の色 (locColorDitch) にします。

上方向に水路が開いていない (t_path = 0) 場合は、RGBA(0,0,0,0) で透明にしています。

こんな感じで上下左右にまたがる4つの四角形を重ねて、十字の水路を表現しています。

撤去モード

左下のスコップボタンを押すと、配置済みのマスを1枚だけ手札に戻せる「撤去モード」に入ることができます。スコップボタン自体の OnSelect 処理は単純で、撤去モードに入っているかのフラグをオン/オフするだけ。このフラグの状態によって、ボード内のマスをクリックしたときの処理を分岐させます。

// OnSelect:
UpdateContext({locScopUsing: !locScopUsing});

撤去モード利用中かどうかによって、ボタンの見た目を切り替えています。左が初期状態、右が撤去モード利用中の状態です。

image.png

// Fill:
If(locScopUsing,
    RGBA(250, 190, 190, 1),
    RGBA(202, 202, 202, 1)
)
// BorderColor:
If(locScopUsing,
    RGBA(255, 0, 0, 1),
    RGBA(100, 100, 100, 1)
)

撤去は1回しか利用できないので、利用済みの場合は Visible: false で非表示にしています。

次のマス表示

手札コレクションの上から3番目・2番目・1番目を表示しています。画像表示のロジックは、ボード内のマスとほぼ同じ(通水状態の判定が不要)です。

例えば、手札コレクションの上から3番目(次の次の次に配置するマス)の上方向への接続部の Fill プロパティは下記のとおり。

image.png

// Fill:
With({n: Index(colStock, 3)},
    If(n.t_path, locColorDitch, RGBA(0,0,0,0))
)

※試行錯誤の結果、あまり意味のない With が残ってます…

初期設定でダミーのマスを3枚手札に追加していたのは、「次のマス」表示のためと説明しました。これは、ダミーのマスを追加していない場合、手札が少なくなったときに不具合が起きるためです。

例えば、手札を消費していって残り1枚になると、手札コレクションの上から3番目・2番目が存在しなくなり、Index 参照エラーが発生します。

image.png

そのため、初期設定でダミーマスを3つ追加し、次のマス欄を埋めるようにしている、というわけです。

image.png

なお、撤去モードとの兼ね合いで、「次のマス」のみ2つの処理を追加しています。

  • 囲みの赤線は、撤去モード利用中は灰色に
// BorderColor:
If(!locScopUsing,
    RGBA(255, 0, 0, 1),
    RGBA(100, 100, 100, 1)
)
  • クリック時、撤去モード利用を解除する
// OnSelect:
UpdateContext({locScopUsing: false});

image.png

赤枠=ボードをクリックしたときのアクション、というエクスペリエンスで統一できたかなと。

ボードクリック時

ボード内をクリックしたときの処理は3種類に分岐します。

  • マス設置なし&撤去モードではない:手札からマスを設置
  • マス設置済み&撤去モードである:設置されたマスを撤去
  • それ以外:なにもしない

マス設置

選択されたマスに、手札のコレクションの先頭レコードの情報を書き込みます。また、手札のコレクションの先頭レコードは、使用済みとして Remove で削除します。

If(!ThisItem.tile && !locScopUsing,
    With({st: First(colStock)},
        UpdateIf(colBoard,
            id = galBoard.Selected.id,
            {tile:1, t_path:st.t_path, b_path:st.b_path, l_path:st.l_path, r_path:st.r_path}
        );
        Remove(colStock, st)
    ),
...

マス撤去

...
    ThisItem.tile && locScopUsing,
    With({sel: galBoard.Selected},
        ClearCollect(colStockTemp, colStock);
        ClearCollect(colStock,
            {t_path:sel.t_path, b_path:sel.b_path, l_path:sel.l_path, r_path:sel.r_path},
            colStockTemp
        );
        UpdateIf(colBoard,
            id = sel.id,
            {tile:0, t_path:0, b_path:0, l_path:0, r_path:0}
        );
        UpdateContext({locScopUsing: false, locScopEnable: false});
    );
);

マス撤去時の流れは、以下の通りです。

  1. 手札コレクション(colStock)を複製した colStockTemp を作成
  2. 手札コレクション(colStock)を再作成し、選択されたマスの情報と colStockTemp を追加
  3. 選択されたマスを、未設置状態に変更
  4. 撤去モード利用済みフラグを、ON に変更

撤去されるマスの情報を手札コレクションの先頭に挿入したいのですが、Collect だと末尾への追加しかできません。そのため、別のコレクションを複製したあと、「先頭に挿入したいレコード+複製」の形で手札コレクションを再作成しています。

Bing や Copilot の式生成には以下の方法でできると言われたんですが、"新しいデータ"が 2 行だけ入ったコレクションが作られてしまっていました。現在のコレクションをいったん別のコレクションに逃がしておく必要があると考え、上記の方法を採りました。

// BingCopilot が生成した回答
ClearCollect(
    MyCollection, 
    { NewData: "新しいデータ" }, // 先頭に追加したいデータ
    MyCollection // 既存のコレクションを後ろに追加
)

すべてのマスが埋まったか判定

上記の処理後、ボードのすべてのマスが埋まっている場合、判定処理に進みます。手札にダミーマスを3つ追加しているので、手札が3枚になったら、ボードのすべてのマスが埋まっていると判断します。

If(CountRows(colStock) <= 3,
    Select(_btnJudgeLoop)
)

※ボードのコレクションの tile 列がすべて 1 になっていることを判定した方が確実ですね

通水判定

ボードクリック時に、手札が3枚 (追加したダミーマス) 以下だったら、非表示になっている判定ボタンを押して処理を開始します。

まず、取水口の右にあるマスの左辺が 1 かどうかを判定。このマスだけは、ボードの中からではなく外から水がやってくるため、例外として処理しています。

image.png

取水口の位置は 3 行目で固定しているので、列数の 2 倍に 1 を足せば、3 行目の最初のマスを指定できます。

UpdateIf(colBoard,
    id = locCol * 2 + 1 && l_path,
    {water: 1}
);

その後、ボード全体の通水しているマスの数を変数に格納しておきます。処理前の状態の記録です。

UpdateContext({locWaterCount: CountIf(colBoard, water)});

ForAll を使った、ボードの各マスに対して、通水状態に変化させるかどうかを判定するループに入ります。例えば、あるマスの左側から水が流れてくるかどうかは、以下の 4 つをすべて満たすかで判定しています。

Mod(id, locCol) <> 1 &&                  //ボードの左辺ではない
l_path &&                                //そのマスの左側の水路が開いている
LookUp(colBoard,id = b.id - 1).water &&  //左のマスが通水状態である
LookUp(colBoard,id = b.id - 1).r_path    //左のマスの右側の水路が開いている

これを各マスの上下左右に対して判定し、どこか一辺からでも水が流れてくるのであれば、そのマスを通水状態に変更します。

ForAll(colBoard As b,
    UpdateIf(colBoard,
        id = b.id &&
        Or(
            // 左から通水。一番左の行は対象外
            Mod(id, locCol) <> 1 && l_path && LookUp(colBoard,id = b.id - 1).water && LookUp(colBoard,id = b.id - 1).r_path,
            // 右から通水。一番右の行は対象外
            Mod(id, locCol) <> 0 && r_path && LookUp(colBoard,id = b.id + 1).water && LookUp(colBoard,id = b.id + 1).l_path,
            // 上から通水。一番上の行は対象外
            id > locCol && t_path && LookUp(colBoard,id = b.id - locCol).water && LookUp(colBoard,id = b.id - locCol).b_path,
            // 下から通水。一番下の行は対象外
            id <= (locCol * (locRow - 1)) && b_path && LookUp(colBoard,id = b.id + locCol).water && LookUp(colBoard,id = b.id + locCol).t_path
        ),
        {water: 1}
    )
);

ボードの各マスに対するループが終わったら、ボード全体の通水しているマスを数え、ループ実行前の状態 (変数locWaterCount) と比較します。通水マスが増えていれば判定を再実行、増えていなければゲーム終了の処理に進みます。

If(locWaterCount < CountIf(colBoard, water),
    UpdateContext({locToggle:true}),
    UpdateContext({locEndMessage:true})    
);

判定継続の場合は、locToggle が true になります。これで非表示のトグルスイッチが ON になり、OnCheck の処理に従って判定が再実行されます。一応、判定回数もカウントしてます。

//トグルの Default
locToggle

//トグルの OnCheck
UpdateContext({locToggle: false, c: c+1});
Select(_btnJudgeLoop)

ボード全体(コレクション)に対する処理は、盤面でいうと左上から右下に進んでいくため、右・下方向への通水判定は一気に行われる一方、左・上方向への判定は次のループに持ち越されます。

例えば、以下の状態で判定が開始されると、

image.png

左から水が流れてくるかの判定で通水状態になる 9~12、上からの 16 は初回で一気に通水状態になりますが、下から水が流れてくるはずの 8 のマスは通水状態になりません。8 を処理する時点では、下にある 12 のマスが通水状態ではないため。

image.png

8 のマスは、12 が通水状態になったあとの判定で通水することになります。

image.png

そのあとは左・上方向への通水がジワジワと進んでいきます。

image.png

image.png

image.png

image.png

左・下方向にも一気に通水させないようにするには、ボードのコレクションを複製し、複製した方の通水状態を参照して元のコレクションを更新する方法が考えられます。ルービックキューブの移動ボタンでの「循環」の処理と同じような感じですね。

また、マス配置時に都度通水判定を行う形にしておけば、ジワジワでなく一気に通水させる形にもできそうです。

ゲーム終了

通水状態のマスが増えなくなったら、終了フラグ (locEndMessage) が true になり、終了メッセージが表示されます。終了メッセージは、ボードの全マスのうち通水しているマスの割合によって 5 種類に変化します。

// 終了メッセージ Text:
With(
    {
        all: locCol * locRow,
        water: CountIf(colBoard, water)
    },
    If(
        all = 0, "",
        water/all = 1, "おみごと!",
        water/all > 0.75, "すごい!",
        water/all > 0.5, "いいね!",
        water/all > 0.25, "まあまあ",
        "がんばろう"
    )
)

また、終了フラグを AutoStart に設定したタイマー (Duration: 1000) を設置。タイマーの値を使って終了メッセージのサイズと位置を変化させることで、1 秒かけてテキストがせり上がってくるような演出を入れてみました。長女のウケ狙いで。

// 終了メッセージ Size:
80 + tmrEndMessage.Value/12

// 終了メッセージ Y:
188 - tmrEndMessage.Value/25

気づき

  • マスのパターンの作成は、力ワザでやってます。11 種類程度なら可能とはいえ、効率よく生成する手段も知っておきたいと感じました。初期配置でどこにも通水しないマスを置いたり、1方向にだけ水路が開いている「行き止まり」のマスを追加するのもおもしろそう
  • 通水判定のところが難関だと思っていましたが、わりとスムーズに作れました。解説を書きながら、一気に通水・全方向に1マスずつ通水の 2 パターンの実装方法も考えられました。本来は設計段階で考えておくべきなのかもしれませんが、まず動くものを作ってみたことで、思考の幅が広がったように思います
  • 撤去モードの機能は、実装は難しくなかった上、プレイヤーの選択肢が増えておもしろくなったと自負しています。もともと「次のマス」を目立たせるために赤枠をつけていましたが、撤去モードを導入したことで、赤枠=ボードをクリックしたときにおきること、という意味づけになりました。他と異なる色が何を示すのか、意味づけを考えておく必要性を再認識しました
  • 通水判定のループには、無駄な処理が入っちゃっていました。取水口の右のマスの処理は、1回実施すればいいので、ループに含める必要はありません。本来はループの中ではなく、ボードクリック時の「すべてのマスが埋まったか判定」のところで実行するべきでした
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?