Unity 编写代码,生成随机洞穴(类似蜂巢)(2D、3D地图迷宫),平滑地图块,渲染地图。

参考官网教程:Procedural Cave Generation tutorial
完整Github工程:CaveGeneration

  跟着官方教程走了一遍,基本明白如何创建一个随机地图了。主要是算法的问题,如用广度优先获取区域(房间或墙)大小,用深度优先递归查找区域边界,还有计算两点之间经过结点的梯度变化。吐槽一下官方教程,教程中把结构和类全都放一起,不少方法耦合度很高,需要自己就优化了一下。


数据结构

结构、类名

说明

图释

Node

顶点。

Vector3 position;// 坐标int vertexIndex;// 索引号

unity 真实山脉模型刷树 unity山洞_unity 真实山脉模型刷树

ControlNode

继承于Node,地图位置的基本单位。

bool active; //是否有效(True为墙,False为洞)Node above,right; //上结点和右结点

unity 真实山脉模型刷树 unity山洞_unity_02

Square

包含八个方位的结点,渲染地图的基本单位。

ControlNode topLeft, topRight, bottomRight, bottomLeft; //四个角的点

Node centreTop, centreRight, centreBottom, centreLeft;//四边中点int configuration; //标志位,用于判断四个角哪些是激活状态的

unity 真实山脉模型刷树 unity山洞_地图_03

SquareGrid

地图集。如图8 x 8个ControlNode,包含7 x 7个Square(深绿色框框),亮绿色的是不需要用到的结点。

Square[,] squares; //包含小于ControlNode一行和一列的Square

unity 真实山脉模型刷树 unity山洞_unity 真实山脉模型刷树_04

Coord

包含xy两个坐标。

int tileX,tileY;

略。

Triangle

三角形。包含三个点的索引值。

int vertexIndexA,vertexIndexB,vertexIndexC;

略。

Room

房间。成员变量包含所有结点,边界结点们,直接相连的房间们,房间大小(结点个数),是否连接(直接或间接)到主房间,是否是主房间。主要方法:

SetAccessibleFromMainRoom() :如果本身可以连接到主房间,则使其与之相连的其他房间们都设置为可以连接到主房间(传递性)。

ConnectRooms(Room roomA, Room roomB) :连接AB两个房间,并根据房间属性修改相应的值。

略。


MapGenerator(地图生成器)

1. 产生随机的地图结点(RandomFillMap())。

 1.1. 根据给宽高还有填充百分比,随机分配洞或墙结点(就像二维码)。

2. 平滑结点们生成房间(SmoothMap())。

 2.1. 遍历每个结点,计算其周围8个结点为墙个数,等于4个时保持不变,大于一半则自己也变成墙,反之为洞。

3. 清除小的墙体、空洞(ProcessMap())。

 3.1. 先删掉小墙体,这样有些房间就会变大。

 3.2. 删掉小空洞,并且把没删掉的作为房间存起来,最后把房间最大的作为主房间。

 3.3. 获取区域的大小时用广度优先的方法来查找(GetRegionTiles(x,y))。

4. 清除后幸存房间相互连接(ConnectClosestRooms(survivingRooms))。

 4.1. 首先依次为每个房间(还没连接过任何房间的),通过每个房间边界(room.edgeTiles)找到距离最近的房间,并且连接(CreatePassage(bestRoomA, bestRoomB, bestTileA, bestTileB) //连接距离A最近的房间B,最近的两个点bestTileA和bestTileB)。 但不一定所有房间都能互相连通。

 4.2. 将所有房间连通到主房间,分两列,一列是能连通主房间的房间列表,另一列不连通主房间,同样方法,找到两个队列最近的房间和最近的点,相互连接。ConnectClosestRooms(allRooms, true)

 4.3. 通过上面一步还不一定就能连接完所有房间,需要继续递归调用ConnectClosestRooms(allRooms, true),知道最后找不到需要连接的房间。

5. 相互连接时,创建通道(CreatePassage(roomA, roomB, tileA, tileB))。

 5.1. 通过给的tileA和tileB获取一条线段(梯度变化的结点列表)(GetLine(tileA,tileB)),原理很简单,就是先看成直角坐标,计算两个点产生的直线,可以求出线的斜率(梯度),通过斜率可以计算出下一个移动位置。

 5.2. 根据算出来的线段(List<Coord>),已经给的通道宽度,给每个线段结点,以通道宽度为半径挖洞(DrawCircle(coord,passageWidth))。

6. 最后给地图加一层墙(外边框),避免有洞出来(CrateStaticBorder())。

7. 最最后就是把做好的地图丢给网格生成器(MeshGenerator),用于渲染还有碰撞检测。


生成一个简单的8x8随机地图。

unity 真实山脉模型刷树 unity山洞_地图_05


说明:


1. 黑色:

ControlNode.active == true

,墙体。


2. 白色:

ControlNode.active == false

,空洞(房间)。


3. 橙色:

ControlNode

,一个位置结点,包含上面和右边的蓝色结点。


4. 蓝色:

Node

,是橙色结点的子节点(

ControlNode.above, ControlNode.right

)。


5. 绿色:

Square

,包含四个橙色结点。共7x7个。


MeshGenerator(网格生成器)

1. 首先将每个Square(上图绿色部分)重新绘制(TriangulateSquare(squareGrid.squares[x, y]))成一系列三角形,以便于绘制网格。

 1.1. Square有一个成员变量configuration,就是标志位。用于标志周围四个ControlNode的状态(墙还是洞),如下。

一个Square含有8个主要方位结点(如图粉色)。

unity 真实山脉模型刷树 unity山洞_unity 真实山脉模型刷树_06

简化出来,看成一个绿色方框,四个角分别代表四个标志位如下图。

unity 真实山脉模型刷树 unity山洞_地图_07

实例

说明

划分三角形顺序(深至浅)

unity 真实山脉模型刷树 unity山洞_unity_08

四个角只有左下角是墙。

unity 真实山脉模型刷树 unity山洞_unity_09

unity 真实山脉模型刷树 unity山洞_地图_10

四个角下面两个是墙。

unity 真实山脉模型刷树 unity山洞_算法_11

unity 真实山脉模型刷树 unity山洞_unity 真实山脉模型刷树_12

左下角右上角是墙。

unity 真实山脉模型刷树 unity山洞_unity 真实山脉模型刷树_13

unity 真实山脉模型刷树 unity山洞_unity 真实山脉模型刷树_14

只有左上角是洞。

unity 真实山脉模型刷树 unity山洞_unity_15

unity 真实山脉模型刷树 unity山洞_地图_16

四个都是墙。

unity 真实山脉模型刷树 unity山洞_算法_17

 1.2. 划分出的三角形放入列表中(连续添加三个顶点索引),还有找到的结点们也放入列表中。需要注意的是,添加三角形顶点时要按顺时针依次添加,渲染原理:左手法则,顺时针后正面面向外部。

2. 然后把获得的结点们,和三角形们,添加到Cave.mesh中,就可以产生平滑的地图了(setCaveMesh(map.GetLength(0) * squareSize))。

 2.1. 根据上面8x8的地图,会产生如下图的平滑边框(橙色)。

unity 真实山脉模型刷树 unity山洞_unity 真实山脉模型刷树_18

 2.2. 去除自己渲染的Gizmos,就可以看到平滑的地图了。

unity 真实山脉模型刷树 unity山洞_结点_19

3. 计算出房间的边缘(CalculateMeshOutlines()),存到List<List<int>> outlines 中,及如果有多个房间独立开来的,那么这个变量意思就是存放每个房间的边缘,而每个边缘含有一系列结点索引。

 3.1. 遍历所有所有三角形顶点。通过遍历包含同一顶点的所有三角形(GetConnectedOutlineVertex(vertexIndex)),找到下一个能和其组成单面墙的顶点(其原理就是,判断这条边是否只被一个三角形占有,因为如果一条边同时被两个三角形占有时,说明他两边都是墙。)

 3.2. 如果通过上一步成功找到下一个边缘顶点,在添加到边缘列表(outlines)之后,那么根据这个新顶点继续找下一个边缘顶点(FollowOutline(newOutlineVertex, outlines.Count - 1))。

 3.3. 在找下一个顶点时,其实就是递归了(FollowOutline(nextVertexIndex, outlineIndex);),结束条件就是找不到下一个顶点了。

 3.4. 找出一条边缘后,要记得加上第一个顶点,使这个边缘线闭合。之后就可以找下一条边缘线了(回到3.1步骤)。

4. 可以添加一条最外边(AddBorderLine()),及整个地图的矩形外轮廓,原理和步骤3一样。

5. 如果是3D场景,则创建边缘有高度的墙网格(CreateWallMesh())。

 5.1. 遍历所有房间的外边缘(outlines),每两个点之间产生一片墙,创建方法如下图。

unity 真实山脉模型刷树 unity山洞_unity_20

说明:
白色顶点:上面两个顶点是外边缘连续两个顶点。通过加上高度,产生多两个白色顶点,一共四个白色顶点添加到墙顶点列表(wallVertices)中以用来绘制mesh。
红色,蓝绿色三角形:同之前划分三角形一个意思,用来组成mesh的三角形单位。

6. 如果是2D场景(需要把Cave Mesh和其他相关组件旋转270°(-90°)),则只需画出一条边界碰撞框就好了(Generate2DColliders())。

 6.1. 遍历一遍边缘顶点,转换成2D坐标,加到EdgeCollider2D就好了。


测试地图。

1. 3D场景

  创建一个Player(小球),还有个跟踪相机,丢到场景中。

unity 真实山脉模型刷树 unity山洞_结点_21

  监视板变量如下。

unity 真实山脉模型刷树 unity山洞_算法_22

  需要注意的是MeshCollider是单面,如果从背面看,是完全透明的,及如果小球在绿色墙体里面,是可以出来的,但是不能从外面正面穿过MeshCollider。同样,对光线来说其背面也是透明的,所以如果没有最外层的Mesh,光线可以直接穿过墙体。

2. 2D场景

  同样使用小球和跟踪相机测试。

unity 真实山脉模型刷树 unity山洞_算法_23

  监视板如下。

unity 真实山脉模型刷树 unity山洞_结点_24

  注意到下面创建有两个Edge Collider,是因为含有一层内部房间轮廓,还有最外面一圈矩形。