在本教程中,我们会生成一个多区域迷宫,并在其中游览。你会学会以下内容
-用迷宫生成算法填充一块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脚本开始出加上
我们打算用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;">
<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;">
