这两天跟着学了一种随机地图生成算法,使用DFS(准确来说应该叫洪泛算法flood fill)来生成一张确保连通性的随机障碍地图。
之所以说是随机障碍地图是因为该地图的随机性其实是体现在障碍物的随机性上,并不算是真正意义上的随机地图(个人理解中)。
首先,我们思考怎么生成一张随机地图。我们创建Tile(quad)作为地图瓦片预制体,创建obstacle(Cube)为障碍物。
我们选择将Tile连接在一起来组成地图,为了之后能精确的选择到Tile,并在其上创建障碍物,我们还需要一个队列将所有生成的Tile的坐标保存下来。(通过函数映射得到实际坐标,我们这里只用最简单的(0,0)这种表示方法)很显然,我们很快就能想到使用Random.Range函数去生成一个随机标号,然后在对应的位置生成一个障碍物。代码像下面这样:
这里的Coord是一个自定义结构体,记录X,Y的坐标(最后会有代码),newPos是通过这个坐标逆算回实际位置的函数。
但是这样很容易就能想到一个问题,因为它是完全随机给的标号,所以肯定会出现障碍物在同一位置生成的情况。从而浪费资源,也可能达不到预期效果。(比如你要生成10个障碍物,最终可能只生成9个)
因此,在此基础上,我们可以更进一步的考虑,使用一种不会重复得到标号的随机算法:
这里我们使用Knuth-Durstenfeld Shuffle 洗牌算法
这个算法的原理很简单,就是给定一个数组,随机得到一个标号,然后将这个标号上的位置置换到队列末尾。队列长度随之-1。详细细节可以看相关博主对这个算法的描述,不难。
这里我们新建一个工具类,在这里面声明静态方法,因为我希望它可以被所有脚本调用。
基本原理是,我们使用这个洗牌算法,打乱我们原本的坐标队列。然后每次需要用的时候,取出第一个,随后将它放入队列末尾。这样,既可以保证随机性,又可以保证绝对不会出现重复的元素。(除非你要建立的障碍物数量大于瓦片总数)
实现关键代码大致如下:
接着,也是比较重要的地方,我们的目标是设计一个完全联通的地图,但是这种算法只能保证随机生成的障碍物不会在同一个位置上。
我所学习到的方法是使用深度优先遍历,或者说该叫flood fill,即洪水填充算法来判断一个障碍物该不该在这个位置上生成。
什么意思呢?
比如你现在要生成一个障碍物,就会先调用一次DFS算法,判定在这个位置上生成会不会影响连通性。如果不会,则生成。如果会,则跳过生成。
DFS算法关键代码:
private bool MapIsFullyAccessible(bool[,] _mapObstacles,int _currentObsCount)
{
bool[,] mapFlags = new bool[_mapObstacles.GetLength(0),_mapObstacles.GetLength(1)];
Queue<Coord> queue = new Queue<Coord>();//所有可行走区域瓦片都会放在这个队列里面
queue.Enqueue(mapCenter);//首先放入中心点
mapFlags[mapCenter.x,mapCenter.y] = true;//中心点标记为【已检测】
int accessibleCount = 1;//可行走的瓦片数量
while(queue.Count>0)//使用队列来循坏
{
Coord currentTile = queue.Dequeue();//当前瓦片信息
//遍历当前瓦片的四个方向
for(int x = -1; x<=1 ;x++)
{
for(int y = -1; y<=1 ;y++)
{
int neighborX = currentTile.x + x;
int neighborY = currentTile.y + y;
if(x==0||y==0)//选择四领域遍历(其实可以将方向写成pair,然后用一个for循环来选择写法上好看很多)
{
//边界判断
if(neighborX >=0 && neighborX<_mapObstacles.GetLength(0)
&& neighborY >=0 && neighborY<_mapObstacles.GetLength(1))
{
//保证相邻点没有检测到,且此处没有障碍物
if(!mapFlags[neighborX,neighborY] && !_mapObstacles[neighborX,neighborY])
{
mapFlags[neighborX,neighborY] = true;
accessibleCount++;
queue.Enqueue(new Coord(neighborX,neighborY));
}
}
}
}
}
}
int obsTargetCount = (int)(mapSize.x*mapSize.y - _currentObsCount);//当前可行走的
return accessibleCount == obsTargetCount;
}
整个脚本的完整代码如下:(细节部分,比如颜色渐变,障碍物高度随机这些就先不讲解了,之后会有项目完整代码上传)
MapGenerator:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MapGenerator : MonoBehaviour
{
[Header("Generate Tile Map")]//使用Header特性组织监视面板
public GameObject tilePrefab;//瓦片预制体
public Vector2 mapSize;//地图的X和Y
public Transform mapHolder;//所有瓦片的统一管理对象
[Range(0,1)]public float outlinePercent;//瓦片间的空隙
[Header("Generate Random Obstacles")]
public GameObject obsPrefab;//障碍物预制体
// public float obsCount;//障碍物数量
public List<Coord> allTilesCoord = new List<Coord>();//存储所有位置,一定要记得初始化集合
private Queue<Coord> shuffledQueue;//私有辅助混排队列
[Header("Paint Colorful")]
public Color foregroundColor,backgroundColor;//障碍物渐变色
public float minObsHeight,maxObsHeight;//最小和最大高度
[Header("Map Fully Accessible")]
[Range(0,1)] public float obsPercent;//障碍物占有率
private Coord mapCenter;//默认中心点是没有障碍物的,相当于玩家复活点和洪水填充算法的起始点
bool[,] mapObstacles;//判断任意一个坐标上是否有障碍物
//如果(2,1)有障碍物,则mapObstacles[2,1] = false;
[Header("NavMesh Agent")]
public Vector2 mapMaxSize;
public GameObject navMeshObs;//空气墙
void Start()
{
GenerateMap();
}
// Update is called once per frame
void Update()
{
}
private void GenerateMap()
{
//地图生成部分
for (int i = 0; i < mapSize.x; i++)
{
for (int j = 0; j < mapSize.y; j++)
{
//让地图从中心开始生成而不是左下角,坐标不变
Vector3 newPos = new Vector3(-mapSize.x/2 + 0.5f + i,0,-mapSize.y/2 + 0.5f + j);
GameObject spawnTile = Instantiate(tilePrefab,newPos,Quaternion.Euler(90,0,0));
spawnTile.transform.SetParent(mapHolder);
spawnTile.transform.localScale *= (1-outlinePercent);//通过缩放制造间隙
allTilesCoord.Add(new Coord(i,j));
}
}
//障碍物生成部分
// for(int i=0;i<obsCount;i++)
// {
// //使用random生成方法会让随机数重复产生,虽然可以使用哈希表之类的方法来限制,重随,但是在极端情况会产生较大的时间复杂度
// //因此,使用洗牌算法打乱顺序用队列生成是一个比较好的做法
// Coord randomCoord = allTilesCoord[UnityEngine.Random.Range(0,allTilesCoord.Count)];
// //与地图一样,取得生成位置
// Vector3 newPos = new Vector3(-mapSize.x/2 + 0.5f + randomCoord.x,0,-mapSize.y/2 + 0.5f + randomCoord.y);
// GameObject spawnObs = Instantiate(obsPrefab,newPos,Quaternion.identity);
// spawnObs.transform.SetParent(mapHolder);
// spawnObs.transform.localScale *= (1-outlinePercent);
// }
shuffledQueue = new Queue<Coord>(Utilities.ShuffleCoords(allTilesCoord.ToArray()));
int obsCount =(int)(mapSize.x * mapSize.y * obsPercent);
mapCenter = new Coord((int)mapSize.x/2,(int)mapSize.y/2);//计算得到随机地图的中心
mapObstacles = new bool[(int)mapSize.x,(int)mapSize.y];//创建地图的bool二维矩阵
int currentObsCount = 0;//场景当前已经创建的障碍物数量
for(int i=0;i<obsCount;i++)
{
//使用random生成方法会让随机数重复产生,虽然可以使用哈希表之类的方法来限制,重随,但是在极端情况会产生较大的时间复杂度
//因此,使用洗牌算法打乱顺序用队列生成是一个比较好的做法
Coord randomCoord = GetRandomCoord();
mapObstacles[randomCoord.x,randomCoord.y] = true;
currentObsCount++;
//并不是所有的障碍物都能够随机生成
if(randomCoord != mapCenter && MapIsFullyAccessible(mapObstacles,currentObsCount))
{
float obsHeight = UnityEngine.Random.Range(minObsHeight,maxObsHeight);//随机生成高度
// float obsHeight = Mathf.Lerp(minObsHeight,maxObsHeight,UnityEngine.Random.Range(0f,1f));//这种方法生成一定要加f,不然会调用int类型的方法
Vector3 newPos = new Vector3(-mapSize.x/2+0.5f+randomCoord.x,obsHeight/2,-mapSize.y/2 + 0.5f + randomCoord.y);
GameObject spawnObs = Instantiate(obsPrefab,newPos,Quaternion.identity);
spawnObs.transform.SetParent(mapHolder);
// spawnObs.transform.localScale *= (1 - outlinePercent);
spawnObs.transform.localScale = new Vector3(1-outlinePercent,obsHeight,1-outlinePercent);
#region changeColor
MeshRenderer meshRenderer = spawnObs.GetComponent<MeshRenderer>();
Material material = meshRenderer.material;
float colorPercent = randomCoord.y / mapSize.y;
material.color = Color.Lerp(foregroundColor,backgroundColor,colorPercent);//通过插值进行颜色渐变
meshRenderer.material = material;
#endregion
}else{
mapObstacles[randomCoord.x,randomCoord.y] = false;//还原状态
currentObsCount--;
}
}
//空气墙生成部分,按UP-DOWN-LEFT-RIGHT生成
GameObject navMeshObsForward = Instantiate(navMeshObs,Vector3.forward*(mapMaxSize.y+mapSize.y)/4,Quaternion.identity);
navMeshObsForward.transform.localScale = new Vector3(mapSize.x,5,(mapMaxSize.y/2-mapSize.y/2));
GameObject navMeshObsBack = Instantiate(navMeshObs,Vector3.back*(mapMaxSize.y+mapSize.y)/4,Quaternion.identity);
navMeshObsBack.transform.localScale = new Vector3(mapSize.x,5,(mapMaxSize.y/2-mapSize.y/2));
GameObject navMeshObsLeft = Instantiate(navMeshObs,Vector3.left*(mapMaxSize.x+mapSize.x)/4,Quaternion.identity);
navMeshObsLeft.transform.localScale = new Vector3((mapMaxSize.x/2-mapSize.x/2),5,mapSize.y);
GameObject navMeshObsRight = Instantiate(navMeshObs,Vector3.right*(mapMaxSize.x+mapSize.x)/4,Quaternion.identity);
navMeshObsRight.transform.localScale = new Vector3((mapMaxSize.x/2-mapSize.x/2),5,mapSize.y);
}
private Coord GetRandomCoord()
{
Coord randomCoord = shuffledQueue.Dequeue();//队列:先进先出
shuffledQueue.Enqueue(randomCoord);//将移出的元素放在队列的最后一个,保证队列完整性,大小不变
return randomCoord;
}
private bool MapIsFullyAccessible(bool[,] _mapObstacles,int _currentObsCount)
{
bool[,] mapFlags = new bool[_mapObstacles.GetLength(0),_mapObstacles.GetLength(1)];
Queue<Coord> queue = new Queue<Coord>();//所有可行走区域瓦片都会放在这个队列里面
queue.Enqueue(mapCenter);//首先放入中心点
mapFlags[mapCenter.x,mapCenter.y] = true;//中心点标记为【已检测】
int accessibleCount = 1;//可行走的瓦片数量
while(queue.Count>0)//使用队列来循坏
{
Coord currentTile = queue.Dequeue();//当前瓦片信息
//遍历当前瓦片的四个方向
for(int x = -1; x<=1 ;x++)
{
for(int y = -1; y<=1 ;y++)
{
int neighborX = currentTile.x + x;
int neighborY = currentTile.y + y;
if(x==0||y==0)//选择四领域遍历(其实可以将方向写成pair,然后用一个for循环来选择写法上好看很多)
{
//边界判断
if(neighborX >=0 && neighborX<_mapObstacles.GetLength(0)
&& neighborY >=0 && neighborY<_mapObstacles.GetLength(1))
{
//保证相邻点没有检测到,且此处没有障碍物
if(!mapFlags[neighborX,neighborY] && !_mapObstacles[neighborX,neighborY])
{
mapFlags[neighborX,neighborY] = true;
accessibleCount++;
queue.Enqueue(new Coord(neighborX,neighborY));
}
}
}
}
}
}
int obsTargetCount = (int)(mapSize.x*mapSize.y - _currentObsCount);//当前可行走的
return accessibleCount == obsTargetCount;
}
}
//注意这里为了让Utilities得到,是放在类之外的
[System.Serializable]//序列化让它显示出来
public struct Coord//存储瓦片具体位置的结构体
{
public int x;
public int y;
public Coord(int _x,int _y)
{
this.x = _x;
this.y = _y;
}
public static bool operator !=(Coord _c1,Coord _c2)
{
return !(_c1==_c2);//这个实现很有意思
}
public static bool operator ==(Coord _c1,Coord _c2)
{
return (_c1.x == _c2.x) && (_c1.y == _c2.y);
}
}
//补充点关于c#重载的小知识:
//1.c#不允许重载 = ,但是可以重载+=,-=(C++里面这些运算符不属于一类)
//2.c#要求成对的重载比较运算符,并且必须返回布尔类型值。比如重载了!=就必须重载==
//3.在C#里(C++也一样),运算符重载的本质就是函数重载
Utilies:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Utilities : MonoBehaviour
{
void Start() {
}
public static Coord[] ShuffleCoords(Coord[] _dataArray)
{
for(int i = 0;i<_dataArray.Length;i++)
{
int randomNum = Random.Range(i,_dataArray.Length);
//SWAP交换思路实现洗牌算法
Coord temp = _dataArray[randomNum];
_dataArray[randomNum] = _dataArray[i];
_dataArray[i] = temp;
}
return _dataArray;
}
}
另外,我将我的地图生成了一个NAV Mesh,障碍物使用了Nav obstacle组件,方便之后做一个导航功能。脚本中的空气强只需要一个空物体挂上Nav obstacle组件即可。
PS:
虽然这种方法可以完全保证生成的地图随机且联通,但是有一个我觉得很大的问题。时间复杂度过大了。如果地图很大,这种方式是完全不可以接受的。DFS是一种我觉得很消耗时间的算法,更别提每次生成一个障碍物都需要判定整个地图一次。
我现在能自己想到的还有一种方法是反过来做。先生成全是障碍物的地图,然后从中心点随机生长(随机选择邻接区域来生成),慢慢打通整条路径。有时间的话我会试试自己做这个的。