Unityでの手続き型ラビリンス生成

画像






注:このチュートリアルはUnity 2017.1.0向けに書かれており、上級ユーザーを対象としています。 Unityでのゲームのプログラミングにすでに慣れていることは理解できます。


Unity開発者は、おそらく手動でレベルを作成する十分な経験を持っています。 しかし、その場でレベルを生成したいと思ったことはありますか? 以前に作成されたモデルの単純な配置とは対照的に、床と壁のメッシュの手順生成は、ゲームのはるかに高い柔軟性と再現性を提供します。



このチュートリアルでは、次のことを学びます。





仕事を始める



ほとんどのアルゴリズム( このアルゴリズムやこのアルゴリズムなど)は、「理想的な」密な迷路、つまり1つの正しいパスのみを持ち、ループを持たないものを作成します。 パズルの新聞セクションに掲載されている迷宮のように見えます。









ただし、迷路が不完全でループがある場合、ほとんどのゲームの方がプレイしやすいです。 それらは広大で、狭い曲がりくねった廊下ではなく、オープンスペースで構成されている必要があります。 これは、手続きレベルがそれほど「迷宮」ではなく、ダンジョンであるならず者のようなジャンルに特に当てはまります。









このチュートリアルでは、 ここで説明する最も単純な迷路アルゴリズムの1つを実装します 。 最小限の労力でゲーム内の迷路を実現するために選択しました。 この単純なアプローチは、ここにリストされている古典的なゲームでうまく機能するため、 Speedy Treasure Thiefと呼ばれるゲームで迷路を作成するために使用できます。



このゲームでは、各レベルは宝箱が隠された新しい迷路です。 しかし、警備員が戻る前に彼を捜索して逃げる時間はあまりありません! 各レベルには時間制限があり、捕まるまでプレイできます。 獲得するポイントは、盗んだ宝物の量によって異なります。









まず、Unityで新しい空のプロジェクトを作成します。



空白プロジェクトをダウンロードして解凍し、新しいプロジェクト** proc-mazes-starter.unitypackage **にインポートします。 ドラフトコンテンツには次のコンテンツが含まれます。



  1. グラフィックフォルダー。ゲームに必要なすべてのグラフィックが含まれています。
  2. シーンシーンは、プレーヤーとUIを含むこのチュートリアルのソースシーンです。
  3. 2つのヘルパースクリプトを含むScriptsフォルダー。 チュートリアルの実行中に残りのスクリプトを作成します。


仕事を始めるにはこれで十分です。 これらの各ポイントについては、後で詳しく検討します。



コードアーキテクチャの定義



空のプロジェクトをシーンに追加することから始めましょう。 GameObject▸Create Emptyを選択し、 Controllerという名前を付けて(X:0、Y:0、Z:0)に配置します。 このオブジェクトは、ゲームを制御するスクリプトの接続点にすぎません。



プロジェクトのScriptsフォルダーで、 GameControllerというC#スクリプトを作成し、別のスクリプトを作成してMazeConstructorという名前を付けます。 最初のスクリプトはゲーム全体を制御し、2番目のスクリプトは迷路を生成します。



GameControllerのすべての行を次のコードに置き換えます。



using System; using UnityEngine; [RequireComponent(typeof(MazeConstructor))] // 1 public class GameController : MonoBehaviour { private MazeConstructor generator; void Start() { generator = GetComponent<MazeConstructor>(); // 2 } }
      
      





作成したものを簡単に説明します。



  1. RequireComponent



    属性は、このスクリプトをGameObjectに追加するときにMazeConstructorコンポーネントを追加します。
  2. プライベート変数は、 GetComponent()



    によって返されるリンクを保持します。


このスクリプトをシーンに追加します。ProjectウィンドウからGameControllerスクリプトをHierarchyウィンドウのGameObject Controllerにドラッグします。



MazeConstructorもControllerに追加されていることに注意してください。 これは、 RequireComponent



属性のおかげで自動的にRequireComponent



ます。



次に、 MazeConstructorのすべてを次のコードに置き換えます。



 using UnityEngine; public class MazeConstructor : MonoBehaviour { //1 public bool showDebug; [SerializeField] private Material mazeMat1; [SerializeField] private Material mazeMat2; [SerializeField] private Material startMat; [SerializeField] private Material treasureMat; //2 public int[,] data { get; private set; } //3 void Awake() { // default to walls surrounding a single empty cell data = new int[,] { {1, 1, 1}, {1, 0, 1}, {1, 1, 1} }; } public void GenerateNewMaze(int sizeRows, int sizeCols) { // stub to fill in } }
      
      





ここで何が起こるかです:



  1. これらすべてのフィールドは、 インスペクターで利用できます。 showDebug



    はデバッグ表示を切り替え、さまざまなマテリアルリンクは生成されたモデルのマテリアルです。 ところで、変数がprivate



    であっても、 SerializeField



    属性はInspectorにフィールドを表示します。
  2. 次はdata



    プロパティです。 アクセス宣言(たとえば、プロパティをpublic



    として宣言した後、 private set



    割り当てる)は、クラス外で読み取り専用にします。 したがって、迷路のデータを外部から変更することはできません。
  3. 興味深いコードの最後の部分はAwake()



    ます。 この関数は、ゼロを囲む3 x 3のユニット配列でdata



    を初期化します。 1は壁を意味し、0は空きスペースを意味します。つまり、グリッドはデフォルトでは壁のある部屋のように見えます。


これはすでにコードの優れた基盤ですが、今のところは何も表示しません!



迷路データを表示してその外観を確認するには、次のメソッドをMazeConstructorに追加します。



 void OnGUI() { //1 if (!showDebug) { return; } //2 int[,] maze = data; int rMax = maze.GetUpperBound(0); int cMax = maze.GetUpperBound(1); string msg = ""; //3 for (int i = rMax; i >= 0; i--) { for (int j = 0; j <= cMax; j++) { if (maze[i, j] == 0) { msg += "...."; } else { msg += "=="; } } msg += "\n"; } //4 GUI.Label(new Rect(20, 20, 500, 500), msg); }
      
      





コメントされた各セクションを検討してください。



  1. このコードは、デバッグ表示がオンになっているかどうかを確認します。
  2. いくつかのローカル変数の初期化:保存された迷路のローカルコピー、最大の行と列、および行。
  3. 2つのネストされたループは、2次元配列の行と列を通過します。 配列の各行/列に対して、コードは保存された値をチェックし、値がゼロかどうかに応じて「....」または「==」を追加します。 また、行のすべての列を調べた後、コードは新しい行を追加して、配列の各行が新しい行で始まるようにします。
  4. 最後に、 GUI.Label()



    は作成される文字列を表示します。 このプロジェクトは新しいプレーヤー出力GUIシステムを使用しますが、古いシステムは高速デバッグメッセージを作成するのが簡単です。


MazeConstructorコンポーネントのShow Debugを必ず有効にしてください。 [ 再生]をクリックすると、保存された迷路データが画面に表示されます(現在はデフォルトの迷路です)。









いいスタートです! ただし、コードはまだ迷路自体を生成しません。 次のセクションでは、この問題を解決する方法を説明します。



迷路データ生成



これまでのところ、 MazeConstructor.GenerateNewMaze()



は空です。 これは空白で、後で入力します。 GameControllerスクリプトのStart()



メソッドの最後に、次の行を追加します。 彼女はこのスタブメソッドを呼び出します。



  generator.GenerateNewMaze(13, 15);
      
      





「マジック」番号13と15は、迷路のサイズを決定するメソッドのパラメーターです。 まだ使用していませんが、これらのサイズオプションは、グリッドの行と列の数を指定します。



この時点で、迷路のデータの生成を開始できます。 新しいMazeDataGeneratorスクリプトを作成します。 このクラスはデータ生成ロジックをカプセル化し、 MazeConstructorで使用されます。 新しいスクリプトを開き、すべてを次のコードに置き換えます。



 using System.Collections.Generic; using UnityEngine; public class MazeDataGenerator { public float placementThreshold; // chance of empty space public MazeDataGenerator() { placementThreshold = .1f; // 1 } public int[,] FromDimensions(int sizeRows, int sizeCols) // 2 { int[,] maze = new int[sizeRows, sizeCols]; // stub to fill in return maze; } }
      
      





このクラスはMonoBehaviourを継承しないことに注意してください。 コンポーネントとして直接使用されるのではなく、 MazeConstructor内でのみ使用されるため、 MonoBehaviourの機能を持つ必要はありません。



  1. スペースが空であるかどうかを決定するために、 placementThreshold



    はデータ生成アルゴリズムによって使用されます。 デフォルトのコンストラクターは、クラスコンストラクターでこの変数に割り当てられますが、他のコードが生成された迷路の設定を制御できるようにpublic



    されます。
  2. メソッドの1つ(この場合はFromDimensions()



    )が再び空になり、空白が残ります。これは後で入力します。


次に、 MazeConstructorにコードのいくつかのセクションを追加して、 スタブメソッドを呼び出せるようにします。 まず、データジェネレーターを格納するプライベート変数を追加します。



 private MazeDataGenerator dataGenerator;
      
      





次に、 Awake()



でインスタンスを作成し、 Awake()



メソッドの先頭に次の行を追加して、ジェネレーターを新しい変数に保存します。



  dataGenerator = new MazeDataGenerator();
      
      





最後に、 GenerateNewMaze()



FromDimensions()



FromDimensions()



を呼び出し、グリッドサイズを渡し、結果のデータを保存します。 GenerateNewMaze()



// stub to fill in



と言う行を見つけて、次の行に置き換えます。



  if (sizeRows % 2 == 0 && sizeCols % 2 == 0) { Debug.LogError("Odd numbers work better for dungeon size."); } data = dataGenerator.FromDimensions(sizeRows, sizeCols);
      
      





生成された迷路は壁に囲まれるため、サイズに奇数を使用する方がよいという警告がここに追加されました。



ゲームを実行して、空の迷路データを表示しますが、正しい寸法で:









いいね! すべてが迷路データを保存して表示する準備ができました! FromDimensions()



から迷路生成アルゴリズムを実装する時が来FromDimensions()













上記のアルゴリズムは、壁を配置し、ブロックする隣接スペースを選択することにより、グリッド内のすべてのセルをバイパスします(つまり、すべてのセルではありません!)。 ここでプログラムされたアルゴリズムはそれとは少し異なります。また、スペースをスキップするかどうかを決定します。これにより、迷路に空きスペースが出現する可能性があります。 アルゴリズムは多くの情報を保存したり、迷路の残りの部分、たとえば通過する必要のある分岐点について多くを知る必要がないため、コードは非常に単純になります。



この迷路生成アルゴリズムを実装するには、 FromDimensions()



からFromDimensions()



次のコードを追加し、行を// stub to fill in



置き換えて入力// stub to fill in



ます。



  int rMax = maze.GetUpperBound(0); int cMax = maze.GetUpperBound(1); for (int i = 0; i <= rMax; i++) { for (int j = 0; j <= cMax; j++) { //1 if (i == 0 || j == 0 || i == rMax || j == cMax) { maze[i, j] = 1; } //2 else if (i % 2 == 0 && j % 2 == 0) { if (Random.value > placementThreshold) { //3 maze[i, j] = 1; int a = Random.value < .5 ? 0 : (Random.value < .5 ? -1 : 1); int b = a != 0 ? 0 : (Random.value < .5 ? -1 : 1); maze[i+a, j+b] = 1; } } } }
      
      





ご覧のとおり、コードは2D配列の境界を取得し、それをバイパスします。



  1. 各グリッドセルについて、現在のセルがグリッドを超えているかどうか(つまり、インデックスのいずれかが配列の境界上にあるかどうか)を最初に確認します。 もしそうなら、彼は1を割り当てる壁を設定します。
  2. 次に、コードは、 2番目のセルごとにアクションを実行するために、座標が完全に2で除算されるかどうかを確認します。 また、このセルをランダムにスキップしてアレイを走査し続けるために、上記のplacementThreshold



    値の追加チェックがあります。
  3. 最後に、コードは現在のセルとランダムに選択された隣接セルに値1を割り当てます。 このコードは、いくつかの3項演算を使用して0、1、または-1を配列インデックスに追加し、隣接セルのインデックスを取得します。


迷路データを再度表示して、生成された迷路がどのように見えるかを確認します。









ゲームを再起動して、迷路データが毎回新しいことを確認します。 いいね!



次の大きな課題は、2D迷路データから3Dメッシュを生成することです。



ラビリンスメッシュ生成



迷路のすべてのデータを生成した後、これらのデータに基づいてメッシュを構築できます。



別の新しいMazeMeshGeneratorスクリプトを作成します。 MazeDataGeneratorが迷路生成ロジックをカプセル化したように、 MazeMeshGeneratorはメッシュ生成ロジックを含み、この迷路生成ステップを完了するためにMazeConstructorによって使用されます。



より正確には、 後でメッシュ生成ロジックが含まれます。 最初に、デモ用のテクスチャ付き四角形を作成し、次にこのコードを変更して迷路全体を生成します。 これを行うには、Unityエディターに小さな変更を加えてから、コードを掘り下げる必要があります。



まず、生成されたメッシュに適用されるマテリアルをバインドする必要があります。



[ プロジェクト]ウィンドウで[ グラフィックス ]フォルダーを選択し、ウィンドウで[ 階層コントローラー]を選択して、 インスペクターにそのMaze Constructorコンポーネントを表示します。



マテリアルをGraphicsフォルダからMaze Constructorマテリアルスロットにドラッグします。 マテリアル1にフロア マットを 、マテリアル2にウォール マットを使用し、 開始を適切なスロットにドラッグします。



すでにインスペクターで作業しているため、 生成されたタグも追加します。 インスペクターの上部にある[タグ]メニューをクリックし、[タグを追加]を選択します。 メッシュを生成するとき、それらを見つけるためにこのタグを割り当てます。



Unityエディターで必要な変更をすべて行った後、新しいスクリプトを開き、すべてをこのコードで置き換えます。



 using System.Collections.Generic; using UnityEngine; public class MazeMeshGenerator { // generator params public float width; // how wide are hallways public float height; // how tall are hallways public MazeMeshGenerator() { width = 3.75f; height = 3.5f; } public Mesh FromData(int[,] data) { Mesh maze = new Mesh(); //1 List<Vector3> newVertices = new List<Vector3>(); List<Vector2> newUVs = new List<Vector2>(); List<int> newTriangles = new List<int>(); // corners of quad Vector3 vert1 = new Vector3(-.5f, -.5f, 0); Vector3 vert2 = new Vector3(-.5f, .5f, 0); Vector3 vert3 = new Vector3(.5f, .5f, 0); Vector3 vert4 = new Vector3(.5f, -.5f, 0); //2 newVertices.Add(vert1); newVertices.Add(vert2); newVertices.Add(vert3); newVertices.Add(vert4); //3 newUVs.Add(new Vector2(1, 0)); newUVs.Add(new Vector2(1, 1)); newUVs.Add(new Vector2(0, 1)); newUVs.Add(new Vector2(0, 0)); //4 newTriangles.Add(2); newTriangles.Add(1); newTriangles.Add(0); //5 newTriangles.Add(3); newTriangles.Add(2); newTriangles.Add(0); maze.vertices = newVertices.ToArray(); maze.uv = newUVs.ToArray(); maze.triangles = newTriangles.ToArray(); return maze; } }
      
      





クラスの最上部にある2つのフィールドwidth



height



MazeDataGeneratorplacementThreshold



似ていplacementThreshold



。これらは、コンストラクターでデフォルトで設定され、メッシュ生成コードで使用される値です。



興味深いコードの大部分はFromData()



ます。 これは、 MazeConstructorがメッシュを生成するために呼び出すメソッドです。 現時点では、このコードは単純に四角形を作成してその動作を示しています。 すぐに全体レベルに拡張します。



この図は、四角形の構成要素を示しています。









コードは長くなりますが、わずかに変化して非常に強く繰り返されます。



  1. メッシュは、頂点、UV座標、および三角形の3つのリストで構成されます。
  2. 頂点のリストには、各頂点の位置が格納されています...
  3. リストされたUV座標は、このリストの頂点に対応しています...
  4. そして、三角形は頂点のリスト内のインデックスです(つまり、「この三角形は頂点0、1、2で構成されています」)。
  5. 2つの三角形が作成されていることに注意してください。 四角形は2つの三角形で構成されます。 また、 List



    データ型が(リストに参加するために)使用されますが、最終的にはMesh



    Arrays



    が必要です。


MazeConstructorMazeMeshGeneratorのインスタンスを作成し、メッシュ生成メソッドを呼び出す必要があります。 また、メッシュも表示されるはずなので、次のコードフラグメントを追加します。



まず、メッシュジェネレーターを格納するプライベートフィールドを追加します。



 private MazeMeshGenerator meshGenerator;
      
      





Awake()でそのインスタンスを作成し、 Awake()メソッドの先頭に次の行を追加して、メッシュジェネレーターを新しいフィールドに保存します。



  meshGenerator = new MazeMeshGenerator();
      
      





次に、 DisplayMaze()メソッドを追加します。



 private void DisplayMaze() { GameObject go = new GameObject(); go.transform.position = Vector3.zero; go.name = "Procedural Maze"; go.tag = "Generated"; MeshFilter mf = go.AddComponent<MeshFilter>(); mf.mesh = meshGenerator.FromData(data); MeshCollider mc = go.AddComponent<MeshCollider>(); mc.sharedMesh = mf.mesh; MeshRenderer mr = go.AddComponent<MeshRenderer>(); mr.materials = new Material[2] {mazeMat1, mazeMat2}; }
      
      





最後に、 DisplayMaze()を呼び出すには、 GenerateNewMaze()の最後に次の行を追加します。



  DisplayMaze();
      
      





Mesh



自体は単なるデータです。 シーン内のオブジェクト(より具体的にMeshFilter



オブジェクトのMeshFilter



)に割り当てられるまで表示されMeshFilter



。 したがって、 DisplayMaze()



MazeMeshGenerator.FromData()



呼び出すだけでなく、この呼び出しを新しいMeshFilter



インスタンスの作成中に挿入し、 生成タグを設定し、 MeshFilter



と生成メッシュを追加し、メッシュとの衝突のためのMeshCollider



を追加し、最後にMeshRenderer



とマテリアルを追加します。



MazeMeshGenerator



クラスを作成し、 MazeConstructorでインスタンス化したので、[ 再生 ]をクリックします。









テクスチャ付き四角形を完全にコードで構築しました! これは興味深く重要な出発点なので、この段階で休憩を取って作業を分析し、コードがどのように機能するかを理解してください。



次に、 FromData()



リファクタリングし、そのようなコードで完全に置き換えます:



 public Mesh FromData(int[,] data) { Mesh maze = new Mesh(); //3 List<Vector3> newVertices = new List<Vector3>(); List<Vector2> newUVs = new List<Vector2>(); maze.subMeshCount = 2; List<int> floorTriangles = new List<int>(); List<int> wallTriangles = new List<int>(); int rMax = data.GetUpperBound(0); int cMax = data.GetUpperBound(1); float halfH = height * .5f; //4 for (int i = 0; i <= rMax; i++) { for (int j = 0; j <= cMax; j++) { if (data[i, j] != 1) { // floor AddQuad(Matrix4x4.TRS( new Vector3(j * width, 0, i * width), Quaternion.LookRotation(Vector3.up), new Vector3(width, width, 1) ), ref newVertices, ref newUVs, ref floorTriangles); // ceiling AddQuad(Matrix4x4.TRS( new Vector3(j * width, height, i * width), Quaternion.LookRotation(Vector3.down), new Vector3(width, width, 1) ), ref newVertices, ref newUVs, ref floorTriangles); // walls on sides next to blocked grid cells if (i - 1 < 0 || data[i-1, j] == 1) { AddQuad(Matrix4x4.TRS( new Vector3(j * width, halfH, (i-.5f) * width), Quaternion.LookRotation(Vector3.forward), new Vector3(width, height, 1) ), ref newVertices, ref newUVs, ref wallTriangles); } if (j + 1 > cMax || data[i, j+1] == 1) { AddQuad(Matrix4x4.TRS( new Vector3((j+.5f) * width, halfH, i * width), Quaternion.LookRotation(Vector3.left), new Vector3(width, height, 1) ), ref newVertices, ref newUVs, ref wallTriangles); } if (j - 1 < 0 || data[i, j-1] == 1) { AddQuad(Matrix4x4.TRS( new Vector3((j-.5f) * width, halfH, i * width), Quaternion.LookRotation(Vector3.right), new Vector3(width, height, 1) ), ref newVertices, ref newUVs, ref wallTriangles); } if (i + 1 > rMax || data[i+1, j] == 1) { AddQuad(Matrix4x4.TRS( new Vector3(j * width, halfH, (i+.5f) * width), Quaternion.LookRotation(Vector3.back), new Vector3(width, height, 1) ), ref newVertices, ref newUVs, ref wallTriangles); } } } } maze.vertices = newVertices.ToArray(); maze.uv = newUVs.ToArray(); maze.SetTriangles(floorTriangles.ToArray(), 0); maze.SetTriangles(wallTriangles.ToArray(), 1); //5 maze.RecalculateNormals(); return maze; } //1, 2 private void AddQuad(Matrix4x4 matrix, ref List<Vector3> newVertices, ref List<Vector2> newUVs, ref List<int> newTriangles) { int index = newVertices.Count; // corners before transforming Vector3 vert1 = new Vector3(-.5f, -.5f, 0); Vector3 vert2 = new Vector3(-.5f, .5f, 0); Vector3 vert3 = new Vector3(.5f, .5f, 0); Vector3 vert4 = new Vector3(.5f, -.5f, 0); newVertices.Add(matrix.MultiplyPoint3x4(vert1)); newVertices.Add(matrix.MultiplyPoint3x4(vert2)); newVertices.Add(matrix.MultiplyPoint3x4(vert3)); newVertices.Add(matrix.MultiplyPoint3x4(vert4)); newUVs.Add(new Vector2(1, 0)); newUVs.Add(new Vector2(1, 1)); newUVs.Add(new Vector2(0, 1)); newUVs.Add(new Vector2(0, 0)); newTriangles.Add(index+2); newTriangles.Add(index+1); newTriangles.Add(index); newTriangles.Add(index+3); newTriangles.Add(index+2); newTriangles.Add(index); }
      
      





うわー、なんて長いコードでしょう! しかし、ここでもほとんど同じことが繰り返されます。一部の数値のみが変更されます。 特に、クアッド生成コードは別のAddQuad()



メソッドに移動され、各グリッドセルの床、天井、壁に対して再度呼び出されます。



  1. AddQuad()



    の最後の3つのパラメーターは、頂点、UV、および三角形の同じリストです。 メソッドの最初の行は、開始するインデックスを取得します。 新しい四角形を追加すると、インデックスが増加します。
  2. ただし、 AddQuad()



    最初のパラメーターは変換行列であり、この部分を理解するのは難しい場合があります。 実際、位置/回転/スケールは行列として保存し、頂点に適用できます。 これは、まさにMultiplyPoint3x4()



    コールが行うことです。 したがって、四角形生成コードは、床、天井、壁などに使用できます。 使用する変換行列を変更するだけです!
  3. FromData()



    FromData()



    ます。 UV頂点と三角形のリストが上部に作成されます。 今回は、三角形の2つのリストがあります。 Mesh



    Unityオブジェクトには、それぞれに異なるマテリアルを持つ多くのサブミックスを含めることができます。つまり、三角形の各リストは個別のサブミックスです。 フロアと壁に異なるマテリアルを割り当てることができるように、2つのミックスを発表します。
  4. その後、2D配列を調べて、各グリッドセルの床、天井、壁の四角形を作成します。 各セルには床と天井が必要です。さらに、壁の必要性について隣接セルのチェックが行われます。 AddQuad()



    は複数回呼び出されますが、そのたびに異なる変換マトリックスと、床と壁に使用される三角形の異なるリストが呼び出されることに注意してください。 また、四角形の位置とサイズを決定するためにwidth



    height



    が使用されることに注意してください。
  5. ああ、もう1つ小さな追加: RecalculateNormals()



    は、メッシュをライティング用に準備します。


[再生]をクリックして、メッシュ全体がどのように生成されるかを確認します。









おめでとう、ここで迷路とSpeedy Treasure Thiefに必要なプログラミングの主要部分が生成されました! 次のセクションでは、ゲームの残りの部分を見ていきます。



ゲームを終了する



コードに他の追加や変更を加える必要がありますが、まず、ドラフトプロジェクトにあったものを使用しましょう。 導入部で述べたように、ドラフトプロジェクトには2つのスクリプトがあります。プレーヤーとUIのあるシーンと、迷路で遊ぶためのすべてのグラフィックです。 FpsMovementスクリプトは、 私の本のキャラクターコントローラーのシングルスクリプトバージョンであり、 TriggerEventRouterは、ゲームトリガーの操作に便利な補助コードです。



プレーヤーは、 FpsMovementコンポーネントと

指向性光源がカメラに取り付けられています。 さらに、スカイボックスとアンビエント照明は、 照明設定ウィンドウで無効になっています。 最後に、シーンにはポイントと時間のマークが付いたUIキャンバスがあります。



そして、それがドラフトプロジェクトにあります。 次に、ゲームの残りのコードを記述します。



MazeConstructorから始めましょう。 まず、次のプロパティを追加して、寸法と座標を保存します。



 public float hallWidth { get; private set; } public float hallHeight { get; private set; } public int startRow { get; private set; } public int startCol { get; private set; } public int goalRow { get; private set; } public int goalCol { get; private set; }
      
      





次に、新しいメソッドを追加する必要があります。 最初はDisposeOldMaze()



です。 名前が示すように、既存の迷路を削除します。 このコードは、 生成されたタグを持つすべてのオブジェクトを見つけて破棄します。



 public void DisposeOldMaze() { GameObject[] objects = GameObject.FindGameObjectsWithTag("Generated"); foreach (GameObject go in objects) { Destroy(go); } }
      
      





次に、 FindStartPosition()



メソッドを追加します。 このコードは0,0から始まり、空きスペースが見つかるまで迷路内のすべてのデータを調べます。 次に、これらの座標が迷路の初期位置として保存されます。



 private void FindStartPosition() { int[,] maze = data; int rMax = maze.GetUpperBound(0); int cMax = maze.GetUpperBound(1); for (int i = 0; i <= rMax; i++) { for (int j = 0; j <= cMax; j++) { if (maze[i, j] == 0) { startRow = i; startCol = j; return; } } } }
      
      





同様に、 FindGoalPosition()



基本的に同じことを行い、最大値から開始してカウントダウンします。 このメソッドも追加します。



 private void FindGoalPosition() { int[,] maze = data; int rMax = maze.GetUpperBound(0); int cMax = maze.GetUpperBound(1); // loop top to bottom, right to left for (int i = rMax; i >= 0; i--) { for (int j = cMax; j >= 0; j--) { if (maze[i, j] == 0) { goalRow = i; goalCol = j; return; } } } }
      
      





PlaceStartTrigger()



およびPlaceGoalTrigger()



は、オブジェクトをシーン内の開始位置とターゲット位置に配置します。 それらのコライダーはトリガーであり、対応するマテリアルが適用されてから、 TriggerEventRouterが追加されます(プロジェクトブランクから)。このコンポーネントは、トリガーのスコープに入ったときに呼び出されるイベント処理関数を受け取ります。これら2つのメソッドを追加します。



 private void PlaceStartTrigger(TriggerEventHandler callback) { GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube); go.transform.position = new Vector3(startCol * hallWidth, .5f, startRow * hallWidth); go.name = "Start Trigger"; go.tag = "Generated"; go.GetComponent<BoxCollider>().isTrigger = true; go.GetComponent<MeshRenderer>().sharedMaterial = startMat; TriggerEventRouter tc = go.AddComponent<TriggerEventRouter>(); tc.callback = callback; } private void PlaceGoalTrigger(TriggerEventHandler callback) { GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube); go.transform.position = new Vector3(goalCol * hallWidth, .5f, goalRow * hallWidth); go.name = "Treasure"; go.tag = "Generated"; go.GetComponent<BoxCollider>().isTrigger = true; go.GetComponent<MeshRenderer>().sharedMaterial = treasureMat; TriggerEventRouter tc = go.AddComponent<TriggerEventRouter>(); tc.callback = callback; }
      
      





最後に、メソッド全体をGenerateNewMaze()



次のコードに置き換えます。



 public void GenerateNewMaze(int sizeRows, int sizeCols, TriggerEventHandler startCallback=null, TriggerEventHandler goalCallback=null) { if (sizeRows % 2 == 0 && sizeCols % 2 == 0) { Debug.LogError("Odd numbers work better for dungeon size."); } DisposeOldMaze(); data = dataGenerator.FromDimensions(sizeRows, sizeCols); FindStartPosition(); FindGoalPosition(); // store values used to generate this mesh hallWidth = meshGenerator.width; hallHeight = meshGenerator.height; DisplayMaze(); PlaceStartTrigger(startCallback); PlaceGoalTrigger(goalCallback); }
      
      





書き換えられたGenerateNewMaze()



メソッドは、古いメッシュの削除やトリガーの配置などの操作のために追加したばかりの新しいメソッドを呼び出します。MazeConstructor



はすでに多くの機能が追加されています。幸い、このクラスはこれで完了です。もう1つコードが残っています。次に、新しいコードをGameControllerに追加しますファイルの内容全体を次のものに置き換えます。







 using System; using UnityEngine; using UnityEngine.UI; [RequireComponent(typeof(MazeConstructor))] public class GameController : MonoBehaviour { //1 [SerializeField] private FpsMovement player; [SerializeField] private Text timeLabel; [SerializeField] private Text scoreLabel; private MazeConstructor generator; //2 private DateTime startTime; private int timeLimit; private int reduceLimitBy; private int score; private bool goalReached; //3 void Start() { generator = GetComponent<MazeConstructor>(); StartNewGame(); } //4 private void StartNewGame() { timeLimit = 80; reduceLimitBy = 5; startTime = DateTime.Now; score = 0; scoreLabel.text = score.ToString(); StartNewMaze(); } //5 private void StartNewMaze() { generator.GenerateNewMaze(13, 15, OnStartTrigger, OnGoalTrigger); float x = generator.startCol * generator.hallWidth; float y = 1; float z = generator.startRow * generator.hallWidth; player.transform.position = new Vector3(x, y, z); goalReached = false; player.enabled = true; // restart timer timeLimit -= reduceLimitBy; startTime = DateTime.Now; } //6 void Update() { if (!player.enabled) { return; } int timeUsed = (int)(DateTime.Now - startTime).TotalSeconds; int timeLeft = timeLimit - timeUsed; if (timeLeft > 0) { timeLabel.text = timeLeft.ToString(); } else { timeLabel.text = "TIME UP"; player.enabled = false; Invoke("StartNewGame", 4); } } //7 private void OnGoalTrigger(GameObject trigger, GameObject other) { Debug.Log("Goal!"); goalReached = true; score += 1; scoreLabel.text = score.ToString(); Destroy(trigger); } private void OnStartTrigger(GameObject trigger, GameObject other) { if (goalReached) { Debug.Log("Finish!"); player.enabled = false; Invoke("StartNewMaze", 4); } } }
      
      





  1. 最初に追加したのは、シーン内のオブジェクトのフィールドをシリアル化することでした。
  2. タイマーとゲームポイントを追跡するためのいくつかのプライベート変数と、ターゲットが迷路で見つかったかどうかを追加しました。
  3. MazeConstructor



    , , Start()



    , GenerateNewMaze()



    .
  4. StartNewGame()



    , . , , .
  5. StartNewMaze()



    , . , , .
  6. Update()



    , , , . .
  7. OnGoalTrigger()



    OnStartTrigger()



    — , TriggerEventRouter MazeConstructor . OnGoalTrigger()



    , , . OnStartTrigger()



    , , , .


そして、これがすべてのコードです。Unityのシーンに戻りましょう。開始するには、[ 階層]ウィンドウで[ キャンバス]選択し、インスペクターで有効にします。迷路コードの作成時にデバッグの表示を妨げないように、Canvasは無効になっています。シリアル化されたフィールドが追加されていることを忘れないでください。そのため、シーンオブジェクト(PlayerCanvasのTimeラベル、およびScoreラベル)をインスペクターのスロットにドラッグします[ デバッグの表示 ]をオフにして、[再生]をクリックすることもできます。









素晴らしい仕事です!手続き的に迷路を生成するのは大変な作業ですが、その結果、刺激的でダイナミックなゲームプレイが得られます。



次はどこへ行きますか?



私の後に繰り返した場合、あなたはすでに完成したゲームを作成しています。必要に応じて、完成したUnityプロジェクトをここからダウンロードできます



次に、のコードを置き換えることにより、他の迷路生成アルゴリズムを調べることができますFromDimensions()



他の環境を生成することもできます。セルオートマトンで洞窟の生成を探索することから始めます。



マップ上のオブジェクトと敵のランダムな生成は、非常に興味深いアクティビティです。



All Articles