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