Unityでタワーディフェンスを作成する、パート1

フィールド





これは、単純なタワーディフェンスゲームの作成に関する一連のチュートリアルの最初の部分です。 このパートでは、競技場の作成、パスの検索、最終的なタイルと壁の配置を検討します。



チュートリアルはUnity 2018.3.0f2で作成されました。









タワーディフェンスのジャンルタイルゲームで使用できるフィールド。



タワーディフェンスゲーム



タワーディフェンスは、プレイヤーの目標が敵の群集を最終地点に到達するまで破壊することであるジャンルです。 プレイヤーは敵を攻撃する塔を建設することで目標を達成します。 このジャンルには多くのバリエーションがあります。 タイルフィールドを使用してゲームを作成します。 敵はフィールドを横切って終点に向かって移動し、プレイヤーは障害物を作成します。



オブジェクトの管理に関する一連のチュートリアルを既に学習したと仮定します。



フィールド



競技場はゲームの最も重要な部分であるため、最初に作成します。 これは、 GameBoard



という独自のコンポーネントを持つゲームオブジェクトになります。これは、2次元でサイズを設定することで初期化でき、 Vector2Int



の値を使用できます。 フィールドは任意のサイズで機能するはずですが、どこかでサイズを選択するため、このための共通のInitialize



メソッドを作成します。



さらに、地球を示す1つの四角形でフィールドを視覚化します。 フィールドオブジェクト自体を四辺形にするのではなく、子クワッドオブジェクトを追加します。 初期化時に、地球のXYスケールをフィールドのサイズに等しくします。 つまり、各タイルのサイズは、エンジンの1平方単位です。



 using UnityEngine; public class GameBoard : MonoBehaviour { [SerializeField] Transform ground = default; Vector2Int size; public void Initialize (Vector2Int size) { this.size = size; ground.localScale = new Vector3(size.x, size.y, 1f); } }
      
      





グラウンドをデフォルト値に明示的に設定するのはなぜですか?
アイデアは、Unityエディターでカスタマイズ可能なすべてのものに、シリアル化された非表示フィールドからアクセスできるということです。 これらのフィールドは、インスペクターでのみ変更できる必要があります。 残念ながら、Unityエディターは、値が割り当てられないというコンパイラーの警告を常に表示します。 フィールドのデフォルト値を明示的に設定することにより、この警告を抑制することができます。 null



割り当てることもできますが、デフォルト値を使用することを明示的に示すために作成しました。これは、グラウンドへの真の参照ではないため、 default



を使用しdefault





新しいシーンでフィールドオブジェクトを作成し、地球のように見えるマテリアルで子クワッドを追加します。 単純なプロトタイプゲームを作成しているため、均一なグリーンマテリアルで十分です。 X軸に沿って90度回転して、XZ平面上に配置します。





















競技場。



ゲームをXY平面に配置してみませんか?
ゲームは2D空間で行われますが、3Dの敵と特定のポイントに対して移動可能なカメラを使用して、3Dでレンダリングします。 XZプレーンはこれに便利で、アンビエント照明に使用される標準のスカイボックスの向きに対応しています。


ゲーム



次に、 Game



全体を担当するGame



コンポーネントを作成します。 この段階では、これはフィールドを初期化することを意味します。 インスペクターを使用してサイズをカスタマイズし、起動時にコンポーネントにフィールドを初期化させるだけです。 デフォルトのサイズである11×11を使用しましょう。



 using UnityEngine; public class Game : MonoBehaviour { [SerializeField] Vector2Int boardSize = new Vector2Int(11, 11); [SerializeField] GameBoard board = default; void Awake () { board.Initialize(boardSize); } }
      
      





フィールドサイズは正の値のみであり、単一のタイルでフィールドを作成することはほとんど意味がありません。 最小値を2×2に制限しましょう。 これを行うには、 OnValidate



メソッドを追加して、最小値を強制的に制限します。



  void OnValidate () { if (boardSize.x < 2) { boardSize.x = 2; } if (boardSize.y < 2) { boardSize.y = 2; } }
      
      





Onvalidateはいつ呼び出されますか?
存在する場合、Unityエディターは、コンポーネントを変更した後、それを呼び出します。 ゲームオブジェクトへの追加時、シーンのロード後、再コンパイル後、エディターでの変更後、キャンセル/再試行後、およびコンポーネントのリセット後を含みます。



OnValidate



は、コンポーネント構成フィールドに値を割り当てることができるコード内の唯一の場所です。








ゲームオブジェクト。



これで、ゲームモードを開始すると、正しいサイズのフィールドが取得されます。 ゲーム中、ボード全体が見えるようにカメラを配置し、その変換コンポーネントをコピーし、プレイモードを終了して、コンポーネントの値を貼り付けます。 原点に11×11フィールドがある場合、上から見やすくするために、カメラを位置(0.10.0)に配置し、X軸に沿って90°回転できます。カメラはこの固定位置のままにしますが、可能です。将来変更します。









フィールド上のカメラ。



コンポーネント値をコピーして貼り付ける方法は?
コンポーネントの右上隅にある歯車ボタンをクリックすると表示されるドロップダウンメニューから。


プレハブタイル



フィールドは正方形のタイルで構成されています。 敵はタイルからタイルへと移動できますが、エッジを横切ることはできますが、斜めにはできません。 移動は常に最も近いエンドポイントに向かって行われます。 矢印に沿ってタイルに沿った移動方向をグラフィカルに示しましょう。 こちらから矢印テクスチャをダウンロードできます。









黒の背景の矢印。



プロジェクトに矢印テクスチャを配置し、 Alpha As Transparencyオプションを有効にします。 次に、カットアウトモードが選択されているデフォルトのマテリアルにすることができる矢印のマテリアルを作成し、メインテクスチャとして矢印を選択します。









矢印素材。



カットアウトレンダリングモードを使用する理由
これにより、標準のUnityレンダリングパイプラインを使用するときに矢印を不明瞭にすることができます。


ゲーム内の各タイルを示すために、ゲームオブジェクトを使用します。 フィールドが地球のクワッドを持っているのと同じように、それらのそれぞれは、矢印マテリアルを持つ独自のクワッドを持っています。 また、矢印へのリンクを含むGameTileコンポーネントにタイルを追加します。



 using UnityEngine; public class GameTile : MonoBehaviour { [SerializeField] Transform arrow = default; }
      
      





タイルオブジェクトを作成し、プレハブに変換します。 タイルは地面と同じ高さになるので、矢印を少し上げて、レンダリング時の奥行きの問題を回避します。 また、隣接する矢印の間にスペースがほとんどないように、少しズームアウトします。 Yの0.001のシフトと0.8のスケールは、すべての軸で同じです。





















プレハブタイル。



プレハブタイル階層はどこにありますか?
プレハブアセットをダブルクリックするか、プレハブを選択してインスペクターの[プレハブ開く ]ボタンをクリックすると、プレハブ編集モードを開くことができます。 階層ヘッダーの左上隅にある矢印の付いたボタンをクリックすると、プレハブ編集モードを終了できます。


タイル自体はゲームオブジェクトである必要はありません。 フィールドの状態を追跡するためにのみ必要です。 オブジェクト管理シリーズのチュートリアルの動作と同じアプローチを使用できます。 しかし、単純なゲームやゲームオブジェクトのプロトタイプの初期段階では、非常に満足しています。 これは将来変更される可能性があります。



タイルがあります



タイルを作成するには、 GameBoard



にタイルプレハブへのリンクが必要です。



  [SerializeField] GameTile tilePrefab = default;
      
      











プレハブタイルへのリンク。



その後、2つのグリッド次元でダブルサイクルを使用してインスタンスを作成できます。 サイズはXとYで表されますが、XZ平面とフィールド自体にタイルを配置します。 フィールドは原点を基準に中央に配置されるため、対応するサイズから1を2で割った値をタイル位置のコンポーネントから減算する必要があります。 これは浮動小数点除算でなければならないことに注意してください。そうしないと、偶数サイズでは機能しません。



  public void Initialize (Vector2Int size) { this.size = size; ground.localScale = new Vector3(size.x, size.y, 1f); Vector2 offset = new Vector2( (size.x - 1) * 0.5f, (size.y - 1) * 0.5f ); for (int y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++) { GameTile tile = Instantiate(tilePrefab); tile.transform.SetParent(transform, false); tile.transform.localPosition = new Vector3( x - offset.x, 0f, y - offset.y ); } } }
      
      











タイルのインスタンスを作成しました。



後でこれらのタイルにアクセスする必要があるため、タイルを配列で追跡します。 初期化後、フィールドのサイズは変わらないため、リストは必要ありません。



  GameTile[] tiles; public void Initialize (Vector2Int size) { … tiles = new GameTile[size.x * size.y]; for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { GameTile tile = tiles[i] = Instantiate(tilePrefab); … } } }
      
      





この割り当てはどのように機能しますか?
これはリンクされた割り当てです。 この場合、これは、タイルインスタンスへのリンクを配列要素とローカル変数の両方に割り当てることを意味します。 これらの操作は、以下に示すコードと同じように実行されます。



 GameTile t = Instantiate(tilePrefab); tiles[i] = t; GameTile tile = t;
      
      





道を探す



この段階では、各タイルには矢印がありますが、それらはすべてZ軸の正の方向を指し、これを北と解釈します。 次のステップは、タイルの正しい方向を決定することです。 これを行うには、敵が終点に到達しなければならない経路を見つけます。



タイルの隣人



パスは、タイルからタイルへ、北、東、南、または西の方向に進みます。 検索を簡素化するために、 GameTile



4つの隣人へのリンクを追跡させます。



  GameTile north, east, south, west;
      
      





隣同士の関係は対称的です。 タイルが2番目のタイルの東隣である場合、2番目は最初のタイルの西隣です。 一般的な静的メソッドをGameTile



に追加して、2つのタイル間のこの関係を定義します。



  public static void MakeEastWestNeighbors (GameTile east, GameTile west) { west.east = east; east.west = west; }
      
      





静的メソッドを使用する理由
単一のパラメーターを持つインスタンスメソッドにすることができます。この場合、 eastTile.MakeEastWestNeighbors(westTile)



またはそのようなものとして呼び出します。 ただし、どのタイルでメソッドを呼び出す必要があるかが明確でない場合は、静的メソッドを使用することをお勧めします。 例は、 Vector3



クラスのDistance



およびDot



メソッドです。


接続すると、変更されることはありません。 これが発生した場合、コードを間違えました。 これを確認するには、値をnull



に割り当てる前に両方のリンクを比較し、正しくない場合はエラーを表示します。 Debug.Assert



Debug.Assert



メソッドを使用できます。



  public static void MakeEastWestNeighbors (GameTile east, GameTile west) { Debug.Assert( west.east == null && east.west == null, "Redefined neighbors!" ); west.east = east; east.west = west; }
      
      





Debug.Assertは何をしますか?
最初の引数がfalse



場合、2番目の引数が指定されている場合はそれを使用して、条件エラーを表示します。 このような呼び出しはテストビルドにのみ含まれ、リリースビルドには含まれません。 したがって、これは開発プロセス中に最終リリースに影響しないチェックを追加するのに適した方法です。


同様の方法を追加して、北と南の隣人の間の関係を作成します。



  public static void MakeNorthSouthNeighbors (GameTile north, GameTile south) { Debug.Assert( south.north == null && north.south == null, "Redefined neighbors!" ); south.north = north; north.south = south; }
      
      





GameBoard.Initialize



タイルを作成するときにこの関係を確立できます。 X座標がゼロより大きい場合、現在のタイルと前のタイルの間に東西の関係を作成できます。 Y座標がゼロより大きい場合、現在のタイルと前の行のタイルの間に南北関係を作成できます。



  for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … if (x > 0) { GameTile.MakeEastWestNeighbors(tile, tiles[i - 1]); } if (y > 0) { GameTile.MakeNorthSouthNeighbors(tile, tiles[i - size.x]); } } }
      
      





フィールドの端にあるタイルには4つの隣接がないことに注意してください。 1つまたは2つの隣接参照はnull



ままです。



距離と方向



すべての敵に絶えず道を探させることはありません。 これは、タイルごとに1回だけ実行する必要があります。 その後、敵は移動先のタイルからリクエストできるようになります。 パスの次のタイルへのリンクを追加して、この情報をGameTile



します。 さらに、エンドポイントまでの距離も保存します。これは、敵がエンドポイントに到達する前に訪問する必要があるタイルの数として表されます。 敵にとって、この情報は役に立たないが、最短経路を見つけるために使用する。



  GameTile north, east, south, west, nextOnPath; int distance;
      
      





パスを探す必要があると判断するたびに、パスデータを初期化する必要があります。 パスが見つかるまで、次のタイルはなく、距離は無限と見なすことができます。 これはint.MaxValue



可能な最大整数値として想像できます。 汎用ClearPath



メソッドを追加して、 GameTile



をこの状態にリセットします。



  public void ClearPath () { distance = int.MaxValue; nextOnPath = null; }
      
      





パスは、エンドポイントがある場合にのみ検索できます。 これは、タイルがエンドポイントになる必要があることを意味します。 このようなタイルの距離は0に等しく、パスはその上で終了するため、最後のタイルはありません。 タイルをエンドポイントに変換する汎用メソッドを追加します。



  public void BecomeDestination () { distance = 0; nextOnPath = null; }
      
      





最終的には、すべてのタイルがパスに変わるため、それらの距離はint.MaxValue



と等しくなりません。 タイルに現在パスがあるかどうかを確認する便利なゲッタープロパティを追加します。



  public bool HasPath => distance != int.MaxValue;
      
      





このプロパティはどのように機能しますか?
これは、1つの式のみを含むゲッタープロパティの短縮エントリです。 以下に示すコードと同じです。



  public bool HasPath { get { return distance != int.MaxValue; } }
      
      





矢印演算子=>



は、プロパティのゲッターとセッター、メソッドの本体、コンストラクター、およびその他の場所で個別に使用することもできます。


道を育てる



パスのあるタイルがある場合は、タイルをその隣のタイルに向かってパスを成長させることができます。 最初は、パスを持つ唯一のタイルは終点であるため、ゼロ距離から開始してここから増加させ、敵の動きとは反対方向に移動します。 つまり、エンドポイントのすぐ隣のすべての距離は1になり、これらのタイルのすべての隣の距離は2になります。



GameTile



隠しメソッドを追加して、パラメーターで指定された近隣の1 GameTile



パスを拡大します。 隣人までの距離は現在のタイルよりも1つ大きく、隣人のパスは現在のタイルを示します。 このメソッドは、すでにパスがあるタイルに対してのみ呼び出す必要があるため、アサートでこれを確認しましょう。



  void GrowPathTo (GameTile neighbor) { Debug.Assert(HasPath, "No path!"); neighbor.distance = distance + 1; neighbor.nextOnPath = this; }
      
      





アイデアは、タイルの4つの隣接のそれぞれに対してこのメ​​ソッドを1回呼び出すということです。 これらのリンクの一部はnull



になるため、これをチェックし、そうであれば実行を停止します。 さらに、隣人がすでにパスを持っている場合、何もせずに、やめるべきです。



  void GrowPathTo (GameTile neighbor) { Debug.Assert(HasPath, "No path!"); if (neighbor == null || neighbor.HasPath) { return; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; }
      
      





GameTile



が近隣を追跡する方法は、残りのコードでは不明です。 したがって、 GrowPathTo



は非表示になります。 GrowPathTo



間接的に呼び出して、特定の方向にパスを拡大するようタイルに指示する一般的なメソッドを追加します。 ただし、フィールド全体を検索するコードは、訪問されたタイルを追跡する必要があります。 したがって、実行が終了した場合、ネイバーまたはnull



返します。



  GameTile GrowPathTo (GameTile neighbor) { if (!HasPath || neighbor == null || neighbor.HasPath) { return null; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; return neighbor; }
      
      





次に、特定の方向にパスを拡大するメソッドを追加します。



  public GameTile GrowPathNorth () => GrowPathTo(north); public GameTile GrowPathEast () => GrowPathTo(east); public GameTile GrowPathSouth () => GrowPathTo(south); public GameTile GrowPathWest () => GrowPathTo(west);
      
      





広い検索



GameBoard



は、すべてのタイルに正しいパスデータが含まれているGameBoard



GameBoard



必要があります。 これを行うには、幅優先検索を実行します。 エンドポイントタイルから始めて、その隣のタイルへのパスを拡大し、次にこれらのタイルの隣へのパスを拡大します。 各ステップで、距離は1ずつ増加し、パスが既にパスを持っているタイルの方向に成長することはありません。 これにより、結果としてすべてのタイルがエンドポイントへの最短パスに沿ってポイントされるようになります。



A *を使用してパスを見つけるのはどうですか?
A *アルゴリズムは、幅優先探索の進化的開発です。 唯一の最短経路を探しているときに便利です。 ただし、すべての最短パスが必要なので、A *には利点がありません。 幅優先検索およびアニメーション付きの六角形のグリッドでのA *の例については、六角形のマップに関する一連のチュートリアルを参照してください。


検索を実行するには、パスに追加したが、まだパスを拡大していないタイルを追跡する必要があります。 このタイルのコレクションは、多くの場合、検索フロンティアと呼ばれます。 タイルは、境界線に追加されるのと同じ順序で処理されることが重要です。したがって、 Queue



使用しましょう。 後で検索を数回実行する必要があるため、 GameBoard



フィールドとして設定しましょう。



 using UnityEngine; using System.Collections.Generic; public class GameBoard : MonoBehaviour { … Queue<GameTile> searchFrontier = new Queue<GameTile>(); … }
      
      





競技場の状態が常にtrueであるためには、 Initialize



の最後にパスを見つける必要がありますが、別のFindPaths



メソッドにコードを配置する必要があります。 まず、すべてのタイルのパスをクリアしてから、1つのタイルをエンドポイントにして境界線に追加する必要があります。 最初に最初のタイルを選択しましょう。 tiles



は配列であるため、メモリ汚染を恐れることなくforeach



を使用できます。 後で配列からリストに移動する場合、 foreach



ループをfor



ループに置き換える必要もあります。



  public void Initialize (Vector2Int size) { … FindPaths(); } void FindPaths () { foreach (GameTile tile in tiles) { tile.ClearPath(); } tiles[0].BecomeDestination(); searchFrontier.Enqueue(tiles[0]); }
      
      





次に、境界線から1つのタイルを取得し、すべての隣接タイルへのパスを成長させて、それらすべてを境界線に追加する必要があります。 最初に北、次に東、南、最後に西に移動します。



  public void FindPaths () { foreach (GameTile tile in tiles) { tile.ClearPath(); } tiles[0].BecomeDestination(); searchFrontier.Enqueue(tiles[0]); GameTile tile = searchFrontier.Dequeue(); searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); }
      
      





この段階を繰り返しますが、境界線にはタイルがあります。



  while (searchFrontier.Count > 0) { GameTile tile = searchFrontier.Dequeue(); searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); }
      
      





パスを開拓しても、必ずしも新しいタイルに到達するとは限りません。 キューに追加する前に、 null



の値をチェックする必要がありますが、キューからの出力の後までnull



のチェックを延期できます。



  GameTile tile = searchFrontier.Dequeue(); if (tile != null) { searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathWest()); }
      
      





表示パス



これで正しいパスを含むフィールドができましたが、今のところこれは表示されません。 タイルを通るパスに沿って矢印が向くように矢印を構成する必要があります。 これは、それらを回すことで実行できます。 これらのターンは常に同じなので、方向ごとに静的なQuaternion



フィールドをGameTile



1つ追加します。



  static Quaternion northRotation = Quaternion.Euler(90f, 0f, 0f), eastRotation = Quaternion.Euler(90f, 90f, 0f), southRotation = Quaternion.Euler(90f, 180f, 0f), westRotation = Quaternion.Euler(90f, 270f, 0f);
      
      





また、一般的なShowPath



メソッドを追加します。 距離がゼロの場合、タイルは終点であり、指すものは何もないので、その矢印を非アクティブにします。 それ以外の場合は、矢印をアクティブにしてその回転を設定します。 目的の方向は、 nextOnPath



をその近隣と比較することで決定できます。



  public void ShowPath () { if (distance == 0) { arrow.gameObject.SetActive(false); return; } arrow.gameObject.SetActive(true); arrow.localRotation = nextOnPath == north ? northRotation : nextOnPath == east ? eastRotation : nextOnPath == south ? southRotation : westRotation; }
      
      





最後にすべてのタイルに対してこのメ​​ソッドを呼び出しGameBoard.FindPaths



ます。



  public void FindPaths () { … foreach (GameTile tile in tiles) { tile.ShowPath(); } }
      
      











見つけた方法。



矢印を直接GrowPathToに変えてみませんか?
検索のロジックと視覚化を分離するため。後で、視覚化を無効にします。矢印が表示されない場合、を呼び出すたびに矢印を回転させる必要はありませんFindPaths





検索の優先度を変更する



終点が南西の角である場合、すべてのパスはフィールドの端に到達するまで正確に西に進み、その後南に曲がることがわかります。ここではすべてが真実です。なぜなら、斜めの動きは不可能だからです。しかし、他にもきれいに見える可能性のある最短経路は他にもたくさんあります。



そのようなパスが見つかる理由をよりよく理解するには、終点をマップの中心に移動します。奇数のフィールドサイズでは、配列の中央にある単なるタイルです。



  tiles[tiles.Length / 2].BecomeDestination(); searchFrontier.Enqueue(tiles[tiles.Length / 2]);
      
      











中央の終点。



検索の仕組みを覚えていれば、結果は論理的に見えます。隣人を北東南東の順序で追加するため、北の優先度が最も高くなります。逆順で検索しているので、これは最後に移動した方向が南であることを意味します。そのため、南を指す矢印はわずかで、東を指す矢印は多くなっています。



ルートの優先順位を設定して、結果を変更できます。東と南を交換しましょう。したがって、南北および東西の対称性を取得する必要があります。



  searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathWest())
      
      











検索順序は、南北東西です。



見た目はきれいですが、パスが方向を変え、自然に見える動きに斜めに近づく方が良いです。これを行うには、市松模様の隣接タイルの検索優先順位を逆にします。



検索中に処理しているタイルのタイプを把握する代わりにGameTile



、現在のタイルが代替であるかどうかを示す一般プロパティに追加します。



  public bool IsAlternative { get; set; }
      
      





このプロパティはで設定しますGameBoard.Initialize



最初に、X座標が偶数の場合、タイルを代替としてマークします。



  for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … tile.IsAlternative = (x & 1) == 0; } }
      
      





操作(x&1)== 0は何をしますか?
— (AND). . 1, 1. 10101010 00001111 00001010.



. 0 1. 1, 2, 3, 4 1, 10, 11, 100. , .



AND , , . , .


次に、Y座標が偶数の場合、結果の符号を変更します。そこで、チェスパターンを作成します。



  tile.IsAlternative = (x & 1) == 0; if ((y & 1) == 0) { tile.IsAlternative = !tile.IsAlternative; }
      
      





FindPaths



我々は代替タイルの検索と同じ順序を維持するが、他のすべてのタイルに戻ってそれを作るために。これにより、パスが斜めに移動し、ジグザグになります。



  if (tile != null) { if (tile.IsAlternative) { searchFrontier.Enqueue(tile.GrowPathNorth()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathWest()); } else { searchFrontier.Enqueue(tile.GrowPathWest()); searchFrontier.Enqueue(tile.GrowPathEast()); searchFrontier.Enqueue(tile.GrowPathSouth()); searchFrontier.Enqueue(tile.GrowPathNorth()); } }
      
      











可変検索順序。



タイルの変更



この時点で、すべてのタイルは空です。1つのタイルがエンドポイントとして使用されますが、目に見える矢印がないことに加えて、他のすべてのタイルと同じように見えます。オブジェクトを配置してタイルを変更する機能を追加します。



タイルコンテンツ



タイルオブジェクト自体は、タイル情報を追跡する方法にすぎません。これらのオブジェクトを直接変更することはありません。代わりに、個別のコンテンツを追加してフィールドに配置します。今のところ、空のタイルとエンドポイントタイルを区別できます。これらのケースを示すには、列挙を作成しますGameTileContentType







 public enum GameTileContentType { Empty, Destination }
      
      





次に、GameTileContent



インスペクターを使用してそのコンテンツのタイプを設定できるコンポーネントタイプ作成します。コンポーネントタイプへのアクセスは、共通のgetterプロパティを介して行われます。



 using UnityEngine; public class GameTileContent : MonoBehaviour { [SerializeField] GameTileContentType type = default; public GameTileContentType Type => type; }
      
      





次に、2つのタイプのコンテンツ用のプレハブを作成します。各コンテンツにGameTileContent



は、対応する指定されたタイプのコンポーネントがあります。青い平らな立方体を使用して、エンドポイントタイルを指定しましょう。ほとんど平らなので、コライダーは必要ありません。空のコンテンツをプレハブするには、空のゲームオブジェクトを使用します。



行き先






空っぽ






エンドポイントのプレハブと空のコンテンツ。



コンテンツオブジェクトを空のタイルに渡します。これは、すべてのタイルに常にコンテンツが含まれるため、コンテンツへのリンクが等しいかどうかをチェックする必要がないためnull



です。



コンテンツファクトリー



コンテンツを編集可能にするために、オブジェクト管理チュートリアルと同じアプローチを使用して、このためのファクトリも作成しますこれはGameTileContent



、元のファクトリを追跡し、一度だけ設定する必要があることを意味し、メソッドでファクトリに戻りますRecycle







  GameTileContentFactory originFactory; … public GameTileContentFactory OriginFactory { get => originFactory; set { Debug.Assert(originFactory == null, "Redefined origin factory!"); originFactory = value; } } public void Recycle () { originFactory.Reclaim(this); }
      
      





これは存在を前提としているGameTileContentFactory



ため、必要な方法でこれのスクリプト可能オブジェクトタイプを作成しますRecycle



この段階では、コンテンツを利用する完全に機能するファクトリーの作成に煩わされることはないので、コンテンツを破壊するだけです。後で、残りのコードを変更せずにオブジェクトの再利用をファクトリに追加することが可能になります。



 using UnityEngine; using UnityEngine.SceneManagement; [CreateAssetMenu] public class GameTileContentFactory : ScriptableObject { public void Reclaim (GameTileContent content) { Debug.Assert(content.OriginFactory == this, "Wrong factory reclaimed!"); Destroy(content.gameObject); } }
      
      





Get



プレハブをパラメーターとして使用して、非表示メソッドファクトリーに追加します。ここでも、オブジェクトの再利用をスキップします。彼はオブジェクトのインスタンスを作成し、元のファクトリを設定し、それをファクトリシーンに移動して返します。



  GameTileContent Get (GameTileContent prefab) { GameTileContent instance = Instantiate(prefab); instance.OriginFactory = this; MoveToFactoryScene(instance.gameObject); return instance; }
      
      





インスタンスは工場のコンテンツシーンに移動され、必要に応じて作成できます。エディタを使用している場合、シーンを作成する前に、ホットリスタート中に見えなくなった場合に備えて、シーンが存在するかどうかを確認する必要があります。



  Scene contentScene; … void MoveToFactoryScene (GameObject o) { if (!contentScene.isLoaded) { if (Application.isEditor) { contentScene = SceneManager.GetSceneByName(name); if (!contentScene.isLoaded) { contentScene = SceneManager.CreateScene(name); } } else { contentScene = SceneManager.CreateScene(name); } } SceneManager.MoveGameObjectToScene(o, contentScene); }
      
      





コンテンツは2種類しかないため、2つのプレハブ構成フィールドを追加するだけです。



  [SerializeField] GameTileContent destinationPrefab = default; [SerializeField] GameTileContent emptyPrefab = default;
      
      





ファクトリが機能するために最後に行う必要があるのは、対応するプレハブのインスタンスを受け取るGet



パラメーターGameTileContentType



持つ共通メソッドを作成することです。



  public GameTileContent Get (GameTileContentType type) { switch (type) { case GameTileContentType.Destination: return Get(destinationPrefab); case GameTileContentType.Empty: return Get(emptyPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; }
      
      





空のコンテンツの個別のインスタンスを各タイルに追加することは必須ですか?
, . . , - , , , , . , . , , .


ファクトリアセットを作成し、プレハブへのリンクを設定しましょう。









コンテンツファクトリ。



そして、Game



リンクを工場に渡します。



  [SerializeField] GameTileContentFactory tileContentFactory = default;
      
      











工場とのゲーム。



タイルタッチ



フィールドを変更するには、タイルを選択できる必要があります。ゲームモードで可能にします。プレイヤーがゲームウィンドウをクリックした場所のシーンに光線を放出します。ビームがタイルと交差する場合、プレイヤーはタイルに触れました。つまり、変更する必要があります。Game



プレーヤーの入力を処理しますが、プレーヤーがタッチしたタイルを決定する責任がありGameBoard



ます。



すべての光線がタイルと交差するわけではないため、何も受け取れない場合があります。したがって、常に最初に常に返されるGameBoard



メソッドにメソッドを追加しGetTile



ますnull



(これは、タイルが見つからなかったことを意味します)。



  public GameTile GetTile (Ray ray) { return null; }
      
      





光線がタイルを通過したかどうかを判断Physics.Raycast



するには、光線を引数として指定して呼び出す必要があります。交差点があったかどうかに関する情報を返します。その場合は、タイルを返すことができますが、まだどのタイルがわからないので、今のところは返しますnull







  public GameTile TryGetTile (Ray ray) { if (Physics.Raycast(ray) { return null; } return null; }
      
      





タイルとの交差点があるかどうかを確認するには、交差点に関する詳細情報が必要です。Physics.Raycast



2番目のパラメータを使用してこの情報を提供できますRaycastHit



これは出力パラメーターであり、そのout



前の単語示されます。これは、メソッド呼び出しが、渡した変数に値を割り当てることができることを意味します。



  RaycastHit hit; if (Physics.Raycast(ray, out hit) { return null; }
      
      





出力パラメーターに使用される変数の宣言を埋め込むことができますので、それをやってみましょう。



  if (Physics.Raycast(ray, out RaycastHit hit) { return null; }
      
      





交差点がどのコライダーで発生したかは関係ありません。XZ交差点の位置を使用してタイルを決定します。交差点の座標にフィールドのサイズの半分を追加し、結果を整数値に変換することにより、タイルの座標を取得します。結果としての最終的なタイルインデックスは、X座標とY座標にフィールド幅を掛けたものになります。



  if (Physics.Raycast(ray, out RaycastHit hit)) { int x = (int)(hit.point.x + size.x * 0.5f); int y = (int)(hit.point.z + size.y * 0.5f); return tiles[x + y * size.x]; }
      
      





ただし、これはタイルの座標がフィールド内にある場合にのみ可能であるため、これを確認します。そうでない場合、タイルは返されません。



  int x = (int)(hit.point.x + size.x * 0.5f); int y = (int)(hit.point.z + size.y * 0.5f); if (x >= 0 && x < size.x && y >= 0 && y < size.y) { return tiles[x + y * size.x]; }
      
      





コンテンツの変更



タイルのコンテンツを変更できるように、GameTile



一般プロパティに追加しますContent



そのゲッターは単にコンテンツを返し、セッターは前のコンテンツがあればそれを破棄し、新しいコンテンツを配置します。



  GameTileContent content; public GameTileContent Content { get => content; set { if (content != null) { content.Recycle(); } content = value; content.transform.localPosition = transform.localPosition; } }
      
      





null



最初はコンテンツがないため、ここでコンテンツを確認する必要がある唯一の場所です保証するために、setterがで呼び出されないようにassertを実行しnull



ます。



  set { Debug.Assert(value != null, "Null assigned to content!"); … }
      
      





そして最後に、プレイヤーの入力が必要です。マウスのクリックを光線に変換するには、引数としてScreenPointToRay



with Input.mousePosition



呼び出します。を介してアクセスできるメインカメラに対して呼び出しを行う必要がありますCamera.main



これにプロパティcを追加しますGame







  Ray TouchRay => Camera.main.ScreenPointToRay(Input.mousePosition);
      
      





次にUpdate



、更新中にメインのマウスボタンが押されたかどうかを確認するメソッドを追加します。これを行うにはInput.GetMouseButtonDown



、引数としてゼロで呼び出します。キーが押された場合、プレーヤーのタッチを処理します。つまり、フィールドからタイルを取得し、エンドポイントをそのコンテンツとして設定し、工場から取得します。



  void Update () { if (Input.GetMouseButtonDown(0)) { HandleTouch(); } } void HandleTouch () { GameTile tile = GetTile(TouchRay); if (tile != null) { tile.Content = tileContentFactory.Get(GameTileContentType.Destination); } }
      
      





これで、カーソルを押して任意のタイルをエンドポイントに変えることができます。









いくつかのエンドポイント。



フィールドを正しくする



タイルをエンドポイントに変えることはできますが、これはこれまでのパスには影響しません。さらに、タイルに空のコンテンツをまだ設定していません。フィールドの正確さと整合性を維持することはタスクなGameBoard



ので、タイルのコンテンツを設定する責任を彼に与えましょう。これを実装するには、methodを介してコンテンツファクトリへのリンクを提供し、Intialize



それを使用してすべてのタイルに空のコンテンツのインスタンスを提供します。



  GameTileContentFactory contentFactory; public void Initialize ( Vector2Int size, GameTileContentFactory contentFactory ) { this.size = size; this.contentFactory = contentFactory; ground.localScale = new Vector3(size.x, size.y, 1f); tiles = new GameTile[size.x * size.y]; for (int i = 0, y = 0; y < size.y; y++) { for (int x = 0; x < size.x; x++, i++) { … tile.Content = contentFactory.Get(GameTileContentType.Empty); } } FindPaths(); }
      
      





Game



、工場を現場に移さなければなりません。



  void Awake () { board.Initialize(boardSize, tileContentFactory); }
      
      





ファクトリー構成フィールドをGameBoardに追加してみませんか?
, , . , .


これでいくつかのエンドポイントができたGameBoard.FindPaths



ので、BecomeDestination



それぞれを呼び出してそれらをすべて境界線に追加するように変更します。そして、複数のエンドポイントをサポートするのに必要なことはそれだけです。他のすべてのタイルは通常どおりクリアされます。次に、中央のハードセットエンドポイントを削除します。



  void FindPaths () { foreach (GameTile tile in tiles) { if (tile.Content.Type == GameTileContentType.Destination) { tile.BecomeDestination(); searchFrontier.Enqueue(tile); } else { tile.ClearPath(); } } //tiles[tiles.Length / 2].BecomeDestination(); //searchFrontier.Enqueue(tiles[tiles.Length / 2]); … }
      
      





ただし、タイルをエンドポイントに変換できる場合は、逆の操作を実行して、エンドポイントを空のタイルに変換できる必要があります。しかし、その後、エンドポイントがまったくないフィールドを取得できます。この場合、FindPaths



タスクを実行できません。これは、すべてのセルのパスの初期化後に境界が空の場合に発生します。これは、フィールドの無効な状態として示され、false



実行を返し、完了します。そうでない場合は、最後に戻りtrue



ます。



  bool FindPaths () { foreach (GameTile tile in tiles) { … } if (searchFrontier.Count == 0) { return false; } … return true; }
      
      





エンドポイントの削除のサポートを実装してスイッチ操作にする最も簡単な方法。空のタイルをクリックして、それらをエンドポイントに変え、エンドポイントをクリックして、それらを削除します。しかし、今ではコンテンツの変更に取り組んでいるGameBoard



のでToggleDestination



、タイルをパラメーターとする一般的なメソッド提供しましょう。タイルがエンドポイントの場合、空にしてタイルを呼び出しますFindPaths



。それ以外の場合は、それをエンドポイントにして呼び出しますFindPaths







  public void ToggleDestination (GameTile tile) { if (tile.Content.Type == GameTileContentType.Destination) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } }
      
      





エンドポイントを追加しても無効なフィールド状態が作成されることはなく、エンドポイントを削除することもできます。したがって、FindPaths



タイルを空にした後、正常に実行されたかどうかを確認します。そうでない場合は、変更をキャンセルし、タイルを再びエンドポイントFindPaths



に戻し、もう一度呼び出して前の正しい状態に戻ります。



  if (tile.Content.Type == GameTileContentType.Destination) { tile.Content = contentFactory.Get(GameTileContentType.Empty); if (!FindPaths()) { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } }
      
      





検証をより効率的にできますか?
, . , . , . FindPaths



, .


最後に、明示的にを呼び出す代わりに、中央のタイルを引数としてInitialize



呼び出すことができますこれは無効なフィールド状態で開始する唯一の時間ですが、正しい状態で終了することが保証されています。ToggleDestination



FindPaths







  public void Initialize ( Vector2Int size, GameTileContentFactory contentFactory ) { … //FindPaths(); ToggleDestination(tiles[tiles.Length / 2]); }
      
      





最後に、タイル自体のコンテンツを設定する代わりに、強制的にGame



呼び出しToggleDestination



ます。



  void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { //tile.Content = //tileContentFactory.Get(GameTileContentType.Destination); board.ToggleDestination(tile); } }
      
      











正しいパスを持つ複数のエンドポイント。



Gameがタイルのコンテンツを直接設定することを禁止すべきではないでしょうか?
. . , Game



. , .




タワーディフェンスの目標は、敵が最終地点に到達するのを防ぐことです。この目標は2つの方法で達成されます。第一に、彼らを殺し、第二に、彼らを殺すより多くの時間があるように、彼らを遅くします。タイルフィールドでは、敵が移動する必要がある距離を長くすることで時間を伸ばすことができます。これは、障害物のフィールドに配置することで実現できます。通常、これらは敵も殺す塔ですが、このチュートリアルでは壁のみに制限します。



内容



壁は別のタイプのコンテンツなのでGameTileContentType



、要素を追加してみましょう



 public enum GameTileContentType { Empty, Destination, Wall }
      
      





次に、壁のプレハブを作成します。今回は、タイルのコンテンツのゲームオブジェクトを作成し、フィールドに子キューブを追加します。これは、フィールドの上部にあり、タイル全体を埋めます。壁が背後のタイルの一部と視覚的に重なることがあるため、高さを半分にしてコライダーを節約します。したがって、プレーヤーが壁に触れると、対応するタイルに影響を与えます。



根






立方体






プレハブ






プレハブ壁。



コードとインスペクターの両方で、工場に壁のプレハブを追加します。



  [SerializeField] GameTileContent wallPrefab = default; … public GameTileContent Get (GameTileContentType type) { switch (type) { case GameTileContentType.Destination: return Get(destinationPrefab); case GameTileContentType.Empty: return Get(emptyPrefab); case GameTileContentType.Wall: return Get(wallPrefab); } Debug.Assert(false, "Unsupported type: " + type); return null; }
      
      











プレハブ壁の工場。



壁のオンとオフを切り替える



GameBoard



エンドポイントで行ったように、壁をオンまたはオフにする方法を追加します。最初は、フィールドの誤った状態をチェックしません。



  public void ToggleWall (GameTile tile) { if (tile.Content.Type == GameTileContentType.Wall) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } else { tile.Content = contentFactory.Get(GameTileContentType.Wall); FindPaths(); } }
      
      





空のタイルと壁のタイルのみを切​​り替えるためのサポートを提供し、壁がエンドポイントを直接置き換えることを許可しません。したがって、タイルが空の場合にのみ壁を作成します。さらに、壁はパスの検索をブロックする必要があります。ただし、各タイルにはエンドポイントへのパスが必要です。そうしないと、敵はスタックします。これを行うには、再度validationを使用しFindPaths



、不正確なフィールド状態を作成した場合、変更を破棄する必要があります



  else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.Wall); if (!FindPaths()) { tile.Content = contentFactory.Get(GameTileContentType.Empty); FindPaths(); } }
      
      





壁のオン/オフの切り替えは、エンドポイントのオン/オフの切り替えよりも頻繁に使用されるため、Game



メインのタッチで壁を切り替えますエンドポイントは、追加のタッチ(通常はマウスの右ボタン)で切り替えることができ、Input.GetMouseButtonDown



値1 を渡すことで認識できます



  void Update () { if (Input.GetMouseButtonDown(0)) { HandleTouch(); } else if (Input.GetMouseButtonDown(1)) { HandleAlternativeTouch(); } } void HandleAlternativeTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { board.ToggleDestination(tile); } } void HandleTouch () { GameTile tile = board.GetTile(TouchRay); if (tile != null) { board.ToggleWall(tile); } }
      
      











これで壁ができました。



斜めに隣接する壁の影の間に大きな隙間が生じるのはなぜですか?
, , , . , , far clipping plane . , far plane 20 . , MSAA, .


また、エンドポイントが壁を直接置き換えることができないことを確認しましょう。



  public void ToggleDestination (GameTile tile) { if (tile.Content.Type == GameTileContentType.Destination) { … } else if (tile.Content.Type == GameTileContentType.Empty) { tile.Content = contentFactory.Get(GameTileContentType.Destination); FindPaths(); } }
      
      





パス検索ロック



壁がパスの検索をブロックするようにするには、壁のあるタイルを検索境界に追加しなくても十分です。これはGameTile.GrowPathTo



、壁のあるタイルを返さないように強制することで実行できますただし、フィールド上のすべてのタイルにパスがあるように、パスはまだ壁の方向に成長する必要があります。敵のいるタイルが突然壁に変わる可能性があるため、これが必要です。



  GameTile GrowPathTo (GameTile neighbor) { if (!HasPath || neighbor == null || neighbor.HasPath) { return null; } neighbor.distance = distance + 1; neighbor.nextOnPath = this; return neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null; }
      
      





すべてのタイルにパスがあることGameBoard.FindPaths



を確認するには、検索の完了後にこれを確認する必要があります。そうでない場合、フィールドの状態は無効であり、返される必要がありますfalse



フィールドが以前の状態に戻るため、無効な状態のパスの視覚化を更新する必要はありません。



  bool FindPaths () { … foreach (GameTile tile in tiles) { if (!tile.HasPath) { return false; } } foreach (GameTile tile in tiles) { tile.ShowPath(); } return true; }
      
      











壁が道に影響します。



実際に壁に正しいパスがあることを確認するには、キューブを半透明にする必要があります。









透明な壁。



すべてのパスの正確さの要件により、壁が終点のないフィールドの一部をフェンスできないことに注意してください。マップを分割できますが、各パーツに少なくとも1つのエンドポイントがある場合のみです。また、各壁は空のタイルまたはエンドポイントに隣接している必要があります。そうでない場合、パスを持つことができません。たとえば、3×3の壁の固体ブロックを作成することは不可能です。



道を隠す



パスの視覚化により、パス検索がどのように機能するかを確認し、実際に正しいことを確認できます。ただし、プレーヤーに表示する必要はありません。少なくとも、必ずしも表示する必要はありません。したがって、矢印をオフにする機能を提供しましょう。これは、矢印を無効にするだけのGameTile



一般的なメソッドに追加することで実行できますHidePath







  public void HidePath () { arrow.gameObject.SetActive(false); }
      
      





パスマッピング状態は、フィールド状態の一部です。GameBoard



ブールフィールドをデフォルトのイコールfalse



追加して、その状態を追跡し、ゲッターおよびセッターとしての共通プロパティを追跡しますセッターは、すべてのタイルのパスを表示または非表示にする必要があります。



  bool showPaths; public bool ShowPaths { get => showPaths; set { showPaths = value; if (showPaths) { foreach (GameTile tile in tiles) { tile.ShowPath(); } } else { foreach (GameTile tile in tiles) { tile.HidePath(); } } } }
      
      





これで、FindPaths



レンダリングが有効な場合にのみ、メソッドは更新されたパスを表示するはずです。



  bool FindPaths () { … if (showPaths) { foreach (GameTile tile in tiles) { tile.ShowPath(); } } return true; }
      
      





デフォルトでは、パスの視覚化は無効になっています。タイルプレハブの矢印をオフにします。









プレハブ矢印はデフォルトでは無効です。 キーが押されたときに視覚化の状態を切り替える



ようにしGame



ます。Pキーを使用するのは論理的ですが、Unityエディターでゲームモードを有効/無効にするホットキーでもあります。その結果、ゲームモードを終了するためのホットキーを使用すると、視覚化が切り替わりますが、見た目はあまりよくありません。Vキー(視覚化の略)を使用してみましょう。









矢印なし。



グリッド表示



矢印が非表示になると、各タイルの場所を見分けるのが難しくなります。グリッド線を追加しましょう。ここから正方形の境界メッシュテクスチャをダウンロードします。これは、単一のタイルのアウトラインとして使用できます。









メッシュテクスチャ。



このテクスチャを各タイルに個別に追加するのではなく、地面に適用します。ただし、このグリッドをオプションにし、パスを視覚化します。したがって、GameBoard



構成フィールドに追加しそのためのTexture2D



メッシュテクスチャを選択します。



  [SerializeField] Texture2D gridTexture = default;
      
      











メッシュテクスチャを持つフィールド。



別のブールフィールドとプロパティを追加して、グリッドの視覚化の状態を制御します。この場合、セッターは地球のマテリアルを変更する必要があります。これは、GetComponent<MeshRenderer>



地球を呼び出しmaterial



結果プロパティにアクセスすることで実装できますメッシュを表示する必要がある場合、メッシュmainTexture



テクスチャマテリアルプロパティに割り当てますそれ以外の場合は、彼に割り当てnull



ます。マテリアルのテクスチャを変更すると、マテリアルインスタンスの複製が作成されるため、マテリアルアセットから独立することに注意してください。



  bool showGrid, showPaths; public bool ShowGrid { get => showGrid; set { showGrid = value; Material m = ground.GetComponent<MeshRenderer>().material; if (showGrid) { m.mainTexture = gridTexture; } else { m.mainTexture = null; } } }
      
      





Game



Gキーでグリッドの視覚化を切り替えましょう



  void Update () { … if (Input.GetKeyDown(KeyCode.G)) { board.ShowGrid = !board.ShowGrid; } }
      
      





また、デフォルトのメッシュ視覚化をに追加しますAwake







  void Awake () { board.Initialize(boardSize, tileContentFactory); board.ShowGrid = true; }
      
      











スケールなしのグリッド。



これまでのところ、フィールド全体に境界線があります。テクスチャと一致しますが、それは必要なものではありません。グリッドのサイズに一致するように、マテリアルのメインテクスチャをスケーリングする必要があります。これを行うにSetTextureScale



は、テクスチャプロパティの名前(_MainTex)と2次元サイズでmaterial メソッド呼び出します。間接的にvalueに変換されるフィールドのサイズを直接使用できますVector2







  if (showGrid) { m.mainTexture = gridTexture; m.SetTextureScale("_MainTex", size); }
      
      





なしで






と






パスの視覚化をオン/オフにしたスケーリングされたグリッド。



そのため、この段階で、タワーディフェンスのジャンルのタイルゲームの機能フィールドを取得しました。次のチュートリアルでは、敵を追加します。



リポジトリ



PDF



All Articles