Unity 六边形地图系列(二十三) :自动生成地形

原文:https://catlikecoding.com/unity/tutorials/hex-map/part-23/

机翻+个人润色

·通过自动生成地形填充地图。

·将地图块升到水面上或者沉下去一些。

·控制哪些地块出现,有多高,以及不规律。

·支持多种配置选项来创建不同的地图0 。

·可以重新生成一张同样的地图

这是 hexagon maps的第23章教程。这是第一篇写了一些包含如何使用程序生成地图的教程。

这篇教程是基于Unity2017.1.0制作

                                                     这是一张程序生成的地图

扫描二维码关注公众号,回复: 4209649 查看本文章

1.生成地图

当我们手动的创建我们喜欢的地图时,会花费大量的时间。如果我们的应用程序可以帮助设计师在一开始为他们生成地图,然后根据自己的需要修改地图,那将会很方便。更进一步的做法是完全放弃手工设计,完全依靠应用程序本身为我们生成完成地图。这将使每次玩游戏时使用新地图成为可能,确保每次新游戏体验都是不同的。当探索是游戏的重要组成部分时,事先不知道你要玩的地图的布局是至关重要的,这意味着可以多次进行游戏。为了使这一切成为可能,我们必须创建一个生成地图的算法。

你需要什么样的地图生成算法取决于你的应用需要什么样的地图。没有一个最好的方法可以做到这一点,但是在可信度和可玩性之间总是有一个平衡的。

可信度是指玩家在游戏中对地图的真实性和可能性的接受程度。这并不意味着地图必须看起来像我们星球的一部分。它们可能是另一个星球或一个完全不同的现实。但如果它代表的是泥土地形,它至少应该看起来像那部分。

可玩性是指地图是否支持你想要的游戏体验。这常常与可信度不一致。例如,虽然山脉看起来很漂亮,但它们在逻辑上也严重限制了单位的移动和视觉。如果你不愿意这样做,你就必须在没有山脉的情况下进行,这可能会降低游戏的可信度,限制游戏的表现力。或者,你可以保留山脉但减少它们对游戏玩法的影响,这也可能降低可信度。

除此之外,还有可行性。例如,你可以通过模拟板块构造、侵蚀、降雨、火山爆发、流星撞击、月球撞击等等来生成一个非常逼真的类地行星。但这需要很长时间才能实现。此外,生成这样一个行星可能需要一段时间,玩家不希望在开始一款新游戏之前等上几分钟。因此,虽然模拟可能是一种强大的工具,但它有一定的成本。

游戏充满了可信、可玩性和可行性之间的权衡。有时候,这些权衡会被忽视,看起来很正常,或者是任意的,不一致的,或者是不和谐的,这取决于游戏开发者的选择和优先级。这并不局限于地图生成,但在开发过程式地图生成器时,您必须非常清楚这一点。你可能会花很多时间去创建一个算法来生成漂亮的地图,而这些地图对于你想要制作的游戏也毫无用处。

在本系列教程中,我们将学习类地地形。它看起来应该很有趣,有很多种类,没有大的同质区域。地形的规模将是巨大的,地图将覆盖一个或多个大陆,海洋区域,甚至整个行星。我们想要合理地控制地理,包括陆地、气候、有多少地区、地形有多崎岖。本教程将为大地奠定基础。

1.1在开始时使用编辑模式

因为我们关注的是地图而不是游戏玩法,所以在编辑模式下直接启动应用程序是很方便的。这样我们就能马上看到地图。所以对HexMapEditor调整。在Awake中设置编辑模式为真,并启用编辑模式着色器关键字。

void Awake () 
{
    terrainMaterial.DisableKeyword("GRID_ON");
    Shader.EnableKeyword("HEX_MAP_EDIT_MODE");
    SetEditMode(true);
}

1.2Map Generator

因为生成地图的过程需要相当多的代码,所以我们不会直接将其添加到HexGrid中。相反,我们将为它创建一个新的HexMapGenerator组件,让HexGrid不知道它。如果您愿意的话,这也使得以后切换到不同的算法更加容易。

generator需要对grid的引用,因此为它提供一个公共字段。除此之外,添加一个公共GenerateMap方法来完成算法的工作。给它一个地图坐标作为参数,然后让它使用这些来创建一个新的空地图。

using System.Collections.Generic;
using UnityEngine;

public class HexMapGenerator : MonoBehaviour {

	public HexGrid grid;

	public void GenerateMap (int x, int z) {
		grid.CreateMap(x, z);
	}
}

将带有HexMapGenerator组件的对象添加到场景中,并将其连接到网格。

地图生成器对象

1.3调整New Map Menu

我们将调整NewMapMenu以便使它除了创建空的地图,也可以生成地图。我们将通过 generateMaps的一个boolean字段来控制它的功能,默认设置为true。创建一个公共方法来设置这个字段,就像我们对HexMapEditor的切换选项所做的那样。向菜单UI添加相应的toggle,并将其连接到方法。

bool generateMaps = true;

public void ToggleMapGeneration (bool toggle) 
{
    generateMaps = toggle;
}

有新的toggle的New map menu

给菜单一个地图生成器的引用。然后让它调用生成器的GenerateMap方法,而不是直接使用网格的CreateMap。

public HexMapGenerator mapGenerator;

	…

void CreateMap (int x, int z) 
{
    if (generateMaps)
    {
        mapGenerator.GenerateMap(x, z);
	}
	else 
    {
		hexGrid.CreateMap(x, z);
	}
	HexMapCamera.ValidatePosition();
	Close();
}

连接到generator

1.4访问单元

为了完成它的工作,生成器需要访问网格的单元。HexGrid已经有了公共的GetCell方法,它需要一个位置向量或者六边形地图坐标hexcoordinates。生成器不需要使用这两种方法,因此让我们添加两个新的方便的HexGrid.GetCell,使用偏移坐标或单元格索引。

public HexCell GetCell (int xOffset, int zOffset) 
{
    return cells[xOffset + zOffset * cellCountX];
}
	
public HexCell GetCell (int cellIndex) 
{
    return cells[cellIndex];
}

现在HexMapGenerator可以直接检索到单元格。例如,在创建了新地图之后,使用偏移坐标将中间单元格列的地形设置为grass。

public void GenerateMap (int x, int z) 
{
    grid.CreateMap(x, z);
    for (int i = 0; i < z; i++) 
    {
        grid.GetCell(x / 2, i).TerrainTypeIndex = 1;
    }
}

小地图上的一列草地

unitypackage

2.创建陆地

在绘制地图时,我们在概念上没有任何土地。你可以想象整个世界都被一个大海覆盖着。当部分海底被向上推得如此之高以至于高出水面时,陆地就形成了。我们必须决定以这种方式创造了多少土地,它在哪里出现,以什么形状出现。

2.1升起地形

我们从很小的地方开始,把一小块土地抬高到水面之上。为此创建一个RaiseTerrain方法,使用一个参数来控制块的大小。在GenerateMap中调用此方法,替换先前的测试代码。让我们从一小块土地开始,由七个单元组成。

public void GenerateMap (int x, int z) 
{
    grid.CreateMap(x, z);
    //or (int i = 0; i < z; i++) {
    //grid.GetCell(x / 2, i).TerrainTypeIndex = 1;
    //}
    RaiseTerrain(7);
}

void RaiseTerrain (int chunkSize) {}

现在,我们将简单地使用草地地形类型来表示凸起的土地,初始的沙地地形表示海洋。我们将让RaiseTerrain抓取一个随机的单元,调整它的地形类型,直到我们得到所需的土地。

添加一个GetRandomCell方法去获得一个随机单元格,该方法确定了一个随机单元格的索引并检索到网格中相应的单元格。

void RaiseTerrain (int chunkSize) 
{
    for (int i = 0; i < chunkSize; i++) 
    {
        GetRandomCell().TerrainTypeIndex = 1;
    }
}

HexCell GetRandomCell () 
{
    return grid.GetCell(Random.Range(0, grid.cellCountX * grid.cellCountZ));
}

7个随机的地形单元

因为我们最终可能需要很多随机的细胞——或者多次循环遍历所有的细胞——让我们跟踪HexMapGenerator内部的细胞数量。

int cellCount;

public void GenerateMap (int x, int z) 
{
    cellCount = x * z;
    …
}

…

HexCell GetRandomCell () 
{
    return grid.GetCell(Random.Range(0, cellCount));
}

2.2创建单独的地块

当我们把7个随机单元变成陆地时,它们可以在任何地方。它们很可能不会形成一大块土地。我们也可能会多次选择到同一个单元,最终得到的土地会数量少于预期。为了解决这两个问题,只有第一个单元可以不受约束地选取。在那之后,我们必须只选择与我们之前选择的细胞相邻的细胞。这些限制与寻路的限制非常相似,所以我们在这里使用相同的方法。

给HexMapGenerator它自己的优先队列和搜索边界的阶段计数器,就像HexGrid一样。

HexCellPriorityQueue searchFrontier;

int searchFrontierPhase;

确保优先队列在我们需要它之前就存在。

public void GenerateMap (int x, int z) 
{
    cellCount = x * z;
    grid.CreateMap(x, z);
    if (searchFrontier == null) {
        searchFrontier = new HexCellPriorityQueue();
    }
    RaiseTerrain(7);
}

创建新地图后,所有单元格的搜索边界阶段为零。但如果我们要在生成地图的同时搜索单元格,我们要在这个过程中增加它们的搜索边界阶段。如果我们做大量的搜索,它们可能会在HexGrid记录搜索边界阶段之前结束。这可能会打破单位的寻路。为了防止这种情况发生,在地图生成过程结束时将所有单元格的搜索阶段重置为零。

 

RaiseTerrain(7);
for (int i = 0; i < cellCount; i++) 
{
    grid.GetCell(i).SearchPhase = 0;
}

RaiseTerrain现在必须搜索合适的单元格,而不是随机的选择它们。这个过程与我们在HexGrid中搜索路径的方式非常相似。无论如何,我们永远不会多次访问单元格,所以我们可以通过将搜索边界阶段的增量由2改为1来满足需求。然后初始化随机的第一个单元格的frontier。除了设置搜索阶段外,确保将它的距离和SearchHeuristic为零。

void RaiseTerrain (int chunkSize) {
//    for (int i = 0; i < chunkSize; i++) {
//    GetRandomCell().TerrainTypeIndex = 1;
//    }
    searchFrontierPhase += 1;
    HexCell firstCell = GetRandomCell();
    firstCell.SearchPhase = searchFrontierPhase;
    firstCell.Distance = 0;
    firstCell.SearchHeuristic = 0;
    searchFrontier.Enqueue(firstCell);
}

之后的搜索循环也非常熟悉。除了继续到边界为空,我们还应该在块达到所需大小时停止,所以要跟踪块的大小。每一次迭代,对下一个单元格进行出列,设置其地形类型,增加块的大小,然后遍历该单元格的邻居。将所有的邻居都被添加到队列,如果它们还没有被添加过的话。我们不需要做任何其他的比较或调整。一旦我们完成了搜索,确保清理队列。

searchFrontier.Enqueue(firstCell);

int size = 0;
while (size < chunkSize && searchFrontier.Count > 0) 
{
    HexCell current = searchFrontier.Dequeue();
    current.TerrainTypeIndex = 1;
    size += 1;

    for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) 
    {
        HexCell neighbor = current.GetNeighbor(d);
        if (neighbor && neighbor.SearchPhase < searchFrontierPhase) 
        {
            neighbor.SearchPhase = searchFrontierPhase;
            neighbor.Distance = 0;
            neighbor.SearchHeuristic = 0;
            searchFrontier.Enqueue(neighbor);
        }
    }
}
searchFrontier.Clear();

 

一条线的单元格

现在我们得到了所需大小的单个地形块。它最终因为没有足够的单元而变得更小。由于想队列填充相邻单位的方式,它总是产生一排向西北移动的单元。只有在到达地图边缘时才会改变方向。

2.3保持单元聚在一起

大块的土地很少形成一条直线,即使它们是直线也不一定有相同的方向。为了改变块的形状,我们必须改变单元格的优先级。我们可以使用第一个随机单元作为块的中心。所有其他单元格的距离都是相对于这一个点的。这将给予离中心较近的单元格更高的优先级,这将使块在其中心周围而不是在直线上生长。

searchFrontier.Enqueue(firstCell);
HexCoordinates center = firstCell.coordinates;

int size = 0;
while (size < chunkSize && searchFrontier.Count > 0) 
{
    HexCell current = searchFrontier.Dequeue();
    current.TerrainTypeIndex = 1;
    size += 1;

    for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++)
     {
        HexCell neighbor = current.GetNeighbor(d);
        if (neighbor && neighbor.SearchPhase < searchFrontierPhase)
        {
            neighbor.SearchPhase = searchFrontierPhase;
            neighbor.Distance = neighbor.coordinates.DistanceTo(center);
            neighbor.SearchHeuristic = 0;
            searchFrontier.Enqueue(neighbor);
        }
    }
}

成群的单元

事实上,我们的七个单元现在总是整齐地排列在一个紧凑的六边形区域里,除非中心单元恰好位于地图的边缘。让我们用块大小为30来试试。

RaiseTerrain(30);

一堆30个单元

同样地,我们总是得到相同的形状,尽管它没有足够的正确数量的单元来组成一个整齐的六边形。因为块的半径更大,它也更有可能接近一个地图的边缘,从而被迫形成不同的形状。

2.4随机化的地块形状

我们不希望所有地块看起来都一样,所以让我们稍微打乱单元格的优先级。每次我们向队列添加一个邻居的单元格时,如果下一个andom.value的值小于某个阈值,将该单元格的heuristic设置为1而不是0。我们用0.5作为阈值,这意味着很可能有一半的单元会受到影响。

neighbor.Distance = neighbor.coordinates.DistanceTo(center);
neighbor.SearchHeuristic = Random.value < 0.5f ? 1: 0;
searchFrontier.Enqueue(neighbor);

不规则的地块

通过增加单元格的搜索时的heuristic,我们确保访问它的时间比预期的要晚。这导致其他离中心更远的单元被提前访问,除非它们的heuristic也增加了。这意味着如果我们将所有单元的heuristic增加相同的数量,就不会有任何效果。因此,1的阈值没有效果,就像0的阈值一样。0。8的阈值等于0。2的效果。因此,0.5的概率使搜索过程最不规则。

哪种抖动概率最好取决于你想要的地形,所以让我们把它设置成可配置的。向生成器添加一个公共浮点jitterProbability字段,其范围属性限制为0-0.5。给它一个默认值等于其范围的平均值,所以是0。25。这允许我们通过Unity inspector窗口配置我们的生成器。

[Range(0f, 0.5f)]
public float jitterProbability = 0.25f;

抖动属性如何通过游戏内UI来配置它呢?

这是可能的,大多数游戏都是这样做的。我不会在本教程中为它添加游戏内UI,但这不会阻止你。然而,我们最终会得到很多生成器的配置选项。所以在设计UI时请记住这一点。你最好等到知道所有的选择后在进行设计。这时,您可能还会决定使用不同的约束、不同的术语,并限制向玩家公开哪些选项。

现在使用这个概率而不是固定值来决定heuristic是否应该设置为1。

neighbor.SearchHeuristic = Random.value < jitterProbability ? 1: 0;

我们使用heuristic的值为0和1。虽然您也可以使用更大的heuristic的值,但这将大大加剧块的变形,可能会把它变成一束带状。

2.5升高多个块

我们不局限于生产一块土地。例如,将RaiseTerrain的调用放到循环中,这样我们就得到了5个块。

for (int i = 0; i < 5; i++) 
{
    RaiseTerrain(30);
}

                                5个地块

虽然我们现在正在生成5块大小为30的块,但我们不能保证得到相当于150个单元的土地。由于每个块都是单独创建的,它们彼此不知道,所以它们可以重叠。这很好,因为它可以生成更多不同的景观,而不是一堆孤立的块。

为了使土地更加多样化,我们还可以改变每一块土地的大小。添加两个整数字段来控制允许的最小和最大块大小。给他们一个相当大的范围,比如20-200。我将默认的最小值设置为30,默认的最大值设置为100。

	[Range(20, 200)]
	public int chunkSizeMin = 30;

	[Range(20, 200)]
	public int chunkSizeMax = 100;

地块的大小范围

在调用RaiseTerrain时,使用这些字段随机确定块大小。

RaiseTerrain(Random.Range(chunkSizeMin, chunkSizeMax + 1));

在中等地图上5个随机大小的地块

2.6制造足够的陆地

在这一点上,我们无法控制多少土地产生。虽然我们可以为块的数量添加一个配置选项,但块的大小仍然是随机的,它们可能会有一点或很多重叠。因此,块的数量不能保证地图最终有多少是陆地。让我们添加一个选项来直接控制土地百分比,用整数表示。因为100%的陆地或水域并不有趣,所以将它的范围设置为5-95,默认值为50。

陆地百分比

为了确保我们最终得到想要的土地,我们只需要不断地抬高成块的地形,直到我们拥有足够的土地。这就要求我们跟踪我们的进度,这会使得土地的生成更加复杂。因此,让我们用一个新的CreateLand方法来替换当前用来调用太高地块的循环。这个方法做的第一件事就是计算有多少单元必须要变成陆地。那是我们的土地预算。

	public void GenerateMap (int x, int z) {
		…
//		for (int i = 0; i < 5; i++) {
//			RaiseTerrain(Random.Range(chunkSizeMin, chunkSizeMax + 1));
//		}
		CreateLand();
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).SearchPhase = 0;
		}
	}

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
	}

只要还有土地预算要花,CreateLand就会一直调用RaiseTerrain。为了防止超出预算,调整RaiseTerrain使其增加一个额外的参数budget。一旦完成,它应该返回剩余的预算budget。

//	void RaiseTerrain (int chunkSize) {
	int RaiseTerrain (int chunkSize, int budget) {
		…
		return budget;
	}

每当一个单元被从队列中移出并变成土地时,预算就应该减少。如果在那之后所有的预算都花光了,我们就不得不中止搜索,缩短搜索时间。确保仅当当前单元格还没有变成陆地时才这样做。

		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			if (current.TerrainTypeIndex == 0) {
				current.TerrainTypeIndex = 1;
				if (--budget == 0) {
					break;
				}
			}
			size += 1;
			
			…
		}

现在,只要有土地预算就可以一直升高地块了

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		while (landBudget > 0) {
			landBudget = RaiseTerrain(
				Random.Range(chunkSizeMin, chunkSizeMax + 1), landBudget
			);
		}
	}

地图的一半是陆地

unitypackage

3.塑形和高度

陆地不仅仅是由海岸线定义的一块平板。它也可以有不同的海拔,包括小山、山脉、山谷、湖泊等等。缓慢移动的构造板块之间的相互作用导致高程差异较大。虽然我们不打算模拟这个,我们的大块土地有点像这些板块。我们的块不会移动,但它们会重叠。这是我们可以利用的东西。

3.1推动土地升高

每一个地块都代表了从海底推上来的一部分陆地。所以在RaiseTerrain中处理的处理单元格时,我们总是增加它的高度看看会发生什么。

			HexCell current = searchFrontier.Dequeue();
			current.Elevation += 1;
			if (current.TerrainTypeIndex == 0) {
				…
			}

不同高度的地块

我们得到了一些高度,但很难看清。为了让地图高度更加明显我们可以为不同的高度使用不同的地形类型,如地质分层。这只是为了让它的高度表现更明显,所以我们可以简单地使用海拔高度作为地形指数。

                               当海拔高于地形类型的时候会发生什么?

着色器将使用纹理数组中的最后一个纹理。在我们的例子中,雪是最后一种地形类型,所以我们会得到一条雪线。

让我们创建一个单独的SetTerrainType方法,一次性设置所有的地形类型,而不是每次单元格的海拔变化时都更新它的地形类型。

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			cell.TerrainTypeIndex = cell.Elevation;
		}
	}

在创建好陆地后调用这个方法。

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

现在RaiseTerrain不再需要担心地形类型了,而只关注海拔了,这需要改变它的逻辑。当当前单元的新高度为1时,它就变成了土地,所以预算减少,这可能会结束其他单元的增长。

                                 分层的陆地

3.2添加水面

让我们通过将所有单元格的水位设置为1来明确哪些单元格是陆地单元格,哪些是水单元格。在GenerateMap方法中创建土地之前,先做这些事情。

	public void GenerateMap (int x, int z) {
		cellCount = x * z;
		grid.CreateMap(x, z);
		if (searchFrontier == null) {
			searchFrontier = new HexCellPriorityQueue();
		}
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).WaterLevel = 1;
		}
		CreateLand();
		…
	}

现在我们可以使用所有地形类型来表示陆地层。作为最低的陆地单元所有的水下单元都是沙子。这是通过将单元格的海拔减去水位作为地形类型索引来实现的。

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			if (!cell.IsUnderwater) {
				cell.TerrainTypeIndex = cell.Elevation - cell.WaterLevel;
			}
		}
	}

                陆地和水

3.3提高水平面

我们不局限将水平面固定为1。让我们通过范围为1-5和默认值为3的公共字段来配置它。使用此级别来初始化单元格。

	[Range(1, 5)]
	public int waterLevel = 3;
	
	…
	
	public void GenerateMap (int x, int z) {
		…
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).WaterLevel = waterLevel;
		}
		…
	}

 

                                                    水平面等级为3

当水位为3时,我们得到的土地比预期的要少得多。这是因为RaiseTerrain仍然假设水位为1。让我们解决这个问题。

			HexCell current = searchFrontier.Dequeue();
			current.Elevation += 1;
			if (current.Elevation == waterLevel && --budget == 0) {
				break;
			}

使用更高水位的效果是单元格不会立即变成陆地。当水位为2时,第一个地块仍然完全在水下。海底上升了,但仍被淹没的。只有当至少两个板块重叠时,才会形成陆地。水位越高,需要堆叠更多的地块来形成陆地。其结果是更高的水位使得地形更复杂。此外,当需要更多的大块土地时,它们更有可能堆积在现有土地上,这使得山脉更常见,平原更稀少,就像使用小块土地一样。

                           水平面为2~5,50%的陆地

unitypackage

4.垂直移动

到目前为止,我们对一块地块的操作只是每次向上提升一个高度,但它并不仅限于此。

4.1更高升高的地块

虽然每一个地块的单元增加一层高度,也可以产生悬崖。这发生在两个地块的边缘接触的单元格。这可能产生孤立的悬崖,但很难见到成片的悬崖。我们可以通过让地块多增加一层高度来让这些更常见。但是我们应该只对一小部分地块做这个。如果所有的大块区域都是悬崖,地形将变得非常难以导航。让我们来配置一个概率字段,默认值是0。25。

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

更高的提升的的概率

虽然我们在升高地块时可以使用任何的数值,但它很快会失控。2的高度差已经可以造成了悬崖,这就足够了。因为这会使得单元格的高度跳过与水位相等的阶段,我们必须改变我们如何确定一个单元已经变成陆地的方式。如果它以前在水位以下,但现在在同一水平或以上,那么它就是一个新的陆地单元。

		int rise = Random.value < highRiseProbability ? 2 : 1;
		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			current.Elevation = originalElevation + rise;
			if (
				originalElevation < waterLevel &&
				current.Elevation >= waterLevel && --budget == 0
			) {
				break;
			}
			size += 1;
			
			…
		}

更高的升高概率0.25,0.50,0.75和1

4.2下沉的陆地

土地并不总是上升,有时也会下降。当土地下沉到足够低的程度时,它就会沉没并消失。我们目前没有这样做。因为我们只把大块的土地向上推,所以这些土地看起来就像是一堆相当圆润的区域混在一起。如果我们有时向下推一个块,我们可以得到更多不同的地形结构。

没有沉降地块的大地图

可以用另一个概率字段来控制我们下沉的频率。因为下沉会破坏陆地,所以我们应该让下沉的可能性小于上升的可能性。否则,要达到理想的土地比例可能需要很长时间。我们用最大下沉概率为0.4,默认是0.2。

	[Range(0f, 0.4f)]
	public float sinkProbability = 0.2f;

下沉概率

下沉一个板块类似于升起一个板块,只是有一些区别。所以复制RaiseTerrain方法并把它的名字改成SinkTerrain。我们不再需要确定要上升的单元格数量,而需要一个要下降的单元格数量,这就可以使用相同的逻辑。与此同时,检查我们是否通过水面的比较必须颠倒过来。此外,我们下沉地形的不受预算限制。而且每一个不再是陆地的土地单元都要收回花费的预算,所以我们应该增加它并继续下去。

	int SinkTerrain (int chunkSize, int budget) {
		…

		int sink = Random.value < highRiseProbability ? 2 : 1;
		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			current.Elevation = originalElevation - sink;
			if (
				originalElevation >= waterLevel &&
				current.Elevation < waterLevel
//				&& --budget == 0
			) {
//				break;
				budget += 1;
			}
			size += 1;

			…
		}
		searchFrontier.Clear();
		return budget;
	}

在CreateLand循环内的每次迭代中,我们现在应该用下沉概率来决定增加或减少一大块土地。

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		while (landBudget > 0) {
			int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
			if (Random.value < sinkProbability) {
				landBudget = SinkTerrain(chunkSize, landBudget);
			}
			else {
				landBudget = RaiseTerrain(chunkSize, landBudget);
			}
		}
	}

下沉几率为0.1,0.2,0.3,0.4

4.3限制高度

用这种方法,我们可能会堆叠许多地块,有时会多次进行升高,有时其中一部分地块可能会下沉,然后再次上升。这可以产生非常高的海拔的单元格,或者非常低的海拔,特别是当需要高的土地百分比。

90%预算陆地的地图

为了控制单元格的高度,我们添加一个可配置的最小值和最大值。合理的最低值可能介于−4和0之间,而可接受的最大值在6 - 10的范围内,默认值设置为−2和8。当手动编辑地图时,它们位于允许范围之外,因此您自己去调整编辑器UI的滑块。

	[Range(-4, 0)]
	public int elevationMinimum = -2;

	[Range(6, 10)]
	public int elevationMaximum = 8;

高度的最大值和最小值

在RaiseTerrain中,我们现在应该确保单元格高度不超过允许的最大海拔高度。我们将通过检查当前单元格的新高度是否最终会过高来完成这项工作。如果高了,我们就跳过它,不调整它的海拔高度,也不添加它的邻居。这将导致地块避开已经达到最高点的单元格继续升高。

			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			int newElevation = originalElevation + rise;
			if (newElevation > elevationMaximum) {
				continue;
			}
			current.Elevation = newElevation;
			if (
				originalElevation < waterLevel &&
				newElevation >= waterLevel && --budget == 0
			) {
				break;
			}
			size += 1;

在SinkTerrain做同样的事,只是用最小高度来进行比较。

			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			int newElevation = current.Elevation - sink;
			if (newElevation < elevationMinimum) {
				continue;
			}
			current.Elevation = newElevation;
			if (
				originalElevation >= waterLevel &&
				newElevation < waterLevel
			) {
				budget += 1;
			}
			size += 1;

限制了高度的90%的陆地

4.4存储负高度

目前,我们的保存和加载代码不能处理负高度。这是因为我们将高度存储为一个字节。一个负数保存后会变成一个大的正数。因此,保存并加载生成的地图可能会导致一些原本淹没在水中的的单元格变得非常高。

我们可以通过将其存储为整数而不是字节来支持负高度。然而,我们仍然不需要支持许多高度等级。我们还可以偏移存储的值,增加127。可以确保用一个字节存储在-127~128的范围内的海拔。调整相应的HexCell.Save。

	public void Save (BinaryWriter writer) {
		writer.Write((byte)terrainTypeIndex);
		writer.Write((byte)(elevation + 127));
		…
	}

当我们改变了存储地图数据的方式时,增加SaveLoadMenu.mapFileVersion为 4。

const int mapFileVersion = 4;

最后修改HexCell.Load,当版本号大于等于为4时,将读取的高度减去127。

	public void Load (BinaryReader reader, int header) {
		terrainTypeIndex = reader.ReadByte();
		ShaderData.RefreshTerrain(this);
		elevation = reader.ReadByte();
		if (header >= 4) {
			elevation -= 127;
		}
		…
	}

5.重复创建相同的地图

现在我们已经可以创建各种各样的地图了。每次我们生成一个新的,结果都是随机的。我们只能通过配置选项来控制映射的特征,而不能控制它的确切形状。但有时我们想要重新创建完全相同的地图。例如,与别人分享一张漂亮的地图。或者手动编辑后重新开始。它在开发过程中也很有用。让我们让这成为可能。

5.1使用随机种子

我们使用Random.RangeRandom.value使地图的生成过程不可预测。为了得到相同的伪随机序列,我们必须使用相同的种子值。在HexMetrics.InitializeHashGrid中,我们以前已经使用过这种方法。它首先存储数字生成器的当前状态,用特定的种子初始化它,然后再将它恢复到原来的状态。我们可以对HexMapGenerator.GenerateMap使用相同的方法。同样,我们记住旧状态并在完成之后恢复它,这样我们就不会影响任何使用随机的其他东西。

	public void GenerateMap (int x, int z) {
		Random.State originalRandomState = Random.state;
		…
		Random.state = originalRandomState;
	}

接下来,我们将公开生成最后一张地图时使用的种子值。这是通过一个公共整数字段完成的。

public int seed;

显示随机种子

现在我们需要一个种子值来初始化Random。我们必须使用随机种子来着创建随机地图。最直接的方法可能是使用Random.Range 以生成任意种子值。为了不影响原始的随机状态,我们必须在存储之后这样做。

	public void GenerateMap (int x, int z) {
		Random.State originalRandomState = Random.state;
		seed = Random.Range(0, int.MaxValue);
		Random.InitState(seed);
		
		…
	}

在我们完成后恢复随机状态时,如果我们立即生成另一个地图,我们将得到相同的种子值。另外,我们不知道初始的随机状态是如何初始化的。因此,虽然它可以作为一个任意的起点,但我们需要更多的东西来随机化每次调用。

初始化随机数生成器有多种方法。在这种情况下,可以组合一些任意值,这些值会发生很大的变化,因此不太可能再次生成相同的地图。例如,让我们使用以滴答数表示的较低的32位系统时间,加上应用程序的当前运行时间。

		seed = Random.Range(0, int.MaxValue);
		seed ^= (int)System.DateTime.Now.Ticks;
		seed ^= (int)Time.unscaledTime;
		Random.InitState(seed);

结果可能是负数,这对于公开的种子来说并不好。我们可以强制它是正的,方法是用最大整数值^=它,这会将符号位设置为零。

		seed ^= (int)Time.unscaledTime;
		seed &= int.MaxValue;
		Random.InitState(seed);

5.2反复利用种子

我们仍然在生成随机地图,但是我们现在可以看到每次使用的种子的具体的值。为了重新创建相同的地图,我们必须指示生成器重用它的种子值,而不是创建一个新的。我们将通过添加一个布尔字段来切换。

public bool useFixedSeed;

是否重用种子值的选项

当需要使用固定的种子时,我们只需在GenerateMap中跳过生成新种子的过程。如果我们不手动编辑种子的字段,就会再次生成完全相同的地图。

		Random.State originalRandomState = Random.state;
		if (!useFixedSeed) {
			seed = Random.Range(0, int.MaxValue);
			seed ^= (int)System.DateTime.Now.Ticks;
			seed ^= (int)Time.time;
			seed &= int.MaxValue;
		}
		Random.InitState(seed);

现在可以复制您喜欢的映射的种子值并将其存储在某个位置,稍后再生成它。请记住,只有在使用完全相同的生成器设置时,才会得到相同的映射。同样的地图大小,还有所有其他的配置选项。即使是对其中一个概率的微小变化也能产生完全不同的地图。所以除了种子,你还要记住所有的设置。

种子使用0和929396788的大地图,其他设置为默认设置

下一篇教程 :地区和侵蚀

原文:Hex Map 24 Regions and Erosion

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

项目文档下载地址:PDF

【版权声明】

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

猜你喜欢

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