可以与图块卡一起使用的过程模式

程序生成用于增加游戏的可变性。 著名的项目包括MinecraftEnter the GungeonDescenders 。 在本文中,我将解释与Tilemap系统一起使用时可以使用的一些算法,该算法在Unity 2017.2和RuleTile中以2D函数的形式出现。

随着地图的程序创建,每个传递的游戏将是唯一的。 您甚至可以在组装游戏后,使用各种输入数据(例如,时间或玩家的当前级别)来动态更改内容。

这个帖子是关于什么的?


我们将介绍一些创建过程世界的最常见方法,以及我创建的几种变体。 这是阅读本文后可以创建的示例。 三种算法共同使用TilemapRuleTile创建地图:


在使用任何算法生成地图的过程中,我们都会获得一个包含所有新数据的int数组。 您可以继续修改此数据或将其渲染到图块地图。

在进一步阅读之前,很高兴了解以下内容:

  1. 我们区分什么是图块,哪些不使用二进制值。 1是图块,0是不存在。
  2. 我们将所有卡存储在每个函数末尾返回给用户的二维整数数组中(执行渲染的函数除外)。
  3. 我将使用GetUpperBound()数组函数获取每个地图的高度和宽度,以便该函数接收较少的变量,并且代码更简洁
  4. 我经常使用Mathf.FloorToInt() ,因为Tilemap坐标系从左下角开始,而Mathf.FloorToInt()允许您将数字四舍五入为整数。
  5. 这篇文章中的所有代码都是用C#编写的。

数组生成


GenerateArray创建给定大小的新int数组。 我们还可以指示该数组是应填充还是为空(1或0)。 这是代码:

 public static int[,] GenerateArray(int width, int height, bool empty) { int[,] map = new int[width, height]; for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) { if (empty) { map[x, y] = 0; } else { map[x, y] = 1; } } } return map; } 

地图渲染


此功能用于在平铺地图上渲染地图。 我们围绕地图的宽度和高度循环,仅在测试点处的数组的值为1时才放置图块。

 public static void RenderMap(int[,] map, Tilemap tilemap, TileBase tile) { //Clear the map (ensures we dont overlap) tilemap.ClearAllTiles(); //Loop through the width of the map for (int x = 0; x < map.GetUpperBound(0) ; x++) { //Loop through the height of the map for (int y = 0; y < map.GetUpperBound(1); y++) { // 1 = tile, 0 = no tile if (map[x, y] == 1) { tilemap.SetTile(new Vector3Int(x, y, 0), tile); } } } } 

地图更新


此功能仅用于更新地图,而不用于重新渲染。 因此,我们可以使用更少的资源,而无需重新绘制每个图块及其图块数据。

 public static void UpdateMap(int[,] map, Tilemap tilemap) //Takes in our map and tilemap, setting null tiles where needed { for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) { //We are only going to update the map, rather than rendering again //This is because it uses less resources to update tiles to null //As opposed to re-drawing every single tile (and collision data) if (map[x, y] == 0) { tilemap.SetTile(new Vector3Int(x, y, 0), null); } } } } 

噪音珀林


佩林噪声可用于各种目的。 首先,我们可以使用它来创建地图的顶层。 为此,只需使用当前位置x和种子获得一个新点。

简单的解决方案


这种生成方法使用了电平生成中最简单的实现Perlin噪声的形式。 我们可以将Unity函数用于消除Perlin噪声,因此我们不必自己编写代码。 使用Mathf.FloorToInt()函数,我们还将仅使用整数作为图块映射。

 public static int[,] PerlinNoise(int[,] map, float seed) { int newPoint; //Used to reduced the position of the Perlin point float reduction = 0.5f; //Create the Perlin for (int x = 0; x < map.GetUpperBound(0); x++) { newPoint = Mathf.FloorToInt((Mathf.PerlinNoise(x, seed) - reduction) * map.GetUpperBound(1)); //Make sure the noise starts near the halfway point of the height newPoint += (map.GetUpperBound(1) / 2); for (int y = newPoint; y >= 0; y--) { map[x, y] = 1; } } return map; } 

呈现为图块地图后的外观如下:


平滑处理


您也可以使用此功能并使其平滑。 设置固定Perlin高度的时间间隔,然后在这些点之间执行平滑处理。 事实证明,此函数会稍微复杂一点,因为对于间隔,您需要考虑整数值列表。

 public static int[,] PerlinNoiseSmooth(int[,] map, float seed, int interval) { //Smooth the noise and store it in the int array if (interval > 1) { int newPoint, points; //Used to reduced the position of the Perlin point float reduction = 0.5f; //Used in the smoothing process Vector2Int currentPos, lastPos; //The corresponding points of the smoothing. One list for x and one for y List<int> noiseX = new List<int>(); List<int> noiseY = new List<int>(); //Generate the noise for (int x = 0; x < map.GetUpperBound(0); x += interval) { newPoint = Mathf.FloorToInt((Mathf.PerlinNoise(x, (seed * reduction))) * map.GetUpperBound(1)); noiseY.Add(newPoint); noiseX.Add(x); } points = noiseY.Count; 

在此功能的第一部分中,我们首先检查间隔是否大于一个。 如果是这样,则产生噪音。 生成间隔执行,以便可以应用平滑。 函数的下一部分是平滑点。

 //Start at 1 so we have a previous position already for (int i = 1; i < points; i++) { //Get the current position currentPos = new Vector2Int(noiseX[i], noiseY[i]); //Also get the last position lastPos = new Vector2Int(noiseX[i - 1], noiseY[i - 1]); //Find the difference between the two Vector2 diff = currentPos - lastPos; //Set up what the height change value will be float heightChange = diff.y / interval; //Determine the current height float currHeight = lastPos.y; //Work our way through from the last x to the current x for (int x = lastPos.x; x < currentPos.x; x++) { for (int y = Mathf.FloorToInt(currHeight); y > 0; y--) { map[x, y] = 1; } currHeight += heightChange; } } } 

平滑执行如下:

  1. 我们得到当前和最后的位置
  2. 我们得到两点之间的差,我们需要的最重要的信息是沿y轴的差
  3. 然后,我们确定需要多少变化才能达到目标,这是通过将y的差除以区间变量来完成的。
  4. 接下来,我们开始设置位置,一直到零
  5. 当我们在y轴上达到0时,将高度变化添加到当前高度,然后对下一个x位置重复该过程
  6. 在最后位置和当前位置之间的每个位置结束时,我们移至下一个点

如果间隔小于一个,那么我们仅使用前一个函数,它将为我们完成所有工作。

  else { //Defaults to a normal Perlin gen map = PerlinNoise(map, seed); } return map; 

让我们看一下渲染:


随机漫步


随机步行上衣


该算法执行硬币翻转。 我们可以得到两个结果之一。 如果结果是“ eagle”,那么我们将块向上移动;如果结果是“ tails”,那么我们将块向下移动。 通过不断向上或向下移动来创建高度。 这种算法的唯一缺点是其非常明显的块状性。 让我们看看它是如何工作的。

 public static int[,] RandomWalkTop(int[,] map, float seed) { //Seed our random System.Random rand = new System.Random(seed.GetHashCode()); //Set our starting height int lastHeight = Random.Range(0, map.GetUpperBound(1)); //Cycle through our width for (int x = 0; x < map.GetUpperBound(0); x++) { //Flip a coin int nextMove = rand.Next(2); //If heads, and we aren't near the bottom, minus some height if (nextMove == 0 && lastHeight > 2) { lastHeight--; } //If tails, and we aren't near the top, add some height else if (nextMove == 1 && lastHeight < map.GetUpperBound(1) - 2) { lastHeight++; } //Circle through from the lastheight to the bottom for (int y = lastHeight; y >= 0; y--) { map[x, y] = 1; } } //Return the map return map; } 


带有抗锯齿的随机步行上衣

与Perlin噪声生成相比,这种生成使我们的高度更平滑。

与以前的版本相比,Random Walk的这种变化提供了更加平滑的结果。 我们可以通过在函数中再添加两个变量来实现它:

  • 第一个变量用于确定维持当前高度需要多长时间。 它是整数,并在高度变化时重置
  • 第二个变量输入到函数中,并用作高度的最小截面宽度。 当我们看功能时,它将变得更加清晰。

现在我们知道要添加什么。 让我们看一下函数:

 public static int[,] RandomWalkTopSmoothed(int[,] map, float seed, int minSectionWidth) { //Seed our random System.Random rand = new System.Random(seed.GetHashCode()); //Determine the start position int lastHeight = Random.Range(0, map.GetUpperBound(1)); //Used to determine which direction to go int nextMove = 0; //Used to keep track of the current sections width int sectionWidth = 0; //Work through the array width for (int x = 0; x <= map.GetUpperBound(0); x++) { //Determine the next move nextMove = rand.Next(2); //Only change the height if we have used the current height more than the minimum required section width if (nextMove == 0 && lastHeight > 0 && sectionWidth > minSectionWidth) { lastHeight--; sectionWidth = 0; } else if (nextMove == 1 && lastHeight < map.GetUpperBound(1) && sectionWidth > minSectionWidth) { lastHeight++; sectionWidth = 0; } //Increment the section width sectionWidth++; //Work our way from the height down to 0 for (int y = lastHeight; y >= 0; y--) { map[x, y] = 1; } } //Return the modified map return map; } 

如下图所示,通过平滑随机游走算法,您可以在该水平上获得漂亮的平坦段。


结论


我希望本文能激发您在项目中使用过程生成。 如果您想了解有关过程生成地图的更多信息,请浏览过程生成WikiRoguebasin.com的优秀资源。

在本文的第二部分,我们将使用过程生成来创建洞穴系统。

第二部分


我们在本部分中讨论的所有内容都可以在该项目中找到。 您可以下载资产并尝试自己的过程算法。


噪音珀林


在上一部分中,我们研究了应用Perlin噪声创建顶层的方法。 幸运的是,Perlin的噪音也可以用来创建洞穴。 这是通过计算新的Perlin噪声值来实现的,该值接收当前位置的参数乘以修饰符。 修改器的值介于0到1之间。修改器值越高,Perlin生成越混乱。 然后,我们将此值舍入为整数(0或1),并将其存储在map数组中。 查看如何实现:

 public static int[,] PerlinNoiseCave(int[,] map, float modifier, bool edgesAreWalls) { int newPoint; for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) { if (edgesAreWalls && (x == 0 || y == 0 || x == map.GetUpperBound(0) - 1 || y == map.GetUpperBound(1) - 1)) { map[x, y] = 1; //Keep the edges as walls } else { //Generate a new point using Perlin noise, then round it to a value of either 0 or 1 newPoint = Mathf.RoundToInt(Mathf.PerlinNoise(x * modifier, y * modifier)); map[x, y] = newPoint; } } } return map; } 

我们使用修饰符而不是种子,因为当乘以0到0.5的数字时,Perlin生成的结果看起来更好。 值越低,结果将越块状。 看一下样本结果。 Gif以0.01的修改器值开始,并逐渐达到0.25的值。


从此gif可以看出,每次增加的Perlin生成只会增加模式。

随机漫步


在上一部分中,我们看到您可以使用抛硬币来确定平台将在哪个位置向上或向下移动。 在这一部分中,我们将使用相同的想法,但是
左移和右移两个附加选项。 随机游走算法的这种变化使我们能够创建洞穴。 为此,我们选择一个随机方向,然后移动我们的位置并删除图块。 我们继续进行此过程,直到达到需要销毁的所需瓷砖数量。 到目前为止,我们仅使用4个方向:上,下,左,右。

 public static int[,] RandomWalkCave(int[,] map, float seed, int requiredFloorPercent) { //Seed our random System.Random rand = new System.Random(seed.GetHashCode()); //Define our start x position int floorX = rand.Next(1, map.GetUpperBound(0) - 1); //Define our start y position int floorY = rand.Next(1, map.GetUpperBound(1) - 1); //Determine our required floorAmount int reqFloorAmount = ((map.GetUpperBound(1) * map.GetUpperBound(0)) * requiredFloorPercent) / 100; //Used for our while loop, when this reaches our reqFloorAmount we will stop tunneling int floorCount = 0; //Set our start position to not be a tile (0 = no tile, 1 = tile) map[floorX, floorY] = 0; //Increase our floor count floorCount++; 

该函数从以下内容开始:

  1. 找到起始位置
  2. 计算要删除的地砖数量。
  3. 在起始位置删除磁贴
  4. 将图块数加一。

然后我们进入while 。 他将创建一个洞穴:

 while (floorCount < reqFloorAmount) { //Determine our next direction int randDir = rand.Next(4); switch (randDir) { //Up case 0: //Ensure that the edges are still tiles if ((floorY + 1) < map.GetUpperBound(1) - 1) { //Move the y up one floorY++; //Check if that piece is currently still a tile if (map[floorX, floorY] == 1) { //Change it to not a tile map[floorX, floorY] = 0; //Increase floor count floorCount++; } } break; //Down case 1: //Ensure that the edges are still tiles if ((floorY - 1) > 1) { //Move the y down one floorY--; //Check if that piece is currently still a tile if (map[floorX, floorY] == 1) { //Change it to not a tile map[floorX, floorY] = 0; //Increase the floor count floorCount++; } } break; //Right case 2: //Ensure that the edges are still tiles if ((floorX + 1) < map.GetUpperBound(0) - 1) { //Move the x to the right floorX++; //Check if that piece is currently still a tile if (map[floorX, floorY] == 1) { //Change it to not a tile map[floorX, floorY] = 0; //Increase the floor count floorCount++; } } break; //Left case 3: //Ensure that the edges are still tiles if ((floorX - 1) > 1) { //Move the x to the left floorX--; //Check if that piece is currently still a tile if (map[floorX, floorY] == 1) { //Change it to not a tile map[floorX, floorY] = 0; //Increase the floor count floorCount++; } } break; } } //Return the updated map return map; } 

我们在这里做什么?


好吧,首先,借助随机数,我们选择要移动的方向。 然后,使用switch case检查新方向。 在此语句中,我们检查位置是否为墙。 如果不是,则从数组中删除带有图块的元素。 我们会继续这样做,直到达到所需的地板面积为止。 结果如下所示:


我还创建了该函数的自己的版本,其中还包括对角线方向。 该功能代码很长,因此,如果要查看它,请从本文此部分开头的链接下载该项目。

定向隧道


定向隧道始于地图的一个边缘,然后到达相对的边缘。 我们可以通过将隧道的曲率和粗糙度传递给输入函数来控制它们。 我们还可以设置隧道各部分的最小和最大长度。 让我们看一下实现:

 public static int[,] DirectionalTunnel(int[,] map, int minPathWidth, int maxPathWidth, int maxPathChange, int roughness, int curvyness) { //This value goes from its minus counterpart to its positive value, in this case with a width value of 1, the width of the tunnel is 3 int tunnelWidth = 1; //Set the start X position to the center of the tunnel int x = map.GetUpperBound(0) / 2; //Set up our random with the seed System.Random rand = new System.Random(Time.time.GetHashCode()); //Create the first part of the tunnel for (int i = -tunnelWidth; i <= tunnelWidth; i++) { map[x + i, 0] = 0; } 

这是怎么回事?


首先,我们设置宽度值。 宽度值将从负数变为正数。 因此,我们将获得所需的尺寸。 在这种情况下,我们使用值1,这将使我们的总宽度为3,因为我们使用值-1、0、1。

接下来,我们在x中设置初始位置,为此,我们采用了地图宽度的中间位置。 之后,我们可以在地图的第一部分中铺设一条隧道。


现在,让我们进入地图的其余部分。

  //Cycle through the array for (int y = 1; y < map.GetUpperBound(1); y++) { //Check if we can change the roughness if (rand.Next(0, 100) > roughness) { //Get the amount we will change for the width int widthChange = Random.Range(-maxPathWidth, maxPathWidth); //Add it to our tunnel width value tunnelWidth += widthChange; //Check to see we arent making the path too small if (tunnelWidth < minPathWidth) { tunnelWidth = minPathWidth; } //Check that the path width isnt over our maximum if (tunnelWidth > maxPathWidth) { tunnelWidth = maxPathWidth; } } //Check if we can change the curve if (rand.Next(0, 100) > curvyness) { //Get the amount we will change for the x position int xChange = Random.Range(-maxPathChange, maxPathChange); //Add it to our x value x += xChange; //Check we arent too close to the left side of the map if (x < maxPathWidth) { x = maxPathWidth; } //Check we arent too close to the right side of the map if (x > (map.GetUpperBound(0) - maxPathWidth)) { x = map.GetUpperBound(0) - maxPathWidth; } } //Work through the width of the tunnel for (int i = -tunnelWidth; i <= tunnelWidth; i++) { map[x + i, y] = 0; } } return map; } 

我们生成一个随机数与粗糙度值进行比较,如果粗糙度值大于该值,则可以更改路径宽度。 我们还检查该值,以免宽度太小。 在代码的下一部分,我们将逐步浏览地图。 在每个阶段,发生以下情况:

  1. 与曲率值相比,我们生成了一个新的随机数。 与之前的测试一样,如果该值大于该值,则我们更改路径的中心点。 我们还会进行检查,以免超出地图范围。
  2. 最后,我们在新创建的零件中铺设了一条隧道。

该实现的结果如下所示:


细胞自动机


元胞自动机使用相邻单元格来确定当前单元格是打开(1)还是关闭(0)。 用于确定相邻小区的基础是基于随机生成的小区网格创建的。 我们将使用C# Random.Next函数生成此源网格。

由于我们有几种细胞自动机的不同实现,因此我编写了一个单独的函数来生成此基本网格。 该函数如下所示:

 public static int[,] GenerateCellularAutomata(int width, int height, float seed, int fillPercent, bool edgesAreWalls) { //Seed our random number generator System.Random rand = new System.Random(seed.GetHashCode()); //Initialise the map int[,] map = new int[width, height]; for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) { //If we have the edges set to be walls, ensure the cell is set to on (1) if (edgesAreWalls && (x == 0 || x == map.GetUpperBound(0) - 1 || y == 0 || y == map.GetUpperBound(1) - 1)) { map[x, y] = 1; } else { //Randomly generate the grid map[x, y] = (rand.Next(0, 100) < fillPercent) ? 1 : 0; } } } return map; } 

在此功能中,您还可以设置我们的网格是否需要墙。 在所有其他方面,这非常简单。 我们检查一个具有百分比填充的随机数,以确定是否启用了当前单元格。 看一下结果:


摩尔附近


摩尔邻域用于平滑细胞自动机的初始生成。 摩尔附近看起来像这样:


以下规则适用于邻居:

  • 我们在每个方向上检查邻居。
  • 如果邻居是活动图块,则将其添加到周围图块的数量中。
  • 如果邻居是不活动的图块,那么我们什么也不做。
  • 如果一个单元具有4个以上的周围图块,则使该单元处于活动状态。
  • 如果该单元格恰好有4个周围的图块,那么我们将对其不执行任何操作。
  • 重复进行,直到我们检查每个地图图块。

摩尔的邻域检查功能如下:

 static int GetMooreSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls) { /* Moore Neighbourhood looks like this ('T' is our tile, 'N' is our neighbours) * * NNN * NTN * NNN * */ int tileCount = 0; for(int neighbourX = x - 1; neighbourX <= x + 1; neighbourX++) { for(int neighbourY = y - 1; neighbourY <= y + 1; neighbourY++) { if (neighbourX >= 0 && neighbourX < map.GetUpperBound(0) && neighbourY >= 0 && neighbourY < map.GetUpperBound(1)) { //We don't want to count the tile we are checking the surroundings of if(neighbourX != x || neighbourY != y) { tileCount += map[neighbourX, neighbourY]; } } } } return tileCount; } 

检查图块后,我们在平滑功能中使用此信息。 在这里,就像生成细胞自动机一样,人们可以指出地图的边缘是否应该是墙壁。

 public static int[,] SmoothMooreCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount) { for (int i = 0; i < smoothCount; i++) { for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) { int surroundingTiles = GetMooreSurroundingTiles(map, x, y, edgesAreWalls); if (edgesAreWalls && (x == 0 || x == (map.GetUpperBound(0) - 1) || y == 0 || y == (map.GetUpperBound(1) - 1))) { //Set the edge to be a wall if we have edgesAreWalls to be true map[x, y] = 1; } //The default moore rule requires more than 4 neighbours else if (surroundingTiles > 4) { map[x, y] = 1; } else if (surroundingTiles < 4) { map[x, y] = 0; } } } } //Return the modified map return map; } 

在此必须注意,该函数具有for循环,该循环执行平滑指定的次数。 因此,可以获得更漂亮的卡片。


例如,如果它们之间只有两个块,我们总是可以通过连接房间来修改此算法。

冯·诺依曼社区


冯·诺伊曼附近是实现细胞自动机的另一种流行方法。 对于这样的一代,我们使用比摩尔一代更简单的邻域。 邻居看起来像这样:


以下规则适用于邻居:

  • 我们检查图块的直接邻居,而不考虑对角线的邻居。
  • 如果单元格处于活动状态,则将数量加一。
  • 如果该单元处于非活动状态,则什么也不做。
  • 如果该单元具有两个以上的邻居,则我们使当前单元处于活动状态。
  • 如果该单元具有少于2个邻居,则我们使当前单元处于非活动状态。
  • 如果恰好有2个邻居,则不要更改当前单元格。

第二个结果使用与第一个相同的原理,但是扩大了邻域的面积。

:

 static int GetVNSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls) { /* von Neumann Neighbourhood looks like this ('T' is our Tile, 'N' is our Neighbour) * * N * NTN * N * */ int tileCount = 0; //Keep the edges as walls if(edgesAreWalls && (x - 1 == 0 || x + 1 == map.GetUpperBound(0) || y - 1 == 0 || y + 1 == map.GetUpperBound(1))) { tileCount++; } //Ensure we aren't touching the left side of the map if(x - 1 > 0) { tileCount += map[x - 1, y]; } //Ensure we aren't touching the bottom of the map if(y - 1 > 0) { tileCount += map[x, y - 1]; } //Ensure we aren't touching the right side of the map if(x + 1 < map.GetUpperBound(0)) { tileCount += map[x + 1, y]; } //Ensure we aren't touching the top of the map if(y + 1 < map.GetUpperBound(1)) { tileCount += map[x, y + 1]; } return tileCount; } 

, . , for .

 public static int[,] SmoothVNCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount) { for (int i = 0; i < smoothCount; i++) { for (int x = 0; x < map.GetUpperBound(0); x++) { for (int y = 0; y < map.GetUpperBound(1); y++) { //Get the surrounding tiles int surroundingTiles = GetVNSurroundingTiles(map, x, y, edgesAreWalls); if (edgesAreWalls && (x == 0 || x == map.GetUpperBound(0) - 1 || y == 0 || y == map.GetUpperBound(1))) { //Keep our edges as walls map[x, y] = 1; } //von Neuemann Neighbourhood requires only 3 or more surrounding tiles to be changed to a tile else if (surroundingTiles > 2) { map[x, y] = 1; } else if (surroundingTiles < 2) { map[x, y] = 0; } } } } //Return the modified map return map; } 

, , :


, , .

结论


, - . , .

Source: https://habr.com/ru/post/zh-CN471084/


All Articles