UnityでMatch-3を作成する

画像






数年前に青春コンで、マッチ3ゲームを再発見しました。 Dr. マリオの子供時代ですが、 マジカルドロップバストアムーブときめきメモリアル大戦パズルダマのようなより競争力のあるゲームは彼女とは非常に異なっています。



画像

博士 マリオ



その結果、マッチ3ゲームの作成に関連するニュートラルな決定がいくつあるかがわかりました。



次のLudum Dareジャムを試すことにしましたが、最初に、1週間前に、ウォームアップするためにラインを検出して削除するTetrisアルゴリズムを開発しようとしました。 このUnity Plusチュートリアルは本当に助けになりました。 [注 ごと:リンクが開きません。 問題を解決する方法を知っているなら、私に書いてください、私は記事を補足します。



これらのコード例をコンテキストで学習したい場合は、 Ludum Dare 30リポジトリをご覧ください。 (恥知らずな自己宣伝のために、私は再びShifty Shapesにこのロジックを使用しました。)



2つの世界





Magical Drop 3(ソース: Kazuya_UK



Unityでパズルゲームを作成する最も難しい部分は、ゲームが世界の空間に住んでいないことです。 いずれにしても、それは完全には生きません。



これは他のジャンルとの違いです。 たとえば、プラットフォーマーは、ほぼ完全にUnityゲームの世界に住んでいます。 プレイヤーの変形は自分の位置を報告しています。 コライダー(または、場合によってはレイキャスト)は、プレイヤーが地面にいるか、天井にぶつかっているか、敵と衝突しているかどうかを示します。 ゲーム内の物理を使用しなくても、ほとんどの場合、強度を追加するか、リジッドボディの速度を指定して、無料で衝突を認識します。



パズルゲームは完全に異なっています。 ゲーム内でマウスをクリックする必要がある場合、おそらく世界の空間でいくつかの座標を取得しますが、それらは通常、コード内に完全に存在するグリッドセルに変換されます。 これには明確な理由があります。テトリスやドクターのようなゲームのロジックを作成する方がはるかに簡単です。 マリオ、個々のピクセルではなく、ブロックまたはタイルで作業する場合。





テトリスブロックは、ガラスの壁に絶対にくっついてはいけません



実際、私の実験では、可能な限り世界の空間を厳守しようとしました。 物理学を使用してタイルの「着陸」を決定し、文字列の塗りつぶしを決定するためだけにデータを2次元配列に渡しました。 安全だと思われた:結局のところ、ゲームの世界で起こっていることは本物です。 これはプレーヤーに表示されるものです。そのため、ここにデータを保存しておけば、非同期のリスクはありませんよね?



私は間違っていました。 どのようにシステムを構成しようとしても、正しく動作しませんでした。



上記で提供したリンクであるUnity Plusチュートリアルは、私を大いに助けてくれました。 少なくとも、彼は正しいアプローチはゲームワールドから抽象データ構造にロジックを完全に転送することであることを示しました。 まだ行っていない場合は、少なくとも簡単に確認してください。この記事では、Tetrisロジックをmatch-3ロジックに拡張します。



フィールドから世界の空間への変換



この移行が便利であることに気づくとすぐに、残りは簡単でした。 タイルの色、行、列を追跡するGameTileクラスを作成し、それに基づいてタイルの位置を更新しました。 以下はその短縮版です。



public class GameTile : MonoBehaviour { private Transform _t; private SpriteRenderer _s; [System.NonSerialized] public int TileColor; [System.NonSerialized] public int Row; [System.NonSerialized] public int Column; void Awake () { _t = GetComponent<Transform>(); _s = GetComponent<SpriteRenderer>(); } Vector3 tmpPos; public void UpdatePosition() { tmpPos = _t.position; tmpPos.x = (Column * Board.TileSize) - Board.WorldOffset; tmpPos.y = (Row * Board.TileSize) - Board.WorldOffset; _t.position = tmpPos; _s.sprite = Board.Current.Textures[TileColor]; }
      
      







グリッドタイル



この場合、TileSizeは、Unity単位でタイルのサイズを定義する定数であることに注意してください。 64×64ピクセルのタイルを使用し、Unityのスプライトの解像度はユニットあたり100ピクセルであるため、TileSizeは0.64になります。 また、7×7フィールドの中央がワールド空間の座標0,0になり、左下隅がゲーム空間の0、0タイルになるように、一定のオフセットを使用します。



ボードクラスの静的フィールドとしてプレイフィールドを定義する配列も作成しました。 (Boardは最初は静的クラスでしたが、エディターで値を変更する必要があるためシングルトンになりました。そのため、ゲームオブジェクトと静的クラスの機能を不自然に組み合わせています。)



  public const float TileSize = 0.64f; public const float WorldOffset = 1.92f; public const int BoardSize = 7; public static GameTile[,] Tiles = new GameTile[BoardSize, BoardSize];
      
      





Unity Plusチュートリアルでは、整数を格納するために2次元配列が使用されましたが、この配列にGameTileオブジェクトへのリンクを格納することにしました。 これにより、タイルとの間でデータを直接転送できるようになりました(後で説明します)。これにより、タイルの削除とアニメーションの作成が簡単になりました。



競技場の状態を変更するときは、競技場の配列全体を循環させて、各タイルに位置を指定する必要がありました。



  public static void UpdateIndexes(bool updatePositions) { for (int y = 0; y < BoardSize; y++) { for (int x = 0; x < BoardSize; x++) { if (Tiles[x,y] != null) { Tiles[x, y].Row = y; Tiles[x, y].Column = x; if (updatePositions) Tiles[x, y].UpdatePosition(); } } } }
      
      





いずれの場合も、抽象的なゲーム空間からワールド空間に変換していることに注意してください。 Unityゲームオブジェクト自体には、ゲームの状態に関する重要な情報は保存されません。常に、この状態の反映にすぎません。



...そして戻る



私のゲームでは、世界からゲーム空間への変換を実行する必要がある唯一のケースがありました。つまり、プレイヤーが空きスペースをクリックしてフィールドにタイルを投げたときです。 このタスクでは、競技場全体に大きなコライダーを作成し、次のスクリプトを添付しました。



  void OnMouseDown() { if (GameState.Mode == GameState.GameMode.Playing) { mouseClick = Camera.main.ScreenToWorldPoint(Input.mousePosition); mouseX = (int)Mathf.Round ((mouseClick.x + WorldOffset) / TileSize); mouseY = (int)Mathf.Round ((mouseClick.y + WorldOffset) / TileSize); PutNextTile(mouseX, mouseY); Soundboard.PlayDrop(); GameState.ActionsTaken++; } }
      
      





実際、それがすべてです。 基本的に、ゲーム空間がワールド空間に変換されるUpdatePosition()の逆を実行することに注意してください。



一致するタイルの認識と削除





一致するタイルを削除する



これは最も難しい部分です。 おそらくこれのために、あなたは記事を読みます。



(テトリスのように)水平一致は実装が非常に簡単です。1行で隣接するタイルを探すだけです。 (マリオ博士のように)水平または垂直の一致を追加することも、このテーマの単なるバリエーションです。 ただし、水平方向垂直方向の両方で一連の隣接タイルを追跡するには、再帰が必要です。



競技場を変更するすべてのアクションで、チェックを実行します。 最初に行うことは、フィールド配列全体を別の配列にコピーすることです。



  static void CopyBoard(GameTile[,] source, GameTile[,] destination) { for (int y = 0; y < BoardSize; y++) { for (int x = 0; x < BoardSize; x++) { destination[x, y] = source[x, y]; } } } static bool clearedTiles = false; public static void MatchAndClear(GameTile[,] board) { clearedTiles = false; //      CopyBoard(board, toTest); //  ...
      
      





なんで? 後で確認したタイルを判別する方がはるかに簡単になることわかります。



プロセスをブルートフォースで開始します。 セルからセル(最初の行、次に列)に行き、各セルをチェックしましょう。 チェックごとに、チェックの追跡に使用されるいくつかの変数をリセットしてから、別の関数を呼び出します(この関数は後で再帰に使用されます)。



 //  MatchAndClear()... currentTile = null; collector.Clear (); for (int y = 0; y < BoardSize; y++) { for (int x = 0; x < BoardSize; x++) { TestTile (x, y); //  ...
      
      





このTestTile関数を見てみましょう。



  static void TestTile(int x, int y) { //   ,  if (toTest[x,y] == null) { return; } //    if (currentTile == null) { currentTile = toTest[x, y]; toTest[x, y] = null; collector.Add(currentTile); } // **   -      ** //     ,       if (x > 0) TestTile(x - 1, y); if (y > 0) TestTile(x, y - 1); if (x < Board.BoardSize - 1) TestTile(x + 1, y); if (y < Board.BoardSize - 1) TestTile(x, y + 1); }
      
      





関数がセルがヌルであることを検出した場合、それをスキップします。 nullのセルは、空であるか、既にテスト済みであることを意味します。 (そのため、別の配列にコピーしました。新しい配列を任意に操作する方が簡単です。)



セルが重要な場合は、次を実行します。 まず、「現在の」セル、つまり再帰チェーンの最上位にあるセルとして覚えています。 次に、再確認しないように、競技場のコピーからそれを削除します。 また、リストに追加して、見つかった同じ色の隣接するタイルの数を記憶します。



再帰の後半で発生する可能性のある状態は2つありますが、それらについては後で説明します。 セルをチェックした後、その周囲の4つのセルを取得し、それらに対して同じチェックを実行します。



「現在の」セルはすでに設定されています。これは、再帰の最初のレベルではないことを意味します。 これらの関数呼び出しでは、各セルに3つのオプションがあります。



まず、セルがnullである可能性があります。これは、これも既に確認済みであるか、空であることを意味します。 この場合、再び何もしません。



第二に、セルは「現在の」セルと一致しない場合があります。 この場合、「検証済み」とは見なされません。 再帰は、同じ色の隣接するタイルのセットをチェックします。 このタイルが現在のセットの一部ではないからといって、他の一部ではないという意味ではありません。



 //  TestTile() ... //   ,  else if (currentTile.TileColor != toTest[x, y].TileColor) { return; }
      
      





第三に、セルは「現在の」セルと同じ色にすることができます。 この場合、「検証済み」なので、プレイフィールドのコピーでnullに設定します。 また、ドライブとして使用するリストに追加します。 これは、上記の例で見逃した状態の1つです。



 //  TestTile() ... //   else { collector.Add(toTest[x, y]); toTest[x, y] = null; }
      
      





関数は、すべてのオプションが終了し、空のセルまたはフィールドの最後に到達するまで再帰を実行し続けます。 この時点で、結果を処理するためにメインのブルートフォースサイクルに戻ります。



ドライブに3つ以上のタイルがある場合、一致するものが見つかりました。 そうでない場合は、1つまたは2つのタイルをチェックしましたが、アクションを実行する必要はありません。



 //   MatchAndClear() ... if (collector.Count >= 3) { foreach (GameTile tile in collector) { ClearTile(tile.Column, tile.Row); clearedTiles = true; Soundboard.PlayClear(); } } currentTile = null; collector.Clear (); } } if (clearedTiles) { SettleBlocks(board) } }
      
      





ここでは、後で説明するように、アニメーションをオンにします。 ただし、最も単純なアプローチは、ドライブをループして、一致する各タイルのゲームオブジェクトでDestroyObjectを呼び出すことです。 したがって、1つの石で2羽の鳥を殺します。ゲーム内のオブジェクトを取り除き、競技場の状態のセルをnullに設定します。



タイルドロップ





落下タイル



特定の変更-たとえば、この場合のタイルのドロップやタイルの削除-は、タイルをサポートなしのままにし、このケースを解決する必要があります(もちろん、ゲームのルールで必要な場合)。 実際、これは非常に単純なアルゴリズムです。



ここで、列ごとに調べてから、行ごとに調べます。 ここでは順序が重要です。



各列で、空のセルが見つかるまで下から上に移動します。 それからマークします。 次に見つかったタイルをこの位置に移動し、空のセルインデックスに追加します。



  static int? firstEmpty; public static void SettleBlocks(GameTile[,] board) { for (int x = 0; x < BoardSize; x++) { firstEmpty = null; for (int y = 0; y < BoardSize; y++) { if (board[x, y] == null && !firstEmpty.HasValue) { firstEmpty = y; } else if (firstEmpty.HasValue && board[x, y] != null) { board[x, firstEmpty.Value] = board[x, y]; board[x, y] = null; firstEmpty++; } } } UpdateIndexes(false); }
      
      





完了後、一致関数を再度呼び出すことを忘れないでください。 落下するタイルが空のラインを作成した可能性が非常に高いです。



実際、アカウントがゲーム内に保持されている場合、コンボの追跡や乗数のスコアリングが簡単になります。 クラッシュとブロックの削除のこれらの繰り返しはすべて、プレーヤーのアクションによって開始された最初の呼び出しの再帰です。 プレーヤーのアクション後に発生した合計マッチの数と、各アクションに必要な「チェーン」のレベル数を理解できます。



アニメーション



ゲームはすでに機能していますが、これまでのところ、主にアニメーションがないため、直感的ではありません。 タイルが消えて、一番下の行に表示されます。 注意深く従わないと何が起こるかを理解するのは困難です。



これも難しい瞬間です。 ゲームオブジェクトは常にゲームの状態を反映しているため、タイルは常にグリッドに配置されています。 タイルは常に1か所を占有します。タイルは1行目または2行目に配置できますが、1.5行目に配置することはできません。



難しさは何ですか? 運動場アニメーションを同時に操作することはできません。 テトリスや博士を覚えています マリオ-次のタイルは、フィールド上のすべてのタイルが「落ち着く」まで落下しません。 これにより、プレーヤーは短い休息をとることができ、また、予期しない条件や相互作用がないことを保証します。



ところで、新しいプロジェクトの開始時には、「ゲームの状態」の列挙を作成することをお勧めします。 ゲームの状態(ゲームプロセス自体、一時停止、メニュー表示、ダイアログボックスなど)を知る必要のないゲームを書く必要はありませんでした。 開発の初期段階で状態を計画することをお勧めします。これにより、記述するコードの各行を、現在の状態で実行する必要があるかどうかを確認できます。



私の実装は厄介なものであると認めていますが、一般的な考え方は次のとおりです。タイルが削除またはドロップされると、状態の変更を使用します。 各GameTileオブジェクトは、この状態の変化を処理する方法を知っており、さらに重要なこととして、ゲームフィールドにアニメーションが完了したことをいつ伝えるかを知っています。



  void Update () { if (GameState.Mode == GameState.GameMode.Falling && Row != LastRow) { targetY = (Row * Board.TileSize) - Board.WorldOffset; tmpPos = _t.position; tmpPos.y -= FallSpeed * Time.deltaTime; if (tmpPos.y <= targetY) { Board.fallingBlocks.Remove(this); UpdatePosition(); Soundboard.PlayDrop(); } } }
      
      





除去アニメーションが完了すると、ゲームは落下するタイルを確認する必要があります。



  private static float timer; private const float DisappearTimer = 0.667f; void Update() { if (GameState.Mode == GameState.GameMode.Disappearing) { timer -= Time.deltaTime; if (timer <= 0) { GameState.Mode = GameState.GameMode.Playing; SettleBlocks(Tiles); } }
      
      





秋のアニメーションが完了したら、一致を確認します。



  if (GameState.Mode == GameState.GameMode.Falling && fallingBlocks.Count == 0) { GameState.Mode = GameState.GameMode.Playing; MatchAndClear(Tiles); } }
      
      





このサイクルは、さらにマッチが残るまで繰り返され、その後、ゲームは仕事に戻ることができます。



All Articles