unity實現迷宮的隨機生成


在本教程中,我們會生成一個多區域迷宮,並在其中游覽。你會學會以下內容
-用迷宮生成算法填充一塊2D矩形區域
-利用協程使算法可視化
-放置牆和門
-使用對象繼承
-使用擴展方法
-在迷宮中穿梭
-結合第一人稱視角和小地圖
-確定可見的房間

 

你需要具備編輯器和編寫腳步的基礎知識。如果你已經學完了Clock和Fractal教程,那么你可以開始本教程了。
本教程要求Unity4.5或以上版本,更舊的版本將無法正常工作。

 

隨機迷宮
你可能已經見過一些隨機生成的迷宮。迷宮的類型很多,但是基本上核心都是一樣的。迷宮就是一個集合,或者一系列相連的區域,你可以從任何地方開始,訪問到所有其他區域。這些區域的形狀和布局,已經它們是如何連接的,決定了迷宮的特性。
是時候生成我們自己的迷宮了!你可以先試試最終版本,它就是我們將要完成的事。
按下空格重新開始生成。一旦完成,你可以用控制角色游覽迷宮,方向鍵或WASD鍵控制行走,QE控制轉向。右鍵進入全屏。

游戲流程
如果想要做游戲,先要生成迷宮,然后生成可以游覽迷宮的游戲角色。只要開始一個新游戲,就要銷毀當前迷宮,並生成一個新的,再將角色放置在新迷宮中。首先創建一個游戲管理器,來處理這一流程。
創建一個新工程,放置一束默認的平行光,實現基本的光照。然后添加一個名為GameManager的C#腳本。資源應該按類型歸類,所以先新建一個腳步文件夾。之后,創建一個新的游戲物體,命名為Game Manager,並將剛才新建的腳本添加到新物體上。
GameManager腳本中,調用Start方法只是開始游戲。只要按下空格,就重新開始游戲。為了支持這一功能,需要在每次的update中檢測空格鍵是否被按下。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
using UnityEngine; using System.Collections;
public class GameManager : MonoBehaviour {
 
private void Start () {
BeginGame();
}
 
private void Update () {
if (Input.GetKeyDown(KeyCode.Space)) {
RestartGame();
}
}
 
private void BeginGame () {}
 
private void RestartGame () {}}

為了能開始游戲,我們需要先創建一個迷宮。所以添加一個Maze腳本,然后創建一個名為Maze的空游戲物體,並將腳本添加到游戲物體上。創建一個新的Prefabs文件夾,將剛才的游戲物體拖拽到其中,使它成為一個預制。完成這些之后,將游戲物體從層次面板中刪除。

[C#]  純文本查看  復制代碼
?
 
1
using UnityEngine; using System.Collections;
public class Maze : MonoBehaviour {}
<ignore_js_op style="word-wrap: break-word;">

現在可以在GameManager中添加對這個預制的引用,以便可以創建它的實例。添加一個公共變量來保存對預制的引用,在添加一個私有變量保存生成的預制實例。之后,可以在BeginGame中實例化預制,在重新開始游戲前的RestartGame中銷毀實例。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
public Maze mazePrefab;
 
private Maze mazeInstance;
  public int sizeX, sizeZ;
 
public MazeCell cellPrefab;
 
private MazeCell[,] cells;
private void BeginGame () {
mazeInstance = Instantiate(mazePrefab) as Maze;
}
 
private void RestartGame () {
Destroy(mazeInstance.gameObject);
BeginGame();
}

<ignore_js_op style="word-wrap: break-word;">

迷宮原理
現在游戲管理器已經可以工作了。當開始播放時,會創建一個迷宮實例,當按下空格鍵時會銷毀它並創建一個新的。現在是時候生成迷宮的內容了。
我們會用填充矩形網格的方式,創建一個簡單的迷宮。單元格的數量是可配置的,目前設置為20*20的。它們被存儲在一個二維數據中。創建一個新的MazeCell腳本來表示這些單元格。還需要創建一個單元格的預制,以便將來實例化。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
 
 
using UnityEngine;
public class MazeCell : MonoBehaviour {}

迷宮的單元格需要三維顯示。創建一個新的游戲物體,命名為Maze Cell,並添加MazeCell腳本。然后,創建一個默認的quad物體,作為單元格的子物體,並將旋轉設置為 (90,0,0)。這樣我們就有了一個簡單的地塊,用來填充單元格區域。將整個物體轉換為一個預制,刪除層次面板中的實例,並在Maze腳本中引用它。
<ignore_js_op style="word-wrap: break-word;">

現在向Maze腳本中添加一個Generate方法,用來創建迷宮的內容。一開始先創建一個二維數組,通過雙重循環,將新的單元格預制填充到整個網格中。每個單元格的創建在它自己的方法中。實例化一個新單元格放入數組,然后給它取一個具有描述性的名字。將它設置為迷宮的子物體,並設置位置,以保證整個網格是居中的。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public void Generate () {
cells = new MazeCell[sizeX, sizeZ];
for ( int x = 0; x < sizeX; x++) {
for ( int z = 0; z < sizeZ; z++) {
CreateCell(x, z);
}
}
}
 
private void CreateCell ( int x, int z) {
MazeCell newCell = Instantiate(cellPrefab) as MazeCell;
cells[x, z] = newCell;
newCell.name = "Maze Cell " + x + ", " + z;
newCell.transform.parent = transform;
newCell.transform.localPosition = new Vector3(x - sizeX * 0.5f + 0.5f, 0f, z - sizeZ * 0.5f + 0.5f);
}

現在讓GameManager調用Generate,當開始播放時,迷宮就會出現。

[C#]  純文本查看  復制代碼
?
 
1
2
3
private void BeginGame () {
mazeInstance = Instantiate(mazePrefab) as Maze;
mazeInstance.Generate();
}

<ignore_js_op style="word-wrap: break-word;">

現在我們可以得到一個被填滿的網格,但是不能實時看見單元格的生成順序。讓生成過程慢下來我們就可以看見它是如何工作的,這很有用,也很有趣。將Generate改為協程就可以實現,在每一步之前加一點延時。我把延時設置為0.01秒,這就意味着,如果幀率夠高,生成20*20的單元格大約需要4秒。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
public float generationStepDelay;
 
public IEnumerator Generate () {
WaitForSeconds delay = new WaitForSeconds(generationStepDelay);
cells = new MazeCell[sizeX, sizeZ];
for ( int x = 0; x < sizeX; x++) {
for ( int z = 0; z < sizeZ; z++) {
yield return delay;
CreateCell(x, z);
}
}
}

現在必須要修改GameManager了,以便它能夠正確啟動協程。同樣,當游戲重新開始時,正確地停止協程也很重要,因為這可能導致生成過程還沒結束就被終止了。這里只用了一個協程,所以可以調用StopAllCoroutines來結束它。那么現在,在迷宮的生成過程中也可以按空格鍵了,那樣會立刻再生成一個新迷宮。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
private void BeginGame () {
mazeInstance = Instantiate(mazePrefab) as Maze;
StartCoroutine(mazeInstance.Generate());
}
 
private void RestartGame () {
StopAllCoroutines();
Destroy(mazeInstance.gameObject);
BeginGame();
}

<ignore_js_op style="word-wrap: break-word;">
<ignore_js_op style="word-wrap: break-word;">

單元格坐標和整型矢量
生成一個真實的迷宮,需要用隨機的方式向迷宮中添加單元格,而不是像之前那樣的雙重循環。我們需要用迷宮坐標來計算每一步位置。所有操作是在2D空間的,所以用兩個整數就可以表示一個位置了。如果能將坐標當做一個值來操作就更方便了,例如Vector2,不過不是浮點型的,而是整型的。不幸的是並沒有這種結構體,但是我們可以自己創建一個。
添加一個IntVector2腳本,用結構體而不是類。為它設置兩個整型的公共成員變量。這兩個整型變量會被當做一個值處理。還可以添加特定的構造方法,這樣就可以通過new IntVector2(1, 2)的形式定義值了。
[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
public struct IntVector2 {
 
public int x, z;
 
public IntVector2 ( int x, int z) {
this .x = x;
this .z = z;
}}

<ignore_js_op style="word-wrap: break-word;">
常用操作是將矢量添加到一個點上,可以為這種操作創建一個方法。但是如果只是通過+操作符來實現更方便。創建操作符方法可以達到這個目的,這也是Unity的矢量所支持的操作。讓兩個矢量相加意味着調用了一個方法。

現在添加對+操作符的支持。你也可以定義其他的操作符,但這里只需要加法。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
public static IntVector2 operator + (IntVector2 a, IntVector2 b)
{
a.x += b.x;
a.z += b.z;
return a;
}

現在可以用我們的整型矢量類型,定義MazeCell的坐標了。

[C#]  純文本查看  復制代碼
?
 
public IntVector2 coordinates;

在創建單元格是,IntVector2還可以用來控制Maze的大小,而不是用兩個單獨的整型。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public IntVector2 size;
 
public IEnumerator Generate () {
WaitForSeconds delay = new WaitForSeconds(generationStepDelay);
cells = new MazeCell[size.x, size.z];
for ( int x = 0; x < size.x; x++) {
for ( int z = 0; z < size.z; z++) {
yield return delay;
CreateCell( new IntVector2(x, z));
}
}
}
 
private void CreateCell (IntVector2 coordinates) {
MazeCell newCell = Instantiate(cellPrefab) as MazeCell;
cells[coordinates.x, coordinates.z] = newCell;
newCell.coordinates = coordinates;
newCell.name = "Maze Cell " + coordinates.x + ", " + coordinates.z;
newCell.transform.parent = transform;
newCell.transform.localPosition =
new Vector3(coordinates.x - size.x * 0.5f + 0.5f, 0f, coordinates.z - size.z * 0.5f + 0.5f);
}

不過這樣做還有些問題。迷宮的大小不能顯示在檢視面板中了。因為Unity不能保存自定義結構體。幸運的是,只要給IntVector2添加了System命名空間下的Serializable屬性,這個問題就迎刃而解。

[C#]  純文本查看  復制代碼
?
 
[System.Serializable] public struct IntVector2
<ignore_js_op style="word-wrap: break-word;">

隨機生成單元格
現在拋棄之前的雙重循環吧(它們只能生成矩形排列的單元格),在迷宮中隨機選擇一些坐標,然后從這些位置開始生成一行單元格,直到超出迷宮范圍。
[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
public IEnumerator Generate () {
WaitForSeconds delay = new WaitForSeconds(generationStepDelay);
cells = new MazeCell[size.x, size.z];
IntVector2 coordinates = RandomCoordinates;
while (ContainsCoordinates(coordinates)) {
yield return delay;
CreateCell(coordinates);
coordinates.z += 1;
}
}

為了完成上述工作,還需要向Maze添加一個RandomCoordinates屬性,用來處理內部的坐標。還需要添加一個ContainsCoordinates方法,用於檢測坐標是否在迷宮中。這個方法會被用於任何處理迷宮的地方,所以要設置為公共的。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
public IntVector2 RandomCoordinates {
get {
return new IntVector2(Random.Range(0, size.x), Random.Range(0, size.z));
}
}
 
public bool ContainsCoordinates (IntVector2 coordinate) {
return coordinate.x >= 0 && coordinate.x < size.x && coordinate.z >= 0 && coordinate.z < size.z;
}
<ignore_js_op style="word-wrap: break-word;">

但是我們並不想只在一條直線中行走,而是每一步超一個隨機方向移動。但是方向是從何而來的呢?創建一個MazeDirection枚舉類型來定義方向,包括東、西、南、北。將這部分代碼放在一個單獨的腳本文件中。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
using UnityEngine;
public enum MazeDirection {
North,
East,
South,
West}
<ignore_js_op style="word-wrap: break-word;">

限制可以方便地獲取隨機方向了。不過枚舉不是類或結構體,不能在其中定義方法或屬性。只能再添加一個靜態類,用於保存隨機屬性。這個類有多個版本,以名字區分,也放在MazeDirection腳本中。再添加一個常量計數器,這樣可以方便地獲取到有多少方向。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
public static class MazeDirections {
 
public const int Count = 4;
 
public static MazeDirection RandomValue {
get {
return (MazeDirection)Random.Range(0, Count);
}
}}

現在可以獲取隨機方向了,但是如何基於這個方向生成當前坐標呢?如果把方向轉換為整型矢量就很方便了。在MazeDirections中添加一個方法來實現這一步。這里用一個私有靜態數組簡化這步轉換操作。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
private static IntVector2[] vectors = {
new IntVector2(0, 1),
new IntVector2(1, 0),
new IntVector2(0, -1),
new IntVector2(-1, 0)
};
 
public static IntVector2 ToIntVector2 (MazeDirection direction) {
return vectors[( int )direction];
}

MazeDirections.ToIntVector2(someDirection)可以將任意方向轉換為一個整型矢量。這種方式看起來有點糟糕。如果是通過someDirection.ToIntVector2()的形式就更好了。好消息是,我們可以通過擴展方法實現它。只需要對ToIntVector2做一點小改變,它就可以向MazeDirection的實例方法一樣工作了。

[C#]  純文本查看  復制代碼
?
 
1
2
public static IntVector2 ToIntVector2 ( this MazeDirection direction) {
return vectors[( int )direction];
}

有了這些,讓Maze每步以隨機方向生成新單元格就很簡單了。我們必須保證每個單元格最多被訪問一次,所以需要添加一個以坐標檢索迷宮單元格的方法。
[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
public MazeCell GetCell (IntVector2 coordinates) {
return cells[coordinates.x, coordinates.z];
}
 
public IEnumerator Generate () {
WaitForSeconds delay = new WaitForSeconds(generationStepDelay);
cells = new MazeCell[size.x, size.z];
IntVector2 coordinates = RandomCoordinates;
while (ContainsCoordinates(coordinates) && GetCell(coordinates) == null ) {
yield return delay;
CreateCell(coordinates);
coordinates += MazeDirections.RandomValue.ToIntVector2();
}
}

<ignore_js_op style="word-wrap: break-word;">我們打算用List容納迷宮單元格,所以需要在Maze腳本開始出加上
 
 
 

[C#]  純文本查看  復制代碼
?
 
1
2
Systems.Collections.Generic namespace
using UnityEngine; using System.Collections; using System.Collections.Generic;
public class Maze : MonoBehaviour

在Generate方法內創建一個臨時的List。為了讓這個方法保持簡潔,將生成的步驟放在它們自己的方法中,並將一個list作為參數。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
public IEnumerator Generate () {
WaitForSeconds delay = new WaitForSeconds(generationStepDelay);
cells = new MazeCell[size.x, size.z];
List<MazeCell> activeCells = new List<MazeCell>();
DoFirstGenerationStep(activeCells);
while (activeCells.Count > 0) {
yield return delay;
DoNextGenerationStep(activeCells);
}
}

目前的DoFirstGenerationStep方法還很短。DoNextGenerationStep稍長一些,因為它要查找當前單元格,檢查它是否可以移動到,是否需要從List中刪除。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
private void DoFirstGenerationStep (List<MazeCell> activeCells) {
activeCells.Add(CreateCell(RandomCoordinates));
}
 
private void DoNextGenerationStep (List<MazeCell> activeCells) {
int currentIndex = activeCells.Count - 1;
MazeCell currentCell = activeCells[currentIndex];
MazeDirection direction = MazeDirections.RandomValue;
IntVector2 coordinates = currentCell.coordinates + direction.ToIntVector2();
if (ContainsCoordinates(coordinates) && GetCell(coordinates) == null ) {
activeCells.Add(CreateCell(coordinates));
}
else {
activeCells.RemoveAt(currentIndex);
}
}

為了正常工作,另外一個需要改變的地方是,CreateCell需要返回一個新創建的單元格

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
private MazeCell CreateCell (IntVector2 coordinates) {
MazeCell newCell = Instantiate(cellPrefab) as MazeCell;
cells[coordinates.x, coordinates.z] = newCell;
newCell.coordinates = coordinates;
newCell.name = "Maze Cell " + coordinates.x + ", " + coordinates.z;
newCell.transform.parent = transform;
newCell.transform.localPosition =
new Vector3(coordinates.x - size.x * 0.5f + 0.5f, 0f, coordinates.z - size.z * 0.5f + 0.5f);
return newCell;
}

<ignore_js_op style="word-wrap: break-word;">

連接單元格
現在我們打算生成一些更長的路徑,它里一個迷宮的完成還差很多。在選擇如何從一個單元格移動到另一個時,我們應該用更智能的方法。
現在是時候跟蹤單元格之間的連接了。每個單元格有四條邊,每個有一個單元格,除了迷宮的邊界。可以在兩個單元格之間創建一個雙向連接的邊,或者給每個單元格記錄自己單向連接的邊。我選擇后者,因為更靈活。
添加一個新腳本,命名為MazeCellEdge。讓它引用自己所屬的單元格,以及它連接的另一個單元格。再定一個成員用來保存方向。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
using UnityEngine;
public class MazeCellEdge : MonoBehaviour {
 
public MazeCell cell, otherCell;
 
public MazeDirection direction;}

我想要邊成為它們所屬的單元格的子物體,並將它們放在相同的位置。同樣,一旦創建了一條邊,它所屬的單元格應該也能知道。創建一個Initialize方法來實現這一步。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
public void Initialize (MazeCell cell, MazeCell otherCell, MazeDirection direction) {
this .cell = cell;
this .otherCell = otherCell;
this .direction = direction;
cell.SetEdge(direction, this );
transform.parent = cell.transform;
transform.localPosition = Vector3.zero;
}

現在需要向MazeCell添加一個SetEdge方法和一個GetEdge方法,因為它會勢必會被用到。單元格把它們的邊存儲在一個數組中,但是沒有其他類需要知道它的實現方式,所以把這個數組設置為私有的。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
private MazeCellEdge[] edges = new MazeCellEdge[MazeDirections.Count];
 
public MazeCellEdge GetEdge (MazeDirection direction) {
return edges[( int )direction];
}
 
public void SetEdge (MazeDirection direction, MazeCellEdge edge) {
edges[( int )direction] = edge;
}

只要從一個單元格移動一個新單元格,就應該通知兩個單元格,現在有一條連接它們的通道。一旦移出迷宮或者遇見已經創建過的單元格,這條邊會變成牆而不是通道。所以單元格的邊有兩種類型。添加MazePassage腳本和MazeWall腳本,它們都繼承於MazeCellEdge,但代碼實現在各自的文件中。因為我們只會用到這兩種類型,而不會創建父類MazeCellEdge的實例,所以將MazeCellEdge標記為抽象類。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
public abstract class MazeCellEdge : MonoBehaviour
 
using UnityEngine;
public class MazePassage : MazeCellEdge {}
 
using UnityEngine;
public class MazeWall : MazeCellEdge {}

<ignore_js_op style="word-wrap: break-word;">

現在創建一個表示通道的預制。它是一個空游戲物體,上面添加了一個MazePassage腳本。牆的預制創建方法類似,只是多了一個默認的立方體作為子物體。這個立方體用來表示3D的牆。修改它的厚度為0.05個單位,並把它放置在單元格的北邊緣,表示結束。

<ignore_js_op style="word-wrap: break-word;">

預制准備好后,Maze就可以引用它們倆了,以便生成它們的實例。

<ignore_js_op style="word-wrap: break-word;">

假設我們有一個DoNextGenerationStep方法,在其中創建通道和牆。當超出迷宮范圍時,添加一堵牆。如果在迷宮內,需要檢查當前單元格是否和其他相鄰。如果沒有相鄰單元格則創建一個,並在它們間放置一條通道。如果有相鄰單元格,則在它們之間放置一睹牆。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
private void DoNextGenerationStep (List<MazeCell> activeCells) {
int currentIndex = activeCells.Count - 1;
MazeCell currentCell = activeCells[currentIndex];
MazeDirection direction = MazeDirections.RandomValue;
IntVector2 coordinates = currentCell.coordinates + direction.ToIntVector2();
if (ContainsCoordinates(coordinates)) {
MazeCell neighbor = GetCell(coordinates);
if (neighbor == null ) {
neighbor = CreateCell(coordinates);
CreatePassage(currentCell, neighbor, direction);
activeCells.Add(neighbor);
}
else {
CreateWall(currentCell, neighbor, direction);
activeCells.RemoveAt(currentIndex);
}
}
else {
CreateWall(currentCell, null , direction);
activeCells.RemoveAt(currentIndex);
}
}

CreatePassage 和CreateWall 方法,只是根據它們所代表的預制,創建了兩個實例對象並初始化。兩者間真正的不同在於,CreateWall方法的第二個單元格不存在於迷宮。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
private void CreatePassage (MazeCell cell, MazeCell otherCell, MazeDirection direction) {
MazePassage passage = Instantiate(passagePrefab) as MazePassage;
passage.Initialize(cell, otherCell, direction);
passage = Instantiate(passagePrefab) as MazePassage;
passage.Initialize(otherCell, cell, direction.GetOpposite());
}
 
private void CreateWall (MazeCell cell, MazeCell otherCell, MazeDirection direction) {
MazeWall wall = Instantiate(wallPrefab) as MazeWall;
wall.Initialize(cell, otherCell, direction);
if (otherCell != null ) {
wall = Instantiate(wallPrefab) as MazeWall;
wall.Initialize(otherCell, cell, direction.GetOpposite());
}
}

以上代碼用到了一個還不存在的GetOpposite 方法,接下來在MazeDirections中添加一個這個方法。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
private static MazeDirection[] opposites = {
MazeDirection.South,
MazeDirection.West,
MazeDirection.North,
MazeDirection.East
};
 
public static MazeDirection GetOpposite ( this MazeDirection direction) {
return opposites[( int )direction];
}

<ignore_js_op style="word-wrap: break-word;">

現在像迷宮添加一個不可見的通道和可見的牆。這樣可以看到,牆總是在迷宮單元格的北邊,這是不正確的。在MazeCellEdge的Initialize 方法中,將它旋轉到右側,可以修正這個問題

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
public void Initialize (MazeCell cell, MazeCell otherCell, MazeDirection direction) {
this .cell = cell;
this .otherCell = otherCell;
this .direction = direction;
cell.SetEdge(direction, this );
transform.parent = cell.transform;
transform.localPosition = Vector3.zero;
transform.localRotation = direction.ToRotation();
}

是的,這意味着我們要向MazeDirections再添加一個方法。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
private static Quaternion[] rotations = {
Quaternion.identity,
Quaternion.Euler(0f, 90f, 0f),
Quaternion.Euler(0f, 180f, 0f),
Quaternion.Euler(0f, 270f, 0f)
};
 
public static Quaternion ToRotation ( this MazeDirection direction) {
return rotations[( int )direction];
}

<ignore_js_op style="word-wrap: break-word;">

生成整個迷宮
現在牆已經被正確的旋轉了,我們還是沒有填滿整個迷宮。更糟糕的是,產生了一些完全封閉的區域,對迷宮的其他部分來說,它們是不可達的。會這樣是因為選擇下一步方向是完全隨機的,這導致牆會被放在了已經被定義為通道的地方。
如果只移除那些所有邊都被初始化過的單元格,就可以填滿迷宮。所以第一步要在DoNextGenerationStep方法里加入檢測,對於活動列表中的單元格,如果它所有相鄰位置都被訪問過,那么它就已經被初始化完了。為了防止在錯誤的位置放置牆,方向只能選擇當前單元格還沒有被初始化的方向中隨機選擇

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void DoNextGenerationStep (List<MazeCell> activeCells) {
int currentIndex = activeCells.Count - 1;
MazeCell currentCell = activeCells[currentIndex];
if (currentCell.IsFullyInitialized) {
activeCells.RemoveAt(currentIndex);
return ;
}
MazeDirection direction = currentCell.RandomUninitializedDirection;
IntVector2 coordinates = currentCell.coordinates + direction.ToIntVector2();
if (ContainsCoordinates(coordinates)) {
MazeCell neighbor = GetCell(coordinates);
if (neighbor == null ) {
neighbor = CreateCell(coordinates);
CreatePassage(currentCell, neighbor, direction);
activeCells.Add(neighbor);
}
else {
CreateWall(currentCell, neighbor, direction);
// No longer remove the cell here.
}
}
else {
CreateWall(currentCell, null , direction);
// No longer remove the cell here.
}
}

讓MazeCell保存被設置過的邊數,就可以簡單地判斷處單元格是否初始化完全。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
private int initializedEdgeCount;
 
public bool IsFullyInitialized {
get {
return initializedEdgeCount == MazeDirections.Count;
}
}
 
public void SetEdge (MazeDirection direction, MazeCellEdge edge) {
edges[( int )direction] = edge;
initializedEdgeCount += 1;
}

要獲取完全隨機的未初始化方向不那么簡單。一種方法是隨機決定我們應該跳過多少未初始化的方向,然后遍歷邊數組,當發現一個空位時檢查它是否為跳過的邊,如果不是,那將它作為選擇的方向。如果是,將跳過的總變數減1

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
public MazeDirection RandomUninitializedDirection {
get {
int skips = Random.Range(0, MazeDirections.Count - initializedEdgeCount);
for ( int i = 0; i < MazeDirections.Count; i++) {
if (edges == null ) {
if (skips == 0) {
return (MazeDirection)i;
}
skips -= 1;
}
}
}
}

只有存在未初始化的邊,這個方法才能正常工作,否則不能調用這個方法。如果調用,那么在執行完循環后,該方法不應返回任何值。事實上,編譯器會警告不是所有的路徑都有返回值。在方法的末尾拋出一個InvalidOperationException異常,可以解決這個問題。如果代碼有bug,在錯誤的地方調用了這個方法,那么它這會在Unity的控制它輸出一條有用的錯誤信息。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
public MazeDirection RandomUninitializedDirection {
get {
int skips = Random.Range(0, MazeDirections.Count - initializedEdgeCount);
for ( int i = 0; i < MazeDirections.Count; i++) {
if (edges == null ) {
if (skips == 0) {
return (MazeDirection)i;
}
skips -= 1;
}
}
throw new System.InvalidOperationException( "MazeCell has no uninitialized directions left." );
}
}

<ignore_js_op style="word-wrap: break-word;">

最后,終於可以生成完整的迷宮了!現在我們使用的是一種增長樹算法。如果你有興趣,可以用其他方法,選擇DoNextGenerationStep方法中的當前索引,從而改變迷宮的風格。我總是選擇最后的索引,這會讓迷宮被分隔為一寫狹窄的通道。總是選擇第一個或者中間的索引,會讓迷宮呈現迥異的風格。另一種方法是隨機選擇索引。還可以每一步從兩種方法中選擇一個。如果有需要,還可以讓它變成可配置的,參加圖像教程中關於這個方法。

<ignore_js_op style="word-wrap: break-word;"> <ignore_js_op style="word-wrap: break-word;">
<ignore_js_op style="word-wrap: break-word;"> <ignore_js_op style="word-wrap: break-word;">
在本教程中,我們會生成一個多區域迷宮,並在其中游覽。你會學會以下內容
-用迷宮生成算法填充一塊2D矩形區域
-利用協程使算法可視化
-放置牆和門
-使用對象繼承
-使用擴展方法
-在迷宮中穿梭
-結合第一人稱視角和小地圖
-確定可見的房間

 

你需要具備編輯器和編寫腳步的基礎知識。如果你已經學完了Clock和Fractal教程,那么你可以開始本教程了。
本教程要求Unity4.5或以上版本,更舊的版本將無法正常工作。

 

隨機迷宮
你可能已經見過一些隨機生成的迷宮。迷宮的類型很多,但是基本上核心都是一樣的。迷宮就是一個集合,或者一系列相連的區域,你可以從任何地方開始,訪問到所有其他區域。這些區域的形狀和布局,已經它們是如何連接的,決定了迷宮的特性。
是時候生成我們自己的迷宮了!你可以先試試最終版本,它就是我們將要完成的事。
按下空格重新開始生成。一旦完成,你可以用控制角色游覽迷宮,方向鍵或WASD鍵控制行走,QE控制轉向。右鍵進入全屏。

游戲流程
如果想要做游戲,先要生成迷宮,然后生成可以游覽迷宮的游戲角色。只要開始一個新游戲,就要銷毀當前迷宮,並生成一個新的,再將角色放置在新迷宮中。首先創建一個游戲管理器,來處理這一流程。
創建一個新工程,放置一束默認的平行光,實現基本的光照。然后添加一個名為GameManager的C#腳本。資源應該按類型歸類,所以先新建一個腳步文件夾。之后,創建一個新的游戲物體,命名為Game Manager,並將剛才新建的腳本添加到新物體上。
GameManager腳本中,調用Start方法只是開始游戲。只要按下空格,就重新開始游戲。為了支持這一功能,需要在每次的update中檢測空格鍵是否被按下。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
using UnityEngine; using System.Collections;
public class GameManager : MonoBehaviour {
 
private void Start () {
BeginGame();
}
 
private void Update () {
if (Input.GetKeyDown(KeyCode.Space)) {
RestartGame();
}
}
 
private void BeginGame () {}
 
private void RestartGame () {}}

為了能開始游戲,我們需要先創建一個迷宮。所以添加一個Maze腳本,然后創建一個名為Maze的空游戲物體,並將腳本添加到游戲物體上。創建一個新的Prefabs文件夾,將剛才的游戲物體拖拽到其中,使它成為一個預制。完成這些之后,將游戲物體從層次面板中刪除。

[C#]  純文本查看  復制代碼
?
 
1
using UnityEngine; using System.Collections;
public class Maze : MonoBehaviour {}
<ignore_js_op style="word-wrap: break-word;">

現在可以在GameManager中添加對這個預制的引用,以便可以創建它的實例。添加一個公共變量來保存對預制的引用,在添加一個私有變量保存生成的預制實例。之后,可以在BeginGame中實例化預制,在重新開始游戲前的RestartGame中銷毀實例。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
public Maze mazePrefab;
 
private Maze mazeInstance;
 
private void BeginGame () {
mazeInstance = Instantiate(mazePrefab) as Maze;
}
 
private void RestartGame () {
Destroy(mazeInstance.gameObject);
BeginGame();
}

<ignore_js_op style="word-wrap: break-word;">

迷宮原理
現在游戲管理器已經可以工作了。當開始播放時,會創建一個迷宮實例,當按下空格鍵時會銷毀它並創建一個新的。現在是時候生成迷宮的內容了。
我們會用填充矩形網格的方式,創建一個簡單的迷宮。單元格的數量是可配置的,目前設置為20*20的。它們被存儲在一個二維數據中。創建一個新的MazeCell腳本來表示這些單元格。還需要創建一個單元格的預制,以便將來實例化。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
public int sizeX, sizeZ;
 
public MazeCell cellPrefab;
 
private MazeCell[,] cells;
 
using UnityEngine;
public class MazeCell : MonoBehaviour {}

迷宮的單元格需要三維顯示。創建一個新的游戲物體,命名為Maze Cell,並添加MazeCell腳本。然后,創建一個默認的quad物體,作為單元格的子物體,並將旋轉設置為 (90,0,0)。這樣我們就有了一個簡單的地塊,用來填充單元格區域。將整個物體轉換為一個預制,刪除層次面板中的實例,並在Maze腳本中引用它。
<ignore_js_op style="word-wrap: break-word;">

現在向Maze腳本中添加一個Generate方法,用來創建迷宮的內容。一開始先創建一個二維數組,通過雙重循環,將新的單元格預制填充到整個網格中。每個單元格的創建在它自己的方法中。實例化一個新單元格放入數組,然后給它取一個具有描述性的名字。將它設置為迷宮的子物體,並設置位置,以保證整個網格是居中的。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public void Generate () {
cells = new MazeCell[sizeX, sizeZ];
for ( int x = 0; x < sizeX; x++) {
for ( int z = 0; z < sizeZ; z++) {
CreateCell(x, z);
}
}
}
 
private void CreateCell ( int x, int z) {
MazeCell newCell = Instantiate(cellPrefab) as MazeCell;
cells[x, z] = newCell;
newCell.name = "Maze Cell " + x + ", " + z;
newCell.transform.parent = transform;
newCell.transform.localPosition = new Vector3(x - sizeX * 0.5f + 0.5f, 0f, z - sizeZ * 0.5f + 0.5f);
}

現在讓GameManager調用Generate,當開始播放時,迷宮就會出現。

[C#]  純文本查看  復制代碼
?
 
1
2
3
private void BeginGame () {
mazeInstance = Instantiate(mazePrefab) as Maze;
mazeInstance.Generate();
}

<ignore_js_op style="word-wrap: break-word;">

現在我們可以得到一個被填滿的網格,但是不能實時看見單元格的生成順序。讓生成過程慢下來我們就可以看見它是如何工作的,這很有用,也很有趣。將Generate改為協程就可以實現,在每一步之前加一點延時。我把延時設置為0.01秒,這就意味着,如果幀率夠高,生成20*20的單元格大約需要4秒。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
public float generationStepDelay;
 
public IEnumerator Generate () {
WaitForSeconds delay = new WaitForSeconds(generationStepDelay);
cells = new MazeCell[sizeX, sizeZ];
for ( int x = 0; x < sizeX; x++) {
for ( int z = 0; z < sizeZ; z++) {
yield return delay;
CreateCell(x, z);
}
}
}

現在必須要修改GameManager了,以便它能夠正確啟動協程。同樣,當游戲重新開始時,正確地停止協程也很重要,因為這可能導致生成過程還沒結束就被終止了。這里只用了一個協程,所以可以調用StopAllCoroutines來結束它。那么現在,在迷宮的生成過程中也可以按空格鍵了,那樣會立刻再生成一個新迷宮。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
private void BeginGame () {
mazeInstance = Instantiate(mazePrefab) as Maze;
StartCoroutine(mazeInstance.Generate());
}
 
private void RestartGame () {
StopAllCoroutines();
Destroy(mazeInstance.gameObject);
BeginGame();
}

<ignore_js_op style="word-wrap: break-word;">
<ignore_js_op style="word-wrap: break-word;">

單元格坐標和整型矢量
生成一個真實的迷宮,需要用隨機的方式向迷宮中添加單元格,而不是像之前那樣的雙重循環。我們需要用迷宮坐標來計算每一步位置。所有操作是在2D空間的,所以用兩個整數就可以表示一個位置了。如果能將坐標當做一個值來操作就更方便了,例如Vector2,不過不是浮點型的,而是整型的。不幸的是並沒有這種結構體,但是我們可以自己創建一個。
添加一個IntVector2腳本,用結構體而不是類。為它設置兩個整型的公共成員變量。這兩個整型變量會被當做一個值處理。還可以添加特定的構造方法,這樣就可以通過new IntVector2(1, 2)的形式定義值了。
[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
public struct IntVector2 {
 
public int x, z;
 
public IntVector2 ( int x, int z) {
this .x = x;
this .z = z;
}}

<ignore_js_op style="word-wrap: break-word;">
常用操作是將矢量添加到一個點上,可以為這種操作創建一個方法。但是如果只是通過+操作符來實現更方便。創建操作符方法可以達到這個目的,這也是Unity的矢量所支持的操作。讓兩個矢量相加意味着調用了一個方法。

現在添加對+操作符的支持。你也可以定義其他的操作符,但這里只需要加法。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
public static IntVector2 operator + (IntVector2 a, IntVector2 b)
{
a.x += b.x;
a.z += b.z;
return a;
}

現在可以用我們的整型矢量類型,定義MazeCell的坐標了。

[C#]  純文本查看  復制代碼
?
 
public IntVector2 coordinates;

在創建單元格是,IntVector2還可以用來控制Maze的大小,而不是用兩個單獨的整型。

[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public IntVector2 size;
 
public IEnumerator Generate () {
WaitForSeconds delay = new WaitForSeconds(generationStepDelay);
cells = new MazeCell[size.x, size.z];
for ( int x = 0; x < size.x; x++) {
for ( int z = 0; z < size.z; z++) {
yield return delay;
CreateCell( new IntVector2(x, z));
}
}
}
 
private void CreateCell (IntVector2 coordinates) {
MazeCell newCell = Instantiate(cellPrefab) as MazeCell;
cells[coordinates.x, coordinates.z] = newCell;
newCell.coordinates = coordinates;
newCell.name = "Maze Cell " + coordinates.x + ", " + coordinates.z;
newCell.transform.parent = transform;
newCell.transform.localPosition =
new Vector3(coordinates.x - size.x * 0.5f + 0.5f, 0f, coordinates.z - size.z * 0.5f + 0.5f);
}

不過這樣做還有些問題。迷宮的大小不能顯示在檢視面板中了。因為Unity不能保存自定義結構體。幸運的是,只要給IntVector2添加了System命名空間下的Serializable屬性,這個問題就迎刃而解。

[C#]  純文本查看  復制代碼
?
 
[System.Serializable] public struct IntVector2
<ignore_js_op style="word-wrap: break-word;">

隨機生成單元格
現在拋棄之前的雙重循環吧(它們只能生成矩形排列的單元格),在迷宮中隨機選擇一些坐標,然后從這些位置開始生成一行單元格,直到超出迷宮范圍。
[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
public IEnumerator Generate () {
WaitForSeconds delay = new WaitForSeconds(generationStepDelay);
cells = new MazeCell[size.x, size.z];
IntVector2 coordinates = RandomCoordinates;
while (ContainsCoordinates(coordinates)) {
yield return delay;
CreateCell(coordinates);
coordinates.z += 1;
}
}

為了完成上述工作,還需要向Maze添加一個RandomCoordinates屬性,用來處理內部的坐標。還需要添加一個ContainsCoordinates方法,用於檢測坐標是否在迷宮中。這個方法會被用於任何處理迷宮的地方,所以要設置為公共的。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
public IntVector2 RandomCoordinates {
get {
return new IntVector2(Random.Range(0, size.x), Random.Range(0, size.z));
}
}
 
public bool ContainsCoordinates (IntVector2 coordinate) {
return coordinate.x >= 0 && coordinate.x < size.x && coordinate.z >= 0 && coordinate.z < size.z;
}
<ignore_js_op style="word-wrap: break-word;">

但是我們並不想只在一條直線中行走,而是每一步超一個隨機方向移動。但是方向是從何而來的呢?創建一個MazeDirection枚舉類型來定義方向,包括東、西、南、北。將這部分代碼放在一個單獨的腳本文件中。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
using UnityEngine;
public enum MazeDirection {
North,
East,
South,
West}
<ignore_js_op style="word-wrap: break-word;">

限制可以方便地獲取隨機方向了。不過枚舉不是類或結構體,不能在其中定義方法或屬性。只能再添加一個靜態類,用於保存隨機屬性。這個類有多個版本,以名字區分,也放在MazeDirection腳本中。再添加一個常量計數器,這樣可以方便地獲取到有多少方向。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
public static class MazeDirections {
 
public const int Count = 4;
 
public static MazeDirection RandomValue {
get {
return (MazeDirection)Random.Range(0, Count);
}
}}

現在可以獲取隨機方向了,但是如何基於這個方向生成當前坐標呢?如果把方向轉換為整型矢量就很方便了。在MazeDirections中添加一個方法來實現這一步。這里用一個私有靜態數組簡化這步轉換操作。

[C#]  純文本查看  復制代碼
?
 
1
2
3
4
5
6
7
8
9
private static IntVector2[] vectors = {
new IntVector2(0, 1),
new IntVector2(1, 0),
new IntVector2(0, -1),
new IntVector2(-1, 0)
};
 
public static IntVector2 ToIntVector2 (MazeDirection direction) {
return vectors[( int )direction];
}

MazeDirections.ToIntVector2(someDirection)可以將任意方向轉換為一個整型矢量。這種方式看起來有點糟糕。如果是通過someDirection.ToIntVector2()的形式就更好了。好消息是,我們可以通過擴展方法實現它。只需要對ToIntVector2做一點小改變,它就可以向MazeDirection的實例方法一樣工作了。

[C#]  純文本查看  復制代碼
?
 
1
2
public static IntVector2 ToIntVector2 ( this MazeDirection direction) {
return vectors[( int )direction];
}

有了這些,讓Maze每步以隨機方向生成新單元格就很簡單了。我們必須保證每個單元格最多被訪問一次,所以需要添加一個以坐標檢索迷宮單元格的方法。
[C#]  純文本查看  復制代碼
?
 
01
02
03
04
05
06
07
08
09
10
11
12
13
public MazeCell GetCell (IntVector2 coordinates) {
return cells[coordinates.x, coordinates.z];
}
 
public IEnumerator Generate () {
WaitForSeconds delay = new WaitForSeconds(generationStepDelay);
cells = new MazeCell[size.x, size.z];
IntVector2 coordinates = RandomCoordinates;
while (ContainsCoordinates(coordinates) && GetCell(coordinates) == null ) {
yield return delay;
CreateCell(coordinates);
coordinates += MazeDirections.RandomValue.ToIntVector2();
}
}

<ignore_js_op style="word-wrap: break-word;">


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM