Unity 六边形地图系列(二十四) :地区和侵蚀

原文地址:https://catlikecoding.com/unity/tutorials/hex-map/part-24/

机翻+个人润色

  • 在地图周围加一圈水域。
  • 将地图分割成多个区域。
  • 对悬崖进行侵蚀。
  • 移动土地来平滑地形。

这是关于六边形地图的系列教程的第24部分。在上一部分中,我们为程序化生成地图奠定了基础。这次我们将限制土地可能出现的地方,并使其受到侵蚀的影响。

这篇教程基于2017.1.0制作

1地图边界

因为我们随机地推动地块上升,陆地是有可能最终接触到地图边缘的。这可能是不可取的。一幅以水为边界的地图包含了一个自然屏障,可以让玩家远离边缘。所以,如果我们能阻止接近边缘的地块上升超过水平面,那就太好了。

1.1边缘大小

允许土地离地图边缘有多近?这个问题没有一个通用的答案,所以让我们让它可配置。我们将在HexMapGenerator组件中添加两个滑动条,一个用于X边缘的边框,另一个用于Z边缘的边框。这使得在一维中使用更宽的边界成为可能,或者对单个维度使用一个边界。让我们使用0到10个单元格的范围,其中5个单元格都是默认值。

	[Range(0, 10)]
	public int mapBorderX = 5;

	[Range(0, 10)]
	public int mapBorderZ = 5;

地图边界拖动条

1.2约束地块中心点

没有边界,所有单元格都是有效的。当边界生效时,最小有效偏移坐标增加,而最大有效坐标减少。因为在生成地块时我们需要知道有效范围,所以让我们用四个整数字段跟踪这个范围。

int xMin, xMax, zMin, zMax;

在GenerateMap中,在创建土地之前初始化坐标限制。我们将使用这些值作为调用Random.Range的参数,所以最大值实际上是排他的。没有边界,它们等于维度的单元格计数,所以不能- 1。

	public void GenerateMap (int x, int z) {
		…
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).WaterLevel = waterLevel;
		}
		xMin = mapBorderX;
		xMax = x - mapBorderX;
		zMin = mapBorderZ;
		zMax = z - mapBorderZ;
		CreateLand();
		…
	}

我们不会严格执行土地不会出现在边界线上的规定,因为那样只会产生过于生硬的边界。我们将只限制用于开始生成块的单元格。因此地块的中心受到边界的限制,但部分地块可以延伸到边界区域。这是通过调整GetRandomCell来完成的,这样它就可以允许在有效偏移范围内选择一个单元格。

	HexCell GetRandomCell () {
//		return grid.GetCell(Random.Range(0, cellCount));
		return grid.GetCell(Random.Range(xMin, xMax), Random.Range(zMin, zMax));
	}

地图边界为0X5,5X5,10X10,0X10

所有的地图设置都在默认值下时,5的边框将可靠地防止陆地接触地图的边缘。然而,这并不能百分百的保证。土地有时可以在多个地方触摸接近边缘。

陆地能否穿越整个边界区域取决于边界的大小和最大的地块的大小。没有不规则化,地块是六边形。半径为r的全六边形包含3r^2+3r+1个单元格。如果有半径等于边界大小的六边形,那么它们就能穿过边界。半径为5的完整六边形包含91个单元格。由于默认的地块的最大值是每块100个单元格,这意味着陆地可以弥补5个单元格的差距,特别是在不规则化的时候。为了确保不会发生这种情况,可以减少最大地块大小或增加边界大小。

如何推导六边形区域有多少个单元格?

在半径为0时,我们处理的是一个单元格。这就是1的来源。在半径为1时,在中心周围有6个额外的单元格,所以是6+1。你可以把这6个单元格想象成6个三角形的顶点,它们接触到中心。在半径2处,第二行被添加到这些三角形中,所以每个三角形多两个单元格,总共是6(1+2)+1。在半径为3的地方,增加了第三行,每个三角形增加了3个单元格,总共是6(1+2+3)+1,以此类推。一般来说,公式是:

6\left ( \sum_{i=1}^{r}{i} \right ) + 1 = 6\left (\frac{r\left ( r+1 \right )}{2} \right )+1 = 3r\left ( r + 1 \right ) + 1 = 3r^2 + 3r + 1

要清楚地看到这一点,您可以将边界大小固定为200。因为半径为8的完整六边形包含217个单元格,所以是可能触碰地图边缘的。至少在使用默认边界大小为5时是这样。将边界增加到10个将使这种可能性大大降低。

地块大小固定为200,地图边界为5和10

1.3泛大陆

请注意,当您增加地图边界同时保持土地百分比不变时,您将强迫土地在较小的区域内形成。因此,默认的大地图很可能会产生一个大的大陆——超级大陆——可能还有几个小岛。增加边界的大小将使这更有可能,直到你几乎可以保证得到一个超级大陆。然而,当土地的比例过高时,大部分可用的区域就会被填满,最终形成一个看起来很像矩形的陆地。为了防止这种情况的发生,你可以降低土地的比例。

                                   40%的陆地和10的地图边界

泛大陆这个名字从何而来?

很久以前,它是地球上最后一个已知的超级大陆的名字。这个名字也被写成Pangaea。它来源于希腊语单词pan和Gaia。它的意思是整个大地,或整个大地。

1.4防范生成错误的地图

我们只需要在得到了我们想要的土地之前不断地提高土块,就可以得到我们想要的土地数量。这是可行的,因为最终我们可以把每一个单元都升到水面以上。然而,当使用地图边框时,不可能所有单元格都被抬升。当期望的土地比例过高时,这将导致生成器永远试图提高更多的土地,从而陷入无限循环,并最终失败。这将使我们的应用程序陷入死循环,这是不应该发生的。

我们不可能预先发现不可能的配置,但我们可以防止无限循环。简单的记录下我们在CreateLand中循环了多少次。如果我们迭代的次数多得离谱,我们很可能卡住了,应该停止。

对于一个大的地图,多达1000次的迭代似乎是可以接受的,但是10000次的迭代确实是荒谬的。我们用10000作为截止点。

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
//		while (landBudget > 0) {
		for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
			int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
			…
		}
	}

如果我们最终得到一个生成的地图,那么就不会花费那么多时间通过10000次迭代,因为大多数单元会很快达到最大的高度,然后阻止新的块的增长。

即使在中止循环之后,我们仍然有一个有效的地图。但是它不会有想要的土地数量,也不会看起来很有趣。让我们记录一个关于这方面的警告,报告我们还有多少没有使用土地预算。

	void CreateLand () {
		…
		if (landBudget > 0) {
			Debug.LogWarning("Failed to use up " + landBudget + " land budget.");
		}
	}

                               95%的陆地和10单元的地图边界,没有用完土地预算

                                                          为什么失败的地图仍然有多样性?

海岸线的变化是多样的,因为一旦地块的中心海拔变得太高,新的地块就会被阻止向外生长。同样的概念可以防止大块大块的土地变成小块的土地,这些土地还没有达到最高海拔,只是碰巧被忽略了。此外,通过不断下沉的块不断增加多样性。

unitypackage

2.地图分区

现在我们有了一个地图边界,我们已经将地图分成了两个不同的区域。边界区域和地块衍生区域。由于衍生区域才是真正重要的,所以我们可以将其视为一个单区域场景。这个区域并没有覆盖整个地图。但如果可以,那么我们就可以将地图切割成多个分离的衍生区域。这样就有可能迫使多个大陆独立形成,代表不同的大陆。

2.1地块生成区域

让我们用结构体来t表示单个地图的生成区域。这使得生成多个区域更加容易。为此创建MapRegion结构体,它只包含边界字段。由于我们不会在HexMapGenerator外部使用这个结构,所以我们可以将它定义为一个私有的内部结构体。然后,可以用单个MapRegion字段替换四个整数字段。

//	int xMin, xMax, zMin, zMax;
	struct MapRegion {
		public int xMin, xMax, zMin, zMax;
	}

	MapRegion region;

为了程序正常运行,我们现在必须在在GenerateMap中的min-max字段前加上前缀region.。

		region.xMin = mapBorderX;
		region.xMax = x - mapBorderX;
		region.zMin = mapBorderZ;
		region.zMax = z - mapBorderZ;

在GetRandomCell中也做同样的事情

	HexCell GetRandomCell () {
		return grid.GetCell(
			Random.Range(region.xMin, region.xMax),
			Random.Range(region.zMin, region.zMax)
		);
	}

2.2多个区域

为了支持多个区域,请使用区域列表替换单个的MapRegion字段。

//	MapRegion region;
	List<MapRegion> regions;

在这一点上,添加专门的方法去创建区域是一个好主意。它应该创建所需的列表,如果已经有一个列表,就清除它。然后,像前面那样定义单个区域并将其添加到列表中。

	void CreateRegions () {
		if (regions == null) {
			regions = new List<MapRegion>();
		}
		else {
			regions.Clear();
		}

		MapRegion region;
		region.xMin = mapBorderX;
		region.xMax = grid.cellCountX - mapBorderX;
		region.zMin = mapBorderZ;
		region.zMax = grid.cellCountZ - mapBorderZ;
		regions.Add(region);
	}

在GenerateMap中调用此方法,而不是直接创建区域。

//		region.xMin = mapBorderX;
//		region.xMax = x - mapBorderX;
//		region.zMin = mapBorderZ;
//		region.zMax = z - mapBorderZ;
		CreateRegions();
		CreateLand();

给GetRandomCell一个MapRegion参数,让它 在任意区域工作。

	HexCell GetRandomCell (MapRegion region) {
		return grid.GetCell(
			Random.Range(region.xMin, region.xMax),
			Random.Range(region.zMin, region.zMax)
		);
	}

RaiseTerraion和SinkTerrain方法现在必须将正确的区域传递给GetRandomCell。为此,它们也需要每个区域参数。

	int RaiseTerrain (int chunkSize, int budget, MapRegion region) {
		searchFrontierPhase += 1;
		HexCell firstCell = GetRandomCell(region);
		…
	}

	int SinkTerrain (int chunkSize, int budget, MapRegion region) {
		searchFrontierPhase += 1;
		HexCell firstCell = GetRandomCell(region);
		…
	}

CreateLand方法必须确定要为哪个区域提高块或下沉块。要平衡区域之间的土地,只需重复循环遍历区域列表。

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
			for (int i = 0; i < regions.Count; i++) {
				MapRegion region = regions[i];
				int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
				if (Random.value < sinkProbability) {
					landBudget = SinkTerrain(chunkSize, landBudget, region);
				}
				else {
					landBudget = RaiseTerrain(chunkSize, landBudget, region);
				}
			}
		}
		if (landBudget > 0) {
			Debug.LogWarning("Failed to use up " + landBudget + " land budget.");
		}
	}

然而,我们也应该注意均匀地分配大块的下沉。这可以在所有区域循环之前来确定是否下沉来。

		for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
			bool sink = Random.value < sinkProbability;
			for (int i = 0; i < regions.Count; i++) {
				MapRegion region = regions[i];
				int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
//				if (Random.value < sinkProbability) {
				if (sink) {
					landBudget = SinkTerrain(chunkSize, landBudget, region);
				}
				else {
					landBudget = RaiseTerrain(chunkSize, landBudget, region);
				}
			}
		}

最后,为了确保我们完全用完我们的土地预算,我们必须在预算达到零的时候停止这个过程。这可能发生在区域循环中的任何一点。因此,将零预算检查移动到内部循环。实际上,我们可以限制只有在土地已经被提高之后进行这个检查,因为下沉的地块永远不会用完预算。完成后,我们可以直接退出CreateLand方法。

//		for (int guard = 0; landBudget > 0 && guard < 10000; guard++) {
		for (int guard = 0; guard < 10000; guard++) {
			bool sink = Random.value < sinkProbability;
			for (int i = 0; i < regions.Count; i++) {
				MapRegion region = regions[i];
				int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
					if (sink) {
					landBudget = SinkTerrain(chunkSize, landBudget, region);
				}
				else {
					landBudget = RaiseTerrain(chunkSize, landBudget, region);
					if (landBudget == 0) {
						return;
					}
				}
			}
		}

2.3两个区域

虽然我们现在支持多个区域,但仍然只定义了一个区域。让我们通过调整CreateRegions来改变它,所以它垂直地将地图一分为二。为此,将添加的区域的xMax值减半,然后对xMin使用相同的值,对xMax再次使用原始值,将其用作第二个区域。

		MapRegion region;
		region.xMin = mapBorderX;
		region.xMax = grid.cellCountX / 2;
		region.zMin = mapBorderZ;
		region.zMax = grid.cellCountZ - mapBorderZ;
		regions.Add(region);
		region.xMin = grid.cellCountX / 2;
		region.xMax = grid.cellCountX - mapBorderX;
		regions.Add(region);

做到在这一点后生成地图的和原来没有任何区别。尽管我们已经定义了两个区域,但它们覆盖的区域与原来的单个区域相同。要把它们分开,我们必须在它们之间留出空隙。为此,我们将添加一个区域边界的滑块,使用与地图边界相同的范围和默认值。

	[Range(0, 10)]
	public int regionBorder = 5;

                                                                   区域边界拖动条

由于陆地可以在区域之间的空间的任何一边形成,所以在地图的边缘更有可能形成陆桥。为了解决这个问题,我们将使用区域边界在允许生成地块的区域之间的分界线中间定义一个不会产生陆地的的区域。这意味着相邻区域之间的距离是区域边界大小的两倍。

要应用区域边界,从第一个区域的xMax中减去它,并将它添加到第二个区域的xMin中。

		MapRegion region;
		region.xMin = mapBorderX;
		region.xMax = grid.cellCountX / 2 - regionBorder;
		region.zMin = mapBorderZ;
		region.zMax = grid.cellCountZ - mapBorderZ;
		regions.Add(region);
		region.xMin = grid.cellCountX / 2 + regionBorder;
		region.xMax = grid.cellCountX - mapBorderX;
		regions.Add(region);

 

                                                 将地图垂直分成2个区域

使用默认设置,这将生成具有两个明显分开的区域的地图,尽管就像一个区域和一个大的地图边界一样,我们不能保证得到两个确切的陆地。大多数时候,它会是两个大的陆地,可能每个都有几个岛屿。有时候,一个区域最终会包含两个或更多的大岛。有时这两个大陆会通过陆桥相连。

当然,也可以水平分割映射,用X和Z轴替换方法。我们随机选择两个可能的方向中的一个。

		MapRegion region;
		if (Random.value < 0.5f) {
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX / 2 - regionBorder;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			region.xMin = grid.cellCountX / 2 + regionBorder;
			region.xMax = grid.cellCountX - mapBorderX;
			regions.Add(region);
		}
		else {
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX - mapBorderX;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ / 2 - regionBorder;
			regions.Add(region);
			region.zMin = grid.cellCountZ / 2 + regionBorder;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
		}

   水平分成两个区域的地图

因为我们使用的是宽地图,水平分割会产生更宽更薄的区域。这就更有可能使一些地区最终形成多个互不相连的大陆块。

2.4升级到四个区域

让我们配置区域的数量,支持其中的1到4个区域。

	[Range(1, 4)]
	public int regionCount = 1;

区域数量的拖动条

我们可以使用switch语句选择要执行的正确区域代码。首先,重新构造单个区域的代码,使用它作为默认值,同时在case 2中保留两个区域的代码。

		MapRegion region;
		switch (regionCount) {
		default:
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX - mapBorderX;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			break;
		case 2:
			if (Random.value < 0.5f) {
				region.xMin = mapBorderX;
				region.xMax = grid.cellCountX / 2 - regionBorder;
				region.zMin = mapBorderZ;
				region.zMax = grid.cellCountZ - mapBorderZ;
				regions.Add(region);
				region.xMin = grid.cellCountX / 2 + regionBorder;
				region.xMax = grid.cellCountX - mapBorderX;
				regions.Add(region);
			}
			else {
				region.xMin = mapBorderX;
				region.xMax = grid.cellCountX - mapBorderX;
				region.zMin = mapBorderZ;
				region.zMax = grid.cellCountZ / 2 - regionBorder;
				regions.Add(region);
				region.zMin = grid.cellCountZ / 2 + regionBorder;
				region.zMax = grid.cellCountZ - mapBorderZ;
				regions.Add(region);
			}
			break;
		}

                                                                       什么是switch语句?

它是编写一个序列if-else-if-else语句的替代方法。switch应用于变量,标签用于指示执行哪些代码。还有一个默认标签,它的功能与final else块类似。每个案例都必须用break语句或return语句终止。

为了保持switch块的易读性,通常最好保持用例的简短,最好是一条语句或一个方法调用。对于示例区域代码,我没有这么做,但如果要创建更有趣的区域,我建议使用单独的方法。例如:

		switch (regionCount) {
			default: CreateOneRegion(); break;
			case 2: CreateTwoRegions(); break;
			case 3: CreateThreeRegions(); break;
			case 4: CreateFourRegions(); break;
		}

三个区域的工作原理与两个区域相似,只是我们使用了三分之二而不是二分之一。在这种情况下,水平分割会产生过窄的区域,所以我们只支持垂直分割。还要注意,我们最终得到的区域边界空间是两个区域的两倍,因此生成块的空间比两个区域的要少。

		switch (regionCount) {
		default:
			…
			break;
		case 2:
			…
			break;
		case 3:
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX / 3 - regionBorder;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			region.xMin = grid.cellCountX / 3 + regionBorder;
			region.xMax = grid.cellCountX * 2 / 3 - regionBorder;
			regions.Add(region);
			region.xMin = grid.cellCountX * 2 / 3 + regionBorder;
			region.xMax = grid.cellCountX - mapBorderX;
			regions.Add(region);
			break;
		}

三个区域

四个区域可以通过合并水平和垂直分割完成,在地图的每个角落创建一个区域。

		switch (regionCount) {
		…
		case 4:
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX / 2 - regionBorder;
			region.zMin = mapBorderZ;
			region.zMax = grid.cellCountZ / 2 - regionBorder;
			regions.Add(region);
			region.xMin = grid.cellCountX / 2 + regionBorder;
			region.xMax = grid.cellCountX - mapBorderX;
			regions.Add(region);
			region.zMin = grid.cellCountZ / 2 + regionBorder;
			region.zMax = grid.cellCountZ - mapBorderZ;
			regions.Add(region);
			region.xMin = mapBorderX;
			region.xMax = grid.cellCountX / 2 - regionBorder;
			regions.Add(region);
			break;
		}
	}

                                                     四个区域

我们在这里使用的方法是划分地图的最直接的方法。它生成的区域在陆地上大致相等,其多样性可以通过其他地图生成设置来控制。然而,至少相当明显的是,地图是沿着直线分割的。你想要的控制越多,结果就越不自然。因此,出于游戏玩法的原因,如果你需要多个相当平等的区域,这是很好的。但如果你想要最多样化、最自由的土地,你选择使用一个区域。

话虽如此,还有其他方法来划分地图。不用局限于使用直线作为边界线。您也不用局限于使用大小相同的区域,也不需要使用区域覆盖整个地图。你也可以留下空洞。你也可以有区域重叠,或者改变区域间的土地分布。甚至可以为每个区域定义不同的生成器设置(尽管这更复杂),例如,确保地图同时包含一个大大陆和一个群岛。

unitypackage

3.侵蚀

到目前为止,我们生成的所有地图都显得相当粗糙和参差不齐。真实的地形可能是这样的,但随着时间的推移,它会变得更加平滑和光滑,尖锐的特征会因为侵蚀而消失。为了改进我们的地图,我们也应该应用这个侵蚀过程。我们将在创建粗糙的土地之后,用另一种方法来做这件事。

	public void GenerateMap (int x, int z) {
		…
		CreateRegions();
		CreateLand();
		ErodeLand();
		SetTerrainType();
		…
	}
	
	…
	
	void ErodeLand () {}

3.1侵蚀的百分比

时间过得越久,侵蚀就越严重。所以我们想要多少侵蚀不是固定的,它必须是可配置的。至少我们目前生成的地图的情况是没有侵蚀的。最大的情况是完全侵蚀,这意味着进一步使用侵蚀力将不再改变地形。所以侵蚀设置应该是从0到100的百分比,我们将使用50作为默认值。

	[Range(0, 100)]
	public int erosionPercentage = 50;

侵蚀的拖动条

3.2找到受侵蚀的单元

侵蚀使地形更加光滑。在我们的例子中,唯一真实的地形特征是悬崖。这些就是侵蚀过程的目标。如果悬崖存在,侵蚀会使其缩小,直到最终变成斜坡。我们不会再把坡度变平,因为那样会产生无趣的地形。要做到这一点,我们必须弄清楚哪些单元位于悬崖顶上,并降低它们的海拔。这些是我们的可蚀单元。

让我们创建一个方法来确定单元格是否可侵蚀。它通过查看单元格的邻居来完成这项工作,直到找到足够大的高程差。由于悬崖至少需要两个高差,因此如果一个或多个相邻单元至少比它低两级,单元就会被侵蚀。如果没有这样的邻居,单元就不能被侵蚀。

	bool IsErodible (HexCell cell) {
		int erodibleElevation = cell.Elevation - 2;
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			HexCell neighbor = cell.GetNeighbor(d);
			if (neighbor && neighbor.Elevation <= erodibleElevation) {
				return true;
			}
		}
		return false;
	}

我们可以在ErodeLand中使用此方法循环遍历所有单元格,并在临时列表中跟踪所有可蚀单元格。

	void ErodeLand () {
		List<HexCell> erodibleCells = ListPool<HexCell>.Get();
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			if (IsErodible(cell)) {
				erodibleCells.Add(cell);
			}
		}

		ListPool<HexCell>.Add(erodibleCells);
	}

一旦我们知道了可蚀单元的总量,我们就可以使用侵蚀百分比来确定应该保留多少可蚀单元。例如,如果百分比是50,那么我们应该侵蚀单元,直到我们得到原来数量的一半。如果百分比是100,我们不会停止,直到所有的可单元胞消失。

	void ErodeLand () {
		List<HexCell> erodibleCells = ListPool<HexCell>.Get();
		for (int i = 0; i < cellCount; i++) {
			…
		}

		int targetErodibleCount =
			(int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f);

		ListPool<HexCell>.Add(erodibleCells);
	}

                                                 我们不应该只计算可侵蚀的陆地单元吗?

水下也会发生侵蚀。侵蚀有不同的类型,但我们不必担心这些细节,可以使用单一的通用方法。

3.3降低单元

让我们从天真的假设开始,简单地降低一个可蚀单元的高度将使它不再可蚀。如果这是真的,我们只需要从列表中随机选择单元格,递减它们的高度,然后从列表中删除它们。我们重复这个过程,直到达到所需的可蚀单元数量。

		int targetErodibleCount =
			(int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f);
		
		while (erodibleCells.Count > targetErodibleCount) {
			int index = Random.Range(0, erodibleCells.Count);
			HexCell cell = erodibleCells[index];

			cell.Elevation -= 1;

			erodibleCells.Remove(cell);
		}

		ListPool<HexCell>.Add(erodibleCells);

为了防止erodibleCells.Remove有搜索需求。只需用列表中的最后一个覆盖当前单元格,然后删除最后一个元素。我们不用管他们的顺序是什么。

//			erodibleCells.Remove(cell);
			erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
			erodibleCells.RemoveAt(erodibleCells.Count - 1);

侵蚀单元百分比为0%和100%,地图的随机种子为1957632474

3.4持续的侵蚀

我们天真的方法确实有一些侵蚀作用,但还远远不够。这是因为一个单元在其海拔下降一次之后,仍然是可侵蚀的。所以只有当单元不再是可侵蚀的时候才移除它。

			if (!IsErodible(cell)) {
				erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
				erodibleCells.RemoveAt(erodibleCells.Count - 1);
			}

100%侵蚀,同时保持可蚀单元在列表中。

这产生了更强的侵蚀作用,但仍然不能完全消除悬崖。这是因为当一个单元的海拔降低时,它的一个相邻单元可能会变得可蚀。所以最终我们可能会得到比开始更多的可侵蚀单元。

降低单元格后,我们必须检查它的所有邻居。如果它们现在是可侵蚀的,但还不在列表中,我们必须将它们添加到列表中。

			if (!IsErodible(cell)) {
				erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
				erodibleCells.RemoveAt(erodibleCells.Count - 1);
			}
			
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = cell.GetNeighbor(d);
				if (
					neighbor && IsErodible(neighbor) &&
					!erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Add(neighbor);
				}
			}

所有的可蚀单元都被降低了

3.5保护大陆

我们的侵蚀过程现在可以继续到所有的悬崖都被清除。这对土地的影响是巨大的。大量的陆地消失了,我们最终得到的土地比例远远低于预期。这是因为我们从地图上删除了土地。

实际的侵蚀不会破坏物质。它把物质从一个地方拿走,然后存放到另一个地方。我们可以做同样的事情。每当我们降低一个单元格,我们应该提高它的一个邻居。单一的海拔高度有效地迁移到较低的细胞。这保存了地图的总高度,只是把它弄平了。

要做到这一点,我们必须确定好把被侵蚀的材料移到哪里。这是我们的侵蚀目标。让我们创建一个方法来确定目标,给定一个我们将要侵蚀的单元格。由于该单元格必须有一个悬崖,因此选择悬崖底部的单元格作为目标。但可蚀细胞可能有多个悬崖。让我们检查一下所有的邻边,把所有的候选项放入一个临时列表中,然后随机选择其中一个。

	HexCell GetErosionTarget (HexCell cell) {
		List<HexCell> candidates = ListPool<HexCell>.Get();
		int erodibleElevation = cell.Elevation - 2;
		for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
			HexCell neighbor = cell.GetNeighbor(d);
			if (neighbor && neighbor.Elevation <= erodibleElevation) {
				candidates.Add(neighbor);
			}
		}
		HexCell target = candidates[Random.Range(0, candidates.Count)];
		ListPool<HexCell>.Add(candidates);
		return target;
	}

在ErodeLand中,选择可蚀单元后直接确定目标单元。然后依次递减和递增单元格高度。这可能使目标单元本身具有可蚀性,但当我们检查刚刚侵蚀的单元的相邻单元时,就会发现这一点。

			HexCell cell = erodibleCells[index];
			HexCell targetCell = GetErosionTarget(cell);

			cell.Elevation -= 1;
			targetCell.Elevation += 1;

			if (!IsErodible(cell)) {
				erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
				erodibleCells.RemoveAt(erodibleCells.Count - 1);
			}

因为我们提高了目标单元,一些单元的邻居现在可能不再是可侵蚀的。如果它们不在列表中,我们也必须检查它们是否可侵蚀。但是如果它们在列表中,那么我们必须从列表中删除它们。

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = cell.GetNeighbor(d);
				…
			}

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = targetCell.GetNeighbor(d);
				if (
					neighbor && !IsErodible(neighbor) &&
					erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Remove(neighbor);
				}
			}

                                     保护陆地后的100%侵蚀的地图

侵蚀现在会使地形更加光滑,在降低一些地区的同时提高另一些地区。因此,陆地既可能增加,也可能减少。这可以在两个方向上调整了土地的百分比,但没有较大的偏差。因此,侵蚀越多,对最终土地百分比的控制力就越少。

3.6更快的侵蚀

虽然我们不需要太担心我们的侵蚀算法的效率,但可以变得更快一些。首先,请注意,我们明确检查我们侵蚀的单元是否仍然是可侵蚀的。否则,我们将从列表中删除它。所以我们在遍历目标单元格的邻居时可以跳过检查这个单元格。

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = targetCell.GetNeighbor(d);
				if (
					neighbor && neighbor != cell && !IsErodible(neighbor) &&
					erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Remove(neighbor);
				}
			}

其次,我们只需要检查目标单元格的邻居,如果他们之间曾经有一个悬崖,但现在没有了。只有当相邻单元格比目标单元格高一级时,才会出现这种情况。如果是,那么邻居肯定在列表中,所以我们不需要验证这个,这意味着我们可以跳过不必要的搜索。

				HexCell neighbor = targetCell.GetNeighbor(d);
				if (
					neighbor && neighbor != cell &&
					neighbor.Elevation == targetCell.Elevation + 1 &&
					!IsErodible(neighbor)
//					&& erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Remove(neighbor);
				}

第三,我们可以使用类似的技巧检查可蚀细胞的邻居。如果现在他们之间有一个悬崖,那么邻居是可侵蚀的。我们不需要调用IsErodible来找出这个。

				HexCell neighbor = cell.GetNeighbor(d);
				if (
					neighbor && neighbor.Elevation == cell.Elevation + 2 &&
//					IsErodible(neighbor) &&
					!erodibleCells.Contains(neighbor)
				) {
					erodibleCells.Add(neighbor);
				}

然而,我们仍然需要检查目标单元格是否可侵蚀,但是上面的循环现在不再处理这个问题。对目标单元格显式执行此操作。

			if (!IsErodible(cell)) {
				erodibleCells[index] = erodibleCells[erodibleCells.Count - 1];
				erodibleCells.RemoveAt(erodibleCells.Count - 1);
			}

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				…
			}

			if (IsErodible(targetCell) && !erodibleCells.Contains(targetCell)) {
				erodibleCells.Add(targetCell);
			}

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				…
			}

相对于最初生成的悬崖数量,我们现在可以更快地应用侵蚀,达到我们想要的百分比。注意,由于我们稍微更改了目标单元格添加到可侵蚀列表的位置,因此与优化之前相比,最终结果将略有变化。

                                                                           25% 50% 75% 100%侵蚀。

还要注意的是,虽然海岸线的形状发生了变化,但拓扑结构并没有发生根本性的改变。大陆块要么保持连接,要么保持分离。只有小岛才能完全沉没。细节被抹平,但整体形状保持不变。一个狭窄的连接可能会消失,但它也可能增长一点。狭窄的缝隙可能会被填满,也可能会稍微变大。因此,侵蚀不会显著地把遥远的地区粘合在一起。

                                

                                                                            四个完全侵蚀的区域,仍然分开

下一篇教程:水循环

原文:Hex Map 25 Water Cycle

项目工程文件下载地址:unitypackage

项目文档下载地址:PDF

 

【版权声明】

原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。

猜你喜欢

转载自blog.csdn.net/liquanyi007/article/details/83780805
今日推荐