Unity 六边形地图系列(二十六) :特征和河流

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

机翻+个人润色

  • 河流是否起源于高湿单元?
  • 创建一个简单的温度模型。
  • 对单元使用生物群落模型,然后调整它。

这是六边形地图系列教程的第26章。上一个章节是水循环在我们的地图生成算法中添加了部分水循环。这一次我们将用河流和温度来补充它,并给单元分配更多有趣的特征。

这篇教程是基于Unity2017.3.0p3制作

热和水带来了生命的地图。

1生成河流

河流是水循环的结果。基本上,它们是由径流通过侵蚀挖出的沟渠形成的。这表明我们可以根据单元的径流增加河流。然而,这不能保证我们得到任何看起来像真实河流的东西。一旦我们开始一条河流,它应该尽可能地保持流动,意味着要跨越许多单元。这并不符合我们的水循环模拟,它是在单元上并行演化的。另外,您通常希望控制地图上有多少河流。

因为河流是如此不同,我们将分别生成它们。我们将使用水循环模拟的结果来确定河流的位置,但是我们不会让河流反过来影响模拟。

有时河流的流动是错误的?

在我们的TriangulateWaterShore方法中有一个bug,它很少出现。这发生在河流的终点,在逆流之后。问题是我们只检查了河流方向是否与入流河流方向匹配。当我们处理河的起点时,这就错了。解决方案还包括检查单元是否真的有流入的河流。我也在Rivers教程中进行了修复。

	void TriangulateWaterShore (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		…

		if (cell.HasRiverThroughEdge(direction)) {
			TriangulateEstuary(
				e1, e2,
				cell.HasIncomingRiver && cell.IncomingRiver == direction, indices
			);
		}
		…
	}

1.1湿润的高地

在我们的地图上,一个单元要么有一条河,要么没有。它们也不能分支或合并。实际上,河流比这灵活得多,但我们只能用这个近似,只表示较大的河流。我们必须确定的最重要的事实是一条大河的起点,我们只能随机选择。

因为河流需要水,所以河流的源头必须在一个有大量水分的单元中。但这还不够。河流向下流动,所以理想的起点还要有一个高海拔。单元离水面越高,就越有可能是河流的源头。我们可以将其可视化为地图数据,方法是将单元格的高度除以最大高度。为了使它是相对于水平面的,在除之前从两个高度减去水平面的高度。

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			…
			float data =
				(float)(cell.Elevation - waterLevel) /
				(elevationMaximum - waterLevel);
			cell.SetMapData(data);
		}
	}

水分和海拔高度。默认大地图配置,种子1208905299

最好的候选者是那些同时具有高湿度和高海拔的单元。我们可以把这些标准相乘。其结果是河流起源的相性或权重。

河流起源的权重

理想情况下,我们可以使用这些权重来对随机选择的原始单元格进行偏重。虽然我们可以构建一个适当权衡的列表并从中进行选择,但这并不简单,而且会减慢生成过程。我们可以用更简单的重要性分类来区分四个层次。最佳候选人的权重大于0.75。好的候选人的权重在0.5以上。仍然可以接受的候选者的权重大于0.25。所有其他单元格不合格。我们把它形象化。

			float data =
				moisture * (cell.Elevation - waterLevel) /
				(elevationMaximum - waterLevel);
			if (data > 0.75f) {
				cell.SetMapData(1f);
			}
			else if (data > 0.5f) {
				cell.SetMapData(0.5f);
			}
			else if (data > 0.25f) {
				cell.SetMapData(0.25f);
			}
//			cell.SetMapData(data);

河流起点权重分类

有了这种分类方案,我们最终可能会发现河流来自地图上海拔更高、更湿润的地区。但河流仍然有可能在地势较低或较为干燥的地区形成,从而提供了河流的多样性。

添加一个CreateRivers方法,该方法使用这些标准填充一个单元格列表。可接受的单元格添加到此列表中一次,良好的单元格添加到此列表中两次,首选单元格添加到此列表中四次。水下的单元是不合格的,所以我们可以跳过检查。

	void CreateRivers () {
		List<HexCell> riverOrigins = ListPool<HexCell>.Get();
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			if (cell.IsUnderwater) {
				continue;
			}
			ClimateData data = climate[i];
			float weight =
				data.moisture * (cell.Elevation - waterLevel) /
				(elevationMaximum - waterLevel);
			if (weight > 0.75f) {
				riverOrigins.Add(cell);
				riverOrigins.Add(cell);
			}
			if (weight > 0.5f) {
				riverOrigins.Add(cell);
			}
			if (weight > 0.25f) {
				riverOrigins.Add(cell);
			}
		}

		ListPool<HexCell>.Add(riverOrigins);
	}

这个方法必须在CreateClimate之后调用,这样我们就有了可用的湿度数据。

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

随着分类的完成,我们可以去掉地图数据的可视化。

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			…
//			float data =
//				moisture * (cell.Elevation - waterLevel) /
//				(elevationMaximum - waterLevel);
//			if (data > 0.6f) {
//				cell.SetMapData(1f);
//			}
//			else if (data > 0.4f) {
//				cell.SetMapData(0.5f);
//			}
//			else if (data > 0.2f) {
//				cell.SetMapData(0.25f);
//			}
		}
	}

1.2河流预算

有多少条河流是理想的?这应该是可配置的。由于河流的长度各不相同,因此最合理的控制方法是使用河流预算,即规定一条河流应该包含多少土地单元。我们把它表示为一个百分比,最大值为20%,默认值为10%。就像土地百分比一样,这是一个目标数量,而不是一个必须数量。我们可能会以候选地太少或河流太短而无法覆盖所需的土地而告终。这就是为什么最大百分比不应该太高。

	[Range(0, 20)]
	public int riverPercentage = 10;

河百分比滑块

为了确定以单元数量表示的河流预算,我们必须记住在CreateLand中生成了多少陆地单元。

	int cellCount, landCells;
	…
	
	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		landCells = landBudget;
		for (int guard = 0; guard < 10000; guard++) {
			…
		}
		if (landBudget > 0) {
			Debug.LogWarning("Failed to use up " + landBudget + " land budget.");
			landCells -= landBudget;
		}
	}

现在可以像在CreateLand中那样在CreateRivers中计算河流预算。

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

		int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f);
		
		ListPool<HexCell>.Add(riverOrigins);
	}

接下来,持续从原始列表中选择和删除随机的单元格,只要我们还有预算和原始单元格。如果我们没有用完预算,也要记录一个警告。

		int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f);
		while (riverBudget > 0 && riverOrigins.Count > 0) {
			int index = Random.Range(0, riverOrigins.Count);
			int lastIndex = riverOrigins.Count - 1;
			HexCell origin = riverOrigins[index];
			riverOrigins[index] = riverOrigins[lastIndex];
			riverOrigins.RemoveAt(lastIndex);
		}
		
		if (riverBudget > 0) {
			Debug.LogWarning("Failed to use up river budget.");
		}

除此之外,还要添加一个方法来实际创建一条河。它需要原始单元格作为参数,一旦完成,就应该返回河流的长度。让我们从一个只返回0长度的方法开始。

	int CreateRiver (HexCell origin) {
		int length = 0;
		return length;
	}

在我们刚刚添加到CreateRivers方法中的循环的结束时调用这个方法,使用它来减少剩余的预算。确保我们所选的单元格中没有河流经过才创建新的河流。

		while (riverBudget > 0 && riverOrigins.Count > 0) {
			…

			if (!origin.HasRiver) {
				riverBudget -= CreateRiver(origin);
			}
		}

1.3流动的河流

让一条河流流向大海或另一个水体似乎很简单。当我们从原点开始时,我们马上从长度为1开始。然后,随机选择一个邻居,流到其中,并增加长度。一直这样做,直到我们进入水下单元。

	int CreateRiver (HexCell origin) {
		int length = 1;
		HexCell cell = origin;
		while (!cell.IsUnderwater) {
			HexDirection direction = (HexDirection)Random.Range(0, 6);
			cell.SetOutgoingRiver(direction);
			length += 1;
			cell = cell.GetNeighbor(direction);
		}
		return length;
	}

充满偶然性的河流

这种天真的方法导致了随意放置河流的碎片,主要是因为我们最终替换了以前生成的河流。它甚至可能导致错误,因为我们甚至不检查邻居是否真的存在。我们必须遍历所有的方向并确认我们在那里有一个邻居。然后,将这个方向添加到一个潜在的流向列表中,但前提是该邻居没有河流流经。然后从列表中随机选择一个方向。

	List<HexDirection> flowDirections = new List<HexDirection>();
	
	…
	
	int CreateRiver (HexCell origin) {
		int length = 1;
		HexCell cell = origin;
		while (!cell.IsUnderwater) {
			flowDirections.Clear();
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = cell.GetNeighbor(d);
				if (!neighbor || neighbor.HasRiver) {
					continue;
				}
				flowDirections.Add(d);
			}

			HexDirection direction =
//				(HexDirection)Random.Range(0, 6);
				flowDirections[Random.Range(0, flowDirections.Count)];
			cell.SetOutgoingRiver(direction);
			length += 1;
			cell = cell.GetNeighbor(direction);
		}
		return length;
	}

有了这种新方法,我们可能最终得到零可用流向。当这种情况发生时,这条河不能再流下去了,我们不得不中止。如果这时的长度等于1,这意味着我们不能从原始单元流出,所以根本不可能有一条河。在这种情况下,这条河的长度是零。

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

			if (flowDirections.Count == 0) {
				return length > 1 ? length : 0;
			}

受到保护的河流

1.4流下上的河流

我们现在保护的是已经形成的河流,但我们最终得到的仍然是孤立的河流碎片。这是因为到目前为止我们忽略了海拔。每次我们让河流流向更高的地方HexCell.SetOutgoingRiver 会导致失败,从而导致河流的不连续。所以我们也要跳过会导致河流向上流动的方向。

				if (!neighbor || neighbor.HasRiver) {
					continue;
				}

				int delta = neighbor.Elevation - cell.Elevation;
				if (delta > 0) {
					continue;
				}
				
				flowDirections.Add(d);

流下山的河流

这消除了许多河流碎片,虽然我们仍然得到一些。这是一个优化的问题,为了摆脱最难看的河流。首先,河流更喜欢尽可能快地流向下游。虽然不能保证他们走的是最短的路线,但是要劲量做。为了模拟这一点,在下坡方向向列表多走三次。

				if (delta > 0) {
					continue;
				}

				if (delta < 0) {
					flowDirections.Add(d);
					flowDirections.Add(d);
					flowDirections.Add(d);
				}
				flowDirections.Add(d);

1.5避免河流的急转弯

除了喜欢下坡,流水也有动量。河流更有可能笔直向前或缓慢弯曲,而不是突然急转弯。我们可以通过跟踪河流的最后一个方向来引入这种偏差。如果一个势流方向与这个方向没有太大的偏差,那么将它再次添加到列表中。只要现在不是从原点流出的,简单的再次添加即可。

	int CreateRiver (HexCell origin) {
		int length = 1;
		HexCell cell = origin;
		HexDirection direction = HexDirection.NE;
		while (!cell.IsUnderwater) {
			flowDirections.Clear();
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				…

				if (delta < 0) {
					flowDirections.Add(d);
					flowDirections.Add(d);
					flowDirections.Add(d);
				}
				if (
					length == 1 ||
					(d != direction.Next2() && d != direction.Previous2())
				) {
					flowDirections.Add(d);
				}
				flowDirections.Add(d);
			}

			if (flowDirections.Count == 0) {
				return length > 1 ? length : 0;
			}

//			HexDirection direction =
			direction = flowDirections[Random.Range(0, flowDirections.Count)];
			cell.SetOutgoingRiver(direction);
			length += 1;
			cell = cell.GetNeighbor(direction);
		}
		return length;
	}

这使得不雅观曲折的河流不太可能出现。

更少的急转弯

1.6合并河流

有时,一条河流的终点恰好是先前创造的河流的源头。除非那条河的源头海拔较高,否则我们可以决定让这条新河流入那条旧河。结果是一条更长的河流,而不是两条很近的较短的河流。

要做到这一点,只跳过一个有流入河流的邻居,或者是当前河流的源头的邻居。当我们确定它不是一个上坡的方向后,检查是否有一条流出的河流。如果有,我们就发现了一条老的河流的源头。因为这种情况非常罕见,所以不必费心检查其他可能的相邻源头,立即合并河流。

				HexCell neighbor = cell.GetNeighbor(d);
//				if (!neighbor || neighbor.HasRiver) {
//					continue;
//				}
				if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) {
					continue;
				}

				int delta = neighbor.Elevation - cell.Elevation;
				if (delta > 0) {
					continue;
				}

				if (neighbor.HasOutgoingRiver) {
					cell.SetOutgoingRiver(d);
					return length;
				}

河流合并前后

1.7保持间距

因为高质量的原河流起点单元往往聚在一起,我们最终得到的是河流群。同时,我们也可以在一个水体附近找到河流,形成单一的河流。我们可以通过取消与河流或水体相邻的源头的资格来扩展源头。通过遍历CreateRivers中所选原点的邻居来实现这一点。如果我们发现有一个违规的邻居,原点是无效的,我们应该跳过它。

		while (riverBudget > 0 && riverOrigins.Count > 0) {
			int index = Random.Range(0, riverOrigins.Count);
			int lastIndex = riverOrigins.Count - 1;
			HexCell origin = riverOrigins[index];
			riverOrigins[index] = riverOrigins[lastIndex];
			riverOrigins.RemoveAt(lastIndex);

			if (!origin.HasRiver) {
				bool isValidOrigin = true;
				for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
					HexCell neighbor = origin.GetNeighbor(d);
					if (neighbor && (neighbor.HasRiver || neighbor.IsUnderwater)) {
						isValidOrigin = false;
						break;
					}
				}
				if (isValidOrigin) {
					riverBudget -= CreateRiver(origin);
				}
			}

虽然河流最终仍可能彼此相连,但现在它们往往覆盖更大的区域。

与其他水体保持距离

1.8河流尾端的湖泊

并不是所有的河流都能成为一个水体。有些被山谷或其他河流阻挡。这不是一个大问题,因为有许多真正的河流似乎也消失了。这可能发生,例如,因为它们流到地下,因为它们扩散到沼泽地区,或者因为它们干涸。我们的河流无法做到这一点,所以它们只是结束。

话虽如此,我们应该尽量减少这种情况的发生。虽然我们不能合并河流,也不能让它们上坡,但我们可以让它们在湖泊中结束,这更常见,看起来也更好。要做到这一点,CreateRiver必须在单元被卡住时提高其水位。这是否可能取决于该单元格相邻单元格的最小高度。因此,在调查邻居时要注意这一点,这需要进行一些代码重构。

		while (!cell.IsUnderwater) {
			int minNeighborElevation = int.MaxValue;
			flowDirections.Clear();
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = cell.GetNeighbor(d);
//				if (!neighbor || neighbor == origin || neighbor.HasIncomingRiver) {
//					continue;
//				}
				if (!neighbor) {
					continue;
				}

				if (neighbor.Elevation < minNeighborElevation) {
					minNeighborElevation = neighbor.Elevation;
				}

				if (neighbor == origin || neighbor.HasIncomingRiver) {
					continue;
				}

				int delta = neighbor.Elevation - cell.Elevation;
				if (delta > 0) {
					continue;
				}

				…
			}

			…
		}

如果我们卡住了,首先检查一下是否还在原点。如果是,那就中止这条河。否则,检查所有邻居是否至少与当前单元格一样高。如果是这样的话,那么我们可以把水提高到这个高度。这将创建一个单独一个单元的湖,除非单元的海拔高度相同。如果是这样,简单地将海拔设置为低于水位的高度。

			if (flowDirections.Count == 0) {
//				return length > 1 ? length : 0;
				if (length == 1) {
					return 0;
				}

				if (minNeighborElevation >= cell.Elevation) {
					cell.WaterLevel = minNeighborElevation;
					if (minNeighborElevation == cell.Elevation) {
						cell.Elevation = minNeighborElevation - 1;
					}
				}
				break;
			}

河流的尽头没有湖泊和有湖泊。河流百分比在这里是20。

请注意,我们现在可以使用水位以上的水下单元来生成地图,表示海平面以上的湖泊。

1.9额外的湖泊

当我们没有被困住的时候,我们也可以创造湖泊。这将导致河流从一个湖泊流入另一个湖泊。如果不卡住,可以通过将水位提高到单元当前的高度,然后降低单元的高度来创建湖泊。这只在最小海拔仰角至少等于当前单元格海拔时有效。在进入下一个单元格之前,在河循环的末尾执行此操作。

		while (!cell.IsUnderwater) {
			…
			
			if (minNeighborElevation >= cell.Elevation) {
				cell.WaterLevel = cell.Elevation;
				cell.Elevation -= 1;
			}
			
			cell = cell.GetNeighbor(direction);
		}

没有和有额外湖泊的区别

虽然一些湖泊是好的,但如果不加限制,这种方法可能会产生太多的湖泊。让我们为额外的湖泊添加一个可配置的概率,默认值为0.25。

	[Range(0f, 1f)]
	public float extraLakeProbability = 0.25f;

这控制了一个额外湖泊生成的概率。

			if (
				minNeighborElevation >= cell.Elevation &&
				Random.value < extraLakeProbability
			) {
				cell.WaterLevel = cell.Elevation;
				cell.Elevation -= 1;
			}

一些额外的湖泊

那么创建大于一个单元的湖泊呢?

你可以创造更大的湖泊,让它们在水下的单元附近形成,前提是它们有正确的水位。然而,这也有不利的一面。它可以创造河流循环,水从一个水体中流出,然后又回到水体中。这些循环可能很长,也可能很短,但它们总是明显而不正确的。你也可以看到河床沿着一个大湖的底部蜿蜒而行,这看起来很奇怪。

unitypackage

2温度

水只是决定单元地形特征的一个因素。另一个非常重要的因素是温度。虽然我们可以像模拟水一样模拟温度的流动和扩散,但我们只需要一个复杂的因素就可以创造一个有趣的气候。所以我们要保持温度的简单,每个单元只测一次。

2.1温度的纬度

纬度对温度的影响最大。赤道是热的,两极是冷的,两者之间有一个逐渐过渡。让我们创建一个DetermineTemperature的方法返回给定单元格温度。首先,我们简单地用细胞的Z坐标除以Z维作为纬度,然后直接用它作为温度。

	float DetermineTemperature (HexCell cell) {
		float latitude = (float)cell.coordinates.Z / grid.cellCountZ;
		return latitude;
	}

确定SetTerrainType中的温度并将其用作映射数据。

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			float temperature = DetermineTemperature(cell);
			cell.SetMapData(temperature);
			float moisture = climate[i].moisture;
			…
		}
	}

纬度作为温度,像南半球

我们得到的是从底部到顶部的线性温度梯度。我们可以用这个来表示南半球,极地在底部赤道在顶部。但我们不需要覆盖整个半球。我们可以用一个更小的温差来表示一个更小的区域,或者根本没有温差。为了这个目的,我们将使低,高温可配置。我们将在0-1范围内定义这些温度,使用极端值作为默认值。

	[Range(0f, 1f)]
	public float lowTemperature = 0f;

	[Range(0f, 1f)]
	public float highTemperature = 1f;

温度滑块

通过线性插值应用温度范围,使用纬度作为插值器。当我们将纬度表示为从0到1的值时,我们可以使用Mathf.LerpUnclamped。

	float DetermineTemperature (HexCell cell) {
		float latitude = (float)cell.coordinates.Z / grid.cellCountZ;
		float temperature =
			Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude);
		return temperature;
	}

注意,我们不需要强迫低温比高温低。如果你想的话,可以把温度反过来。

2.2半球

我们现在可以表示南半球,也可以表示北半球通过改变温度。但是使用单独的配置选项在半球之间切换要方便得多。让我们为此创建一个枚举和字段。这样我们还可以包括一个覆盖两个半球的选项,我们将其设置为默认值。

	public enum HemisphereMode {
		Both, North, South
	}

	public HemisphereMode hemisphere;

半球的选择

如果我们想要北半球,我们可以简单地把纬度颠倒过来,用1减去它。为了覆盖两个半球,我们需要确保两极位于地图的顶部和底部,而赤道位于地图的中部。我们可以通过将纬度加倍来做到这一点,这可以正确地处理下半球,但使上半球从1到2。为了纠正这个错误,当纬度大于1时,用2减去纬度。

	float DetermineTemperature (HexCell cell) {
		float latitude = (float)cell.coordinates.Z / grid.cellCountZ;
		if (hemisphere == HemisphereMode.Both) {
			latitude *= 2f;
			if (latitude > 1f) {
				latitude = 2f - latitude;
			}
		}
		else if (hemisphere == HemisphereMode.North) {
			latitude = 1f - latitude;
		}

		float temperature =
			Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude);
		return temperature;
	}

包含了两极的地图

请注意,这也使得使用比高温更热的低温来创建赤道冷而两极热的奇异地图成为可能。

2.3更高的地方更冷

除纬度外,海拔对温度也有显著影响。一般来说,你走得越高,天气就越冷。我们可以把它变成一个因子,就像我们对河源候选项做的那样。在本例中,我们使用单元格的海拔高度。同时,因子随着高度的增加而减小,所以它是1减去海拔除以相对于水位的最大值。为了防止因子在最高的时候总是降到0,在除数上加1。然后用这个因子来衡量温度。

		float temperature =
			Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude);

		temperature *= 1f - (cell.ViewElevation - waterLevel) /
			(elevationMaximum - waterLevel + 1f);

		return temperature;

海拔高度影响的温度

2.4温度的变化

我们可以通过加入一个随机的温度波动来降低温度梯度。一点点的随机性可以让它看起来更真实,但是太多的随机性会让它看起来更随意。让我们将此温度抖动的强度配置为最高温度偏差,默认值为0.1。

	[Range(0f, 1f)]
	public float temperatureJitter = 0.1f;

温度抖动滑块

这些波动应该平稳,局部变化较小。我们可以使用噪声纹理。所以调用HexMetrics.SampleNoise,以单元格的位置为参数,按0.1进行缩放。让我们抓取W通道,居中,并通过抖动因子进行缩放。然后把这个值加到我们之前确定的温度上。

		temperature *= 1f - (cell.ViewElevation - waterLevel) /
			(elevationMaximum - waterLevel + 1f);

		temperature +=
			(HexMetrics.SampleNoise(cell.Position * 0.1f).w * 2f - 1f) *
			temperatureJitter;

		return temperature;

温度抖动设置为0.1和1。

我们可以通过随机选择四个噪声通道中的一个来为每个贴图的抖动增加一些变化。在SetTerrainType中确定一次通道,然后在DetermineTemperature中确定颜色通道的索引。

	int temperatureJitterChannel;
	
	…
	
	void SetTerrainType () {
		temperatureJitterChannel = Random.Range(0, 4);
		for (int i = 0; i < cellCount; i++) {
			…
		}
	}
	
	float DetermineTemperature (HexCell cell) {
		…

		float jitter =
			HexMetrics.SampleNoise(cell.Position * 0.1f)[temperatureJitterChannel];

		temperature += (jitter * 2f - 1f) * temperatureJitter;

		return temperature;
	}

不同温度抖动,最大强度

unitypackage

3地形特征

现在我们有了湿度和温度的数据,我们可以创建一个地形特征矩阵。通过索引这个矩阵,我们可以将地形特征分配给所有单元,从而创建一个比仅使用一个数据维度更复杂的景观。

3.1地形特征矩阵

气候模型有很多,但我们不会使用其中任何一个。我们将保持简单,只关心看起来合理的东西。干的意思是沙漠——冷的或热的——我们用沙子来代替。寒冷和潮湿意味着下雪。热和湿意味着很多植物,所以用草地。在这中间,我们有针叶林或苔原,我们用灰色的泥质来表示。一个4×4矩阵应该足够提供空间给这些地形特征之间的过渡。

以前,我们根据五个湿度带来分配地形类型。我们只需要去掉最干燥的频带(最高0.05),保留其他频带。对于温度带,我们将使用0.1、0.3、0.6和更高的温度。在静态数组中定义这些值,以便于参考。

	static float[] temperatureBands = { 0.1f, 0.3f, 0.6f };
					
	static float[] moistureBands = { 0.12f, 0.28f, 0.85f };

虽然我们只设置了基础的的地形类型,但是我们也可以使用它来确定其他东西。让我们在HexMapGenerator中定义一个Biome结构来表示单个Biome的配置。目前,它只包含地形索引和适当的构造函数方法。

	struct Biome {
		public int terrain;
		
		public Biome (int terrain) {
			this.terrain = terrain;
		}
	}

使用此结构体创建包含矩阵数据的静态数组。我们用湿度作为X维,温度作为Y维。在温度最低的一排填满雪,第二排填满苔原,另外两排填满草。然后将最干燥的一列改为沙漠,覆盖温度选项。

	static Biome[] biomes = {
		new Biome(0), new Biome(4), new Biome(4), new Biome(4),
		new Biome(0), new Biome(2), new Biome(2), new Biome(2),
		new Biome(0), new Biome(1), new Biome(1), new Biome(1),
		new Biome(0), new Biome(1), new Biome(1), new Biome(1)
	};

地形特征矩阵,具有一维数组指标。

3.2确定地形特征

要确定SetTerrainType中的地形特征,需要通过温度和湿度波段来确定我们需要的基础指标。使用它们来检索正确的地形特征并设置单元格的地形类型。

	void SetTerrainType () {
		temperatureJitterChannel = Random.Range(0, 4);
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			float temperature = DetermineTemperature(cell);
//			cell.SetMapData(temperature);
			float moisture = climate[i].moisture;
			if (!cell.IsUnderwater) {
//				if (moisture < 0.05f) {
//					cell.TerrainTypeIndex = 4;
//				}
//				…
//				else {
//					cell.TerrainTypeIndex = 2;
//				}
				int t = 0;
				for (; t < temperatureBands.Length; t++) {
					if (temperature < temperatureBands[t]) {
						break;
					}
				}
				int m = 0;
				for (; m < moistureBands.Length; m++) {
					if (moisture < moistureBands[m]) {
						break;
					}
				}
				Biome cellBiome = biomes[t * 4 + m];
				cell.TerrainTypeIndex = cellBiome.terrain;
			}
			else {
				cell.TerrainTypeIndex = 2;
			}
		}
	}

基于地形特征矩阵的地形。

3.3调整地形特征

我们并不局限于矩阵中定义的地形特征。例如,矩阵定义了所有干旱的生物群落为沙漠。但并不是所有的沙漠都充满了沙子。也有许多岩石沙漠,看起来很不一样。让我们把沙漠单元的一部分变成岩石。我们将简单地基于海拔来做这件事,理由是松散的沙子是在较低的海拔发现的,而在较高的海拔你会遇到大部分裸露的岩石。

假设沙粒变成了岩石如果一个单元格的海拔高度更接近海拔最大值而不是水位。这是岩漠高度线,我们可以在SetTerrainType开始时计算。然后,如果我们遇到一个有沙的单元,它的海拔足够高,改变它的地形特征的为岩石。

	void SetTerrainType () {
		temperatureJitterChannel = Random.Range(0, 4);
		int rockDesertElevation =
			elevationMaximum - (elevationMaximum - waterLevel) / 2;
		
		for (int i = 0; i < cellCount; i++) {
			…
			if (!cell.IsUnderwater) {
				…
				Biome cellBiome = biomes[t * 4 + m];

				if (cellBiome.terrain == 0) {
					if (cell.Elevation >= rockDesertElevation) {
						cellBiome.terrain = 3;
					}
				}

				cell.TerrainTypeIndex = cellBiome.terrain;
			}
			else {
				cell.TerrainTypeIndex = 2;
			}
		}
	}

沙漠和岩石沙漠

另一个基于海拔的调整是强迫处于最高海拔的单元变成雪帽,不管它们有多暖和,只要它们不太干燥。这使得雪帽更有可能出现在炎热潮湿的赤道附近。

				if (cellBiome.terrain == 0) {
					if (cell.Elevation >= rockDesertElevation) {
						cellBiome.terrain = 3;
					}
				}
				else if (cell.Elevation == elevationMaximum) {
					cellBiome.terrain = 4;
				}

最高海拔处的雪帽

3.4植被

既然我们已经考虑了地形类型,让我们也让我们的地形特征决定单元的植被类型。这要求我们向Biome中添加一个植物字段,并将其包含在构造函数中。

	struct Biome {
		public int terrain, plant;

		public Biome (int terrain, int plant) {
			this.terrain = terrain;
			this.plant = plant;
		}
	}

最冷和最干燥的生物群落根本没有植物。除此之外,我们得到了更多的植物温暖和湿度。第二级水分柱对于最热的行只获得植物级别1,因此[0,0,0,1]。第三列将级别增加1,除了雪,所以[0,1,1,2]。最潮湿的一列又增加了它们,所以用[0,2,2,3]调整地形特征阵列以包含此植物配置。

	static Biome[] biomes = {
		new Biome(0, 0), new Biome(4, 0), new Biome(4, 0), new Biome(4, 0),
		new Biome(0, 0), new Biome(2, 0), new Biome(2, 1), new Biome(2, 2),
		new Biome(0, 0), new Biome(1, 0), new Biome(1, 1), new Biome(1, 2),
		new Biome(0, 0), new Biome(1, 1), new Biome(1, 2), new Biome(1, 3)
	};

包含了植被的地形特征矩阵

现在能更好的设置单元的植被了。

				cell.TerrainTypeIndex = cellBiome.terrain;
				cell.PlantLevel = cellBiome.plant;

包含了植被的地形特征

这些植物看起来和以前不一样了吗?

我把大多数植物的预制件放大了一点,使它们在远处看得更清楚。两个低植物预制件的标度分别为(1,2,1)和(0.75,1,0.75)。中位数为(1.5,3,1.5)和(2,1.5,2),高位数为(2,4.5,2)和(2.5,3,2.5)。

我还把植物的颜色调暗了一点,以更好地与纹理结合,使用(13,114,0)。

我们也可以调整地形特征的等级。首先,我们应该确保它们不会出现在雪地上,我们可能已经对雪地进行了调整。其次,如果还没有达到最大值,我们也可以增加河流沿线的植物水平。

				if (cellBiome.terrain == 4) {
					cellBiome.plant = 0;
				}
				else if (cellBiome.plant < 3 && cell.HasRiver) {
					cellBiome.plant += 1;
				}

				cell.TerrainTypeIndex = cellBiome.terrain;
				cell.PlantLevel = cellBiome.plant;

调整后的植被

3.5水下的地形特征

到目前为止,我们完全忽略了水下单元。让我们也给这些单元添加一些种类,而不是对所有的细胞都使用泥浆。一个简单的基于海拔的方法应该已经带来了一些更有趣的东西。例如,让我们用草作为海拔低于水面一级的单元。我们也可以用草来建造比这更高的单元,也就是河流形成的湖泊。负海拔的单元在较深的区域,我们用岩石来做。所有其他细胞都可以保持泥状。

	void SetTerrainType () {
			…
			if (!cell.IsUnderwater) {
				…
			}
			else {
				int terrain;
				if (cell.Elevation == waterLevel - 1) {
					terrain = 1;
				}
				else if (cell.Elevation >= waterLevel) {
					terrain = 1;
				}
				else if (cell.Elevation < 0) {
					terrain = 3;
				}
				else {
					terrain = 2;
				}
				cell.TerrainTypeIndex = terrain;
			}
		}
	}

不同的水下单元

让我们在海岸的水下单元中添加更多的细节。这些单元至少有一个在水面上的邻居。如果这样的单元很浅,它可能有海滩。或者,如果它靠近悬崖,那么悬崖就是主要的视觉特征,我们可以用石头代替。

为了弄清楚这一点,请检查单元在水位线下一个级别的邻居。计算一下有多少悬崖和斜坡与陆地相邻。

				if (cell.Elevation == waterLevel - 1) {
					int cliffs = 0, slopes = 0;
					for (
						HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++
					) {
						HexCell neighbor = cell.GetNeighbor(d);
						if (!neighbor) {
							continue;
						}
						int delta = neighbor.Elevation - cell.WaterLevel;
						if (delta == 0) {
							slopes += 1;
						}
						else if (delta > 0) {
							cliffs += 1;
						}
					}
					terrain = 1;
				}

现在我们可以利用这些信息对单元进行分类。首先,如果它的一半以上的邻居是陆地,那么我们面对的是一个湖泊或海湾。让我们用草的纹理来画这些细胞。如果不是这样,如果我们有悬崖,我们就用石头。否则,如果我们有斜坡,用沙子来创造海滩。唯一的另一个选择是远离海岸的浅水区,我们将坚持用草。

				if (cell.Elevation == waterLevel - 1) {
					int cliffs = 0, slopes = 0;
					for (
						HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++
					) {
						…
					}
					if (cliffs + slopes > 3) {
						terrain = 1;
					}
					else if (cliffs > 0) {
						terrain = 3;
					}
					else if (slopes > 0) {
						terrain = 0;
					}
					else {
						terrain = 1;
					}
				}

多样化的海岸线

作为最后的调整,让我们确保我们不会在最冷的温度范围内得到绿色的水下单元。用泥浆代替这些单元。

				if (terrain == 1 && temperature < temperatureBands[0]) {
					terrain = 2;
				}
				cell.TerrainTypeIndex = terrain;

现在,我们可以使用许多配置选项生成看起来非常有趣和自然的随机地图。

 

下一个教程是循环地图

原文:Hex Map 27 Wrapping

 

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

项目文档下载地址:PDF

 

【版权声明】

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

发布了7 篇原创文章 · 获赞 8 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/liquanyi007/article/details/85946528