第1-3部分:网格,颜色和像元高度第4-7部分:颠簸,河流和道路第8-11部分:水,地貌和城墙第12-15部分:保存和加载,纹理,距离第16-19部分:找到道路,队员,动画第20-23部分:战争迷雾,地图研究,程序生成第24-27部分:水循环,侵蚀,生物群落,圆柱图第24部分:区域和侵蚀
- 在地图周围添加水的边界。
- 我们将地图分为几个区域。
- 我们用侵蚀来切断悬崖。
- 我们移动土地以减轻救济。
在上一部分中,我们为生成过程图奠定了基础。 这次,我们将限制可能发生土地的地点,并采取侵蚀措施。
本教程是在Unity 2017.1.0中创建的。
分开并平整土地。地图边框
由于我们随机升高土地面积,因此可能会发生土地触及地图边缘的情况。 这可能是不希望的。 限水地图包含一个自然屏障,可防止玩家接近边缘。 因此,如果我们禁止土地升至地图边缘附近的水位上方,那将很好。
边框尺寸
陆地应距地图边缘多近? 这个问题没有正确答案,因此我们将使此参数可自定义。 我们将在
HexMapGenerator
组件中添加两个滑块,一个滑块用于X轴边缘的边框,另一个滑块用于Z轴的边框,因此我们可以在一个维度上使用更宽的边界,甚至仅在一个维度上创建边界。 让我们使用0到10的间隔,默认值为5。
[Range(0, 10)] public int mapBorderX = 5; [Range(0, 10)] public int mapBorderZ = 5;
地图边框滑块。我们限制陆地区域的中心
没有边界,所有单元格均有效。 有边界时,最小允许偏移坐标会增加,最大允许坐标会减少。 由于要生成图,我们需要知道允许的间隔,因此我们使用四个整数字段对其进行跟踪。
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 () {
地图的边框为0×0、5×5、10×10和0×10。当所有地图参数均设置为其默认值时,大小为5的边框将可靠地保护地图边缘免于接触地面。 但是,这不能保证。 土地有时会靠近边缘,有时会在几个地方碰到它。
土地越过整个边界的可能性取决于边界的大小和站点的最大大小。 毫不犹豫地,这些部分仍然是六边形。 半径全六边形
包含
细胞。 如果有六边形的半径等于边框的大小,则它们可以越过边框。 半径为5的完整六边形包含91个像元。 由于默认情况下每节最大数量为100个像元,因此这意味着陆地将能够跨5个像元搭建桥梁,尤其是在有振动的情况下。 为防止这种情况发生,请减小绘图的最大尺寸,或增大边框的尺寸。
六角形区域中的细胞数公式如何推导?半径为0,我们正在处理一个单元格。 它来自1。以1为中心的半径,还有六个其他单元,即 。 这六个像元可以视为六个三角形的末端,它们接触中心。 半径为2时,向这些三角形添加第二行,即在该三角形上又获得了两个单元,总共 。 半径为3时,将添加第三行,即每个三角形再增加三个单元格 。 依此类推。 也就是说,总的来说,公式看起来像 。
为了更清楚地看到这一点,我们可以将边框大小设置为200。由于半径为8的完整六边形包含217个像元,因此陆地可能会触及地图的边缘。 至少如果您使用默认边框大小值(5)。 如果将边框增加到10,则概率将大大降低。
地块的大小恒定为200,地图的边界为5和10。潘吉亚
请注意,当您增加地图边框并保留相同比例的土地时,我们将强制土地形成较小的区域。 结果,默认情况下,一张大地图可能会创建一个大块土地-超大陆Pangea-可能有几个小岛。 随着边界尺寸的增加,这种可能性增加,在某些值下,我们几乎可以保证获得超大陆。 但是,当土地的比例太大时,大多数可用区域就会填满,结果我们得到的土地几乎是矩形。 为了防止这种情况的发生,您需要减少土地的百分比。
40%寿司,卡片边框为10。Pangea这个名字是从哪里来的?那是许多年前地球上最后一个已知的超大陆的名称。 该名称由希腊语pan和Gaia组成,意思是“全自然”或“全地”。
我们保护免受不可能的卡
我们只需继续耕种土地直到达到所需的土地质量,即可产生适量的土地。 之所以可行,是因为我们迟早会在水位上抬高每个单元格。 但是,使用地图边框时,我们无法到达每个单元。 当需要的土地百分比过高时,这将导致发电机无休止的“尝试和失败”以耕种更多的土地,并且将陷入无休止的循环。 在这种情况下,应用程序将冻结,但这不会发生。
我们无法事先可靠地找到不可能的配置,但是我们可以保护自己免受无尽的循环。 我们将简单地跟踪
CreateLand
执行的周期数。 如果迭代次数太多,那么我们很可能会停滞不前,应该停止。
对于大型地图,一千次迭代似乎是可以接受的,而一万次迭代似乎已经是荒谬的。 因此,让我们将此值用作终止点。
void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
如果我们得到了损坏的地图,则执行10,000次迭代将不会花费很多时间,因为许多单元会迅速达到最大高度,这将阻止新区域的增长。
即使打破循环,我们仍然可以获得正确的地图。 它只是没有适当数量的寿司,而且看起来也不会很有趣。 让我们在控制台中显示有关此的通知,让我们知道我们没有花费剩余的土地。
void CreateLand () { … if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); } }
卡片边框为10的土地中有95%无法使用全部金额。为什么出故障的卡仍然有变化?海岸线具有可变性,因为当创建区域内的高度过高时,新区域将不允许它们向外生长。 相同的原则不允许地块成长为小块土地,直到它们达到最大高度并完全消失为止。 此外,降低曲线图时,变异性会增加。
统一包装分区卡
现在我们有了地图边框,我们基本上将地图分为两个单独的区域:边界区域和创建绘图的区域。 由于仅创建区域对我们很重要,因此我们可以将这种情况视为一个区域的情况。 该区域根本无法覆盖整个地图。 但是,如果这不可能,那么就没有什么可以阻止我们将地图划分为几个相互独立的土地创造区域。 这将使土地块彼此独立地形成,从而指定了不同的大陆。
地图区域
让我们从将地图的一个区域描述为一个结构开始。 这将简化我们在多个地区的工作。 让我们为此创建一个
MapRegion
结构,该结构仅包含区域的边界字段。 由于我们不会在
HexMapGenerator
之外使用此结构,
HexMapGenerator
可以在此类内部将其定义为私有内部结构。 然后,可以将四个整数字段替换为一个
MapRegion
字段。
为了使一切正常,我们需要将
region.
前缀添加到
GenerateMap
的minimum-maximum字段中
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) ); }
几个地区
要支持多个区域,请用区域列表替换一个
MapRegion
字段。
此时,最好添加一个单独的方法来创建区域。 它应该创建所需的列表,或者清除它(如果已经存在)。 之后,他将像以前一样确定一个区域,并将其添加到列表中。
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
调用此方法,而不会直接创建区域。
为了使
GetRandomCell
可以处理任意区域,请为其提供
MapRegion
参数。
HexCell GetRandomCell (MapRegion region) { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); }
现在,
RaiseTerraion
和
SinkTerrain
应该将相应的区域传递给
GetRandomCell
。 为此,它们每个都还需要一个region参数。
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);
最后,为了准确使用全部土地,我们需要在土地达到零时立即停止该过程。 这可以在该区域周期的任何阶段发生。 因此,我们将零和检查移至内部循环。 实际上,我们只能在土地增加后执行此检查,因为降低土地时,它永远不会花掉。 如果完成,我们可以立即退出
CreateLand
方法。
两个地区
尽管我们现在得到了几个地区的支持,但我们仍然只要求一个地区。 让我们更改
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);
该地图垂直分为两个区域。使用默认设置,将创建两个明显分开的区域,但是,就像一个区域和大地图边框的情况一样,我们不能保证会收到正好两个土地块。 通常是两大洲,可能有几个岛屿。 但是有时在一个区域中可以创建两个或更多大岛。 有时地峡可以将两大洲相连。
当然,我们也可以水平划分地图,改变测量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); }
地图水平分为两个区域。由于我们使用的是宽地图,因此将在水平分隔的情况下创建更宽和更薄的区域。 结果,这些地区更有可能形成几个分割的土地块。
四个地区
让我们自定义区域的数量,创建1到4个区域的支持。
[Range(1, 4)] public int regionCount = 1;
滑块的区域数。我们可以使用
switch
选择相应区域代码的执行。 我们首先重复一个区域的代码(默认情况下会使用),然后保留案例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语句的替代方法。 开关应用于变量,标签用于指示需要执行哪些代码。 还有一个
default
标签,用作最后一个
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; } }
四个地区。此处使用的方法是分割地图的最简单方法。 它按土地质量生成大致相同的区域,其可变性受地图生成的其他参数控制。 但是,将卡一分为二总是很明显的。 我们需要的控制越多,结果看起来就越有机。 因此,如果您需要大约相等的游戏区域,这是正常的。 但是,如果您需要最多样且无限的土地,则必须在一个地区的帮助下实现。
此外,还有其他分割地图的方法。 我们不能只限于直线。 我们甚至不必使用相同大小的区域,也不必使用它们覆盖整个地图。 我们可以留下漏洞。 您还可以允许区域的交叉点或更改区域之间的土地分布。 您甚至可以为每个区域设置自己的生成器参数(尽管这比较复杂),例如,在地图上有一个大洲和一个群岛。
统一包装侵蚀
到目前为止,我们生成的所有卡片看上去都很粗鲁和破碎。
真正的浮雕可能看起来像这样,但是随着时间的流逝,它会变得越来越光滑,其尖锐的部分会由于腐蚀而变钝。为了改善地图,我们可以应用这种侵蚀过程。我们将在使用不同方法创建粗糙土地后执行此操作。 public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); SetTerrainType(); … } … void ErodeLand () {}
侵蚀率
时间越久,侵蚀越多。因此,我们希望侵蚀不是永久的,而是可定制的。侵蚀至少为零,与先前创建的贴图相对应。在最大程度上,侵蚀是全面的,也就是说,进一步施加侵蚀力将不再改变地形。也就是说,腐蚀参数应为0到100之间的百分比,默认情况下为50。 [Range(0, 100)] public int erosionPercentage = 50;
侵蚀滑块。寻找破坏侵蚀的细胞
侵蚀使浮雕更平滑。在我们的案例中,唯一的尖锐部分是悬崖。因此,它们将成为腐蚀过程的目标。如果存在悬崖,则应减少侵蚀,直到最终变成斜坡。我们不会平滑斜坡,因为这会导致无聊的地形。为此,我们需要确定哪些单元格位于悬崖的顶部,并降低其高度。这些将是易腐蚀的细胞。让我们创建一个确定细胞是否易于腐蚀的方法。他通过检查单元的邻居来确定这一点,直到发现足够大的高度差为止。由于悬崖需要至少一两个高度的差异,因此如果一个或多个邻居的距离至少低于其两级,则该单元会受到侵蚀。如果没有这样的邻居,那么细胞就不会受到侵蚀。 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); }
我们不应该只考虑易发生土地侵蚀的细胞吗?. , , .
细胞减少
让我们从一个幼稚的方法开始,并假设简单地降低侵蚀破坏细胞的高度将使其不再容易受到侵蚀。如果这是真的,那么我们可以从列表中随机抽取单元格,降低其高度,然后从列表中删除它们。我们将重复此操作,直到达到所需数量的易受腐蚀的细胞。 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
,我们将覆盖列表中的最后一个当前单元格,然后删除最后一个元素。我们仍然不在乎他们的订单。
天真减少了0%和100%的易腐蚀细胞,种子图1957632474。侵蚀追踪
我们的幼稚方法允许我们施加腐蚀,但不能达到正确的程度。发生这种情况的原因是,高度降低一格后,细胞仍然容易腐蚀。因此,仅当不再受到侵蚀时,我们才将其从列表中删除。 if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); }
100%侵蚀,同时在列表中保留易于侵蚀的细胞。因此我们得到了更强的侵蚀,但是当使用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); } }
省略所有腐蚀的电池。我们节省了很多土地
现在,侵蚀过程可以继续进行,直到所有悬崖都消失。这极大地影响了土地。大多数土地消失了,而我们得到的土地却远远少于所需的百分比。这是因为我们正在从地图上移走土地。真正的侵蚀不会破坏物质。她从一个地方拿走它,然后把它放到另一个地方。我们可以做同样的事情。随着一个单元格的减少,我们必须提高其相邻单元格之一。实际上,一个高度的高度已转移到较低的单元格。这样可以节省总的地图高度,同时只需对其进行平滑即可。为了实现这一点,我们需要确定将腐蚀产物转移到哪里。这将是我们的侵蚀目标。让我们创建一种方法来确定要腐蚀的细胞的目标点。由于此单元格包含一个中断,因此选择位于此中断下的单元格为目标是合乎逻辑的。但是容易腐蚀的单元可能会有几个中断,因此我们将检查所有邻居并将所有候选者都放在一个临时列表中,然后我们将随机选择其中之一。 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%的侵蚀。侵蚀现在可以更好地平滑地形,降低某些区域并提高其他区域。结果,土地的数量既可以增加也可以缩小。这可以在一个方向或另一个方向上将土地的百分比改变百分之几,但是很少发生严重的偏差。就是说,我们施加的侵蚀越多,对最终的土地百分比的控制就越少。加速侵蚀
尽管我们不需要真正在乎腐蚀算法的有效性,但是我们可以对其进行简单的改进。首先,请注意,我们明确检查了侵蚀的细胞是否可以侵蚀。如果不是,那么我们基本上将其从列表中删除。因此,在遍历目标单元格的邻居时,可以跳过对该单元格的检查。 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)
第三,当检查易腐蚀电池的邻居时,我们可以使用类似的技巧。如果它们之间现在有悬崖,那么邻居很容易受到侵蚀。要找出答案,我们不需要致电IsErodible
。 HexCell neighbor = cell.GetNeighbor(d); if ( neighbor && neighbor.Elevation == cell.Elevation + 2 &&
但是,我们仍然需要检查目标细胞是否容易受到侵蚀,但是上面显示的循环不再这样做。因此,我们为目标单元格明确执行此操作。 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%侵蚀。还要注意,尽管海岸的形状发生了变化,但拓扑结构并没有根本改变。地块通常保持连接或分隔状态。只有小岛屿才能完全淹死。浮雕细节得以平滑,但总体形状保持不变。狭窄的关节可能消失,或长出一点。小间隙可能会稍微填满或扩大。因此,侵蚀不会强烈地将分割区域粘在一起。四个完全腐蚀的区域仍然分开。统一包装第25部分:水循环
- 显示原始地图数据。
- 我们形成细胞的气氛。
- 创建水循环的部分模拟。
在这一部分,我们将增加陆地上的湿度。本教程是在Unity 2017.3.0中创建的。我们使用水循环来确定生物群落。乌云
到目前为止,地图生成算法仅更改了像元高度。单元格之间的最大区别是它们是在水的上方还是下方。尽管我们可以定义不同类型的地形,但这只是高度的简单可视化。鉴于当地气候,最好指定救济的类型。地球的气候是一个非常复杂的系统。幸运的是,我们不需要创建现实的气候模拟。我们将需要看起来自然的东西。气候最重要的方面是水循环,因为动植物需要液态水才能生存。温度也非常重要,但就目前而言,我们专注于水,基本上使全球温度保持不变,仅改变湿度。水循环描述了环境中水的运动。简而言之,池塘蒸发了,这导致了雨水的形成,然后又流进了池塘。该系统还有很多其他方面,但是模拟这些步骤可能已经足够在地图上创建自然的水分布。数据可视化
在进行此模拟之前,直接查看相关数据将很有用。为此,我们将更改Terrain着色器。我们为其添加了一个switchable属性,可以将其切换到数据可视化模式,该模式显示原始地图数据而不是通常的浮雕纹理。这可以通过使用float属性实现,该属性具有可定义关键字的可切换属性。因此,它将在物料检查器中显示为控制关键字定义的标志。属性本身的名称并不重要,我们只对关键字感兴趣。我们正在使用SHOW_MAP_DATA。 Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _GridTex ("Grid Texture", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Specular ("Specular", Color) = (0.2, 0.2, 0.2) _BackgroundColor ("Background Color", Color) = (0,0,0) [Toggle(SHOW_MAP_DATA)] _ShowMapData ("Show Map Data", Float) = 0 }
切换到显示地图数据。添加着色器功能以启用关键字支持。 #pragma multi_compile _ GRID_ON #pragma multi_compile _ HEX_MAP_EDIT_MODE #pragma shader_feature SHOW_MAP_DATA
我们将使它显示一个浮点数,其余的浮雕数据也是如此。为了实现这一点,我们将在定义关键字时在结构中添加一个Input
字段mapData
。 struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; float4 visibility;
在顶点程序中,我们使用这些像元的Z通道来填充mapData
,就像通常在像元之间插值一样。 void vert (inout appdata_full v, out Input data) { … #if defined(SHOW_MAP_DATA) data.mapData = cell0.z * v.color.x + cell1.z * v.color.y + cell2.z * v.color.z; #endif }
当您需要显示单元格数据时,请将其直接用作反照率片段而不是通常的颜色。将其乘以网格,以便在渲染数据时仍可以打开网格。 void surf (Input IN, inout SurfaceOutputStandardSpecular o) { … o.Albedo = c.rgb * grid * _Color * explored; #if defined(SHOW_MAP_DATA) o.Albedo = IN.mapData * grid; #endif … }
实际将数据传输到着色器。我们需要添加HexCellShaderData
将一些内容写入蓝色纹理数据通道的方法。数据是单个浮点值,限制为0-1。 public void SetMapData (HexCell cell, float data) { cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 255f) : (byte)255); enabled = true; }
但是,此决定会影响研究系统。蓝色通道数据值255用于指示单元可见性处于过渡状态。为了使该系统继续运行,我们需要最大使用字节值254。请注意,分队的移动将擦除所有卡数据,但这很适合我们,因为它们用于调试卡生成。 cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 254f) : (byte)254);
在中添加一个具有相同名称的方法HexCell
。它将请求传输到其着色器数据。 public void SetMapData (float data) { ShaderData.SetMapData(this, data); }
为了检查代码的操作,我们对其进行了更改,HexMapGenerator.SetTerrainType
以便它设置地图的每个单元格的数据。让我们可视化从0到1的间隔中从整数转换为浮点的高度。这是通过从像元高度中减去最小高度,然后除以最大高度减去最小高度来完成的。让我们做除法浮点数。 void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData( (cell.Elevation - elevationMinimum) / (float)(elevationMaximum - elevationMinimum) ); } }
现在,我们可以使用Terrain材质资产的Show Map Data复选框在正常地形和数据可视化之间切换。地图1208905299,正常地形和高度可视化。气候创造
为了模拟气候,我们需要跟踪气候数据。由于地图由离散的单元格组成,因此每个单元格都有自己的局部气候。创建一个结构ClimateData
来存储所有相关数据。当然,您可以将数据添加到单元格本身,但是仅在生成地图时才使用它们。因此,我们将分别保存它们。这意味着我们可以在内部定义此结构HexMapGenerator
,例如MapRegion
。我们将从仅跟踪云开始,这可以使用单个float字段实现。 struct ClimateData { public float clouds; }
添加列表以跟踪所有单元格的气候数据。 List<ClimateData> climate = new List<ClimateData>();
现在我们需要一种创建气候图的方法。首先应清除气候区列表,然后为每个单元格添加一个元素。初始气候数据仅为零,这可以使用标准构造函数实现ClimateData
。 void CreateClimate () { climate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); } }
在设定救济类型之前,应先暴露于土地侵蚀后再创造气候。实际上,侵蚀主要是由空气和水的运动引起的,而空气和水的运动是气候的一部分,但是我们不会对此进行模拟。 public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); SetTerrainType(); … }
进行更改,SetTerrainType
以便我们可以查看云数据而不是像元高度。最初,它看起来像一张黑牌。 void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData(climate[i].clouds); } }
气候变化
气候模拟的第一步是蒸发。应该蒸发多少水?让我们使用滑块控制此值。值为0表示无蒸发,值为1-最大蒸发。默认情况下,我们使用0.5。 [Range(0f, 1f)] public float evaporation = 0.5f;
蒸发滑块。让我们创建另一种专门用于塑造一个单元格气候的方法。我们给它提供细胞指数作为参数,并用它来获得相应的细胞及其气候数据。如果电池在水下,那么我们正在处理必须蒸发的水库。我们立即将蒸气变成云(忽略露点和凝结),因此我们将蒸发直接添加到细胞云的值上。完成此操作后,将气候数据复制回列表中。 void EvolveClimate (int cellIndex) { HexCell cell = grid.GetCell(cellIndex); ClimateData cellClimate = climate[cellIndex]; if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } climate[cellIndex] = cellClimate; }
为中的每个单元格调用此方法CreateClimate
。 void CreateClimate () { … for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } }
但这还不够。要创建复杂的模拟,我们需要多次改变细胞的气候。我们这样做的次数越多,结果就会越好。让我们选择一个恒定值。我用40个周期。 for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } }
因为虽然我们只增加了被水淹没的细胞上方的云的价值,但结果却得到了黑土地和白水库。经水蒸发。云散
云并不是一直存在于一个地方,尤其是当越来越多的水蒸发时。压差使空气移动,空气以风的形式表现出来,这也使云层移动。如果没有主导风向,则平均而言,细胞云将均匀分散在所有方向上,并出现在相邻的细胞中。在下一个周期中生成新的云时,让我们将单元中的所有云分布到其邻居中。即,每个邻居从单元云中接收到六分之一,然后本地减少到零。 if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float cloudDispersal = cellClimate.clouds * (1f / 6f); cellClimate.clouds = 0f; climate[cellIndex] = cellClimate;
要真正向邻居添加云,您需要循环绕过它们,获取其气候数据,增加云的价值并将其复制回列表中。 float cloudDispersal = cellClimate.clouds * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = climate[neighbor.Index]; neighborClimate.clouds += cloudDispersal; climate[neighbor.Index] = neighborClimate; } cellClimate.clouds = 0f;
零星的云。这会创建一张几乎是白色的地图,因为在每个周期中,水下细胞都会为全球气候添加越来越多的云。在第一个周期之后,靠近水的陆地细胞也将具有需要分散的云。这个过程一直持续到大部分地图被云覆盖为止。在使用默认参数的地图1208905299的情况下,只有东北大片土地的内部仍被完全覆盖。请注意,池塘可以产生无限数量的云。水位不是气候模拟的一部分。实际上,保存水库的原因仅在于水以大约蒸发的速度流回水库。也就是说,我们仅模拟部分水循环。这是正常现象,但是我们必须了解,模拟进行的时间越长,向气候中添加的水就越多。到目前为止,水的流失仅发生在地图的边缘,由于缺少邻居,因此散布的云消失了。您可以在地图的顶部看到水的流失,尤其是在右上方的单元格中。在最后一个单元中根本没有云,因为它仍然是形成气候的最后一个单元。她尚未收到邻居的乌云。所有细胞的气候不应该平行形成吗?, . - , . 40 . - , .
降水
水不会永远保持低温。在某个时候,她应该再次跌倒。通常以降雨的形式发生,但有时可能是下雪,冰雹或湿雪。所有这些通常称为降水。云消失的幅度和速率变化很大,但是我们仅使用自定义的全球降雨速率。值为0表示没有降水,值为1表示所有云都会立即消失。默认值为0.25。这意味着在每个周期中,四分之一的云将消失。 [Range(0f, 1f)] public float precipitationFactor = 0.25f;
降水系数滑块。我们将模拟蒸发之后和云散布之前的降水。这将意味着从水库蒸发的部分水立即沉淀,因此散布的云团数量减少。在陆地上,降水将导致云层消失。 if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; float cloudDispersal = cellClimate.clouds * (1f / 6f);
消失的云。现在,当我们在每个周期中销毁25%的云时,土地再次几乎是黑色的。云只向内陆移动了几步,之后便变得不可见了。统一包装湿度
尽管降雨会破坏云层,但它们不应从气候中去除水分。跌落到地面后,仅以不同的状态保存水。它可以多种形式存在,我们通常会考虑水分。湿度追踪
我们将通过跟踪两个水条件(云和湿度)来改善气候模型。要实现此目的,请在ClimateData
字段中添加moisture
。 struct ClimateData { public float clouds, moisture; }
蒸发是最广义的形式,至少在我们简单的气候模型中,是将水分转化为云的过程。这意味着蒸发量不应为恒定值,而应为另一个因素。因此,重命名refactor- evaporation
在evaporationFactor
。 [Range(0f, 1f)] public float evaporationFactor = 0.5f;
当电池在水下时,我们只需宣布湿度为1。这意味着蒸发等于蒸发系数。但是现在我们也可以从寿司池中蒸发掉。在这种情况下,我们需要计算蒸发量,从湿度中减去蒸发量,然后将结果加到云中。之后,将沉淀添加至湿度。 if (cell.IsUnderwater) { cellClimate.moisture = 1f; cellClimate.clouds += evaporationFactor; } else { float evaporation = cellClimate.moisture * evaporationFactor; cellClimate.moisture -= evaporation; cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation;
由于云层现在受到陆地上方蒸发的支持,因此我们可以将其移动到更内陆。现在,大部分土地都变成了灰色。乌云随着湿度蒸发。让我们对其进行更改SetTerrainType
,使其显示湿度而不是云,因为我们将使用它来确定浮雕的类型。 cell.SetMapData(climate[i].moisture);
湿度显示。在这一点上,湿度看起来与云非常相似(除了所有水下细胞都是白色的),但是这种情况很快就会改变。降雨径流
蒸发不是水分离开细胞的唯一途径。水循环告诉我们,添加到土地的大部分水分最终以某种方式最终落入水中。最引人注目的过程是在重力作用下水在陆地上的流动。我们不会模拟真实的河流,而是使用自定义的降雨径流系数。它将指示排水到较低区域的百分比。让我们默认情况下,库存将等于25%。 [Range(0f, 1f)] public float runoffFactor = 0.25f;
排水滑块。径流的作用就像云层的散布,但有三个区别。首先,不是所有的水分都从电池中去除。其次,它携带水分,而不是云。第三,它下降,即仅下降到较低高度的邻居。径流系数描述了如果所有邻居都较低但通常更少的情况下从池中倒出的水分量。这意味着只有在下面找到邻居时,我们才会降低电池湿度。 float cloudDispersal = cellClimate.clouds * (1f / 6f); float runoff = cellClimate.moisture * runoffFactor * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = climate[neighbor.Index]; neighborClimate.clouds += cloudDispersal; int elevationDelta = neighbor.Elevation - cell.Elevation; if (elevationDelta < 0) { cellClimate.moisture -= runoff; neighborClimate.moisture += runoff; } climate[neighbor.Index] = neighborClimate; }
排水至较低的高度。结果,我们的湿度分布更加多样化,因为高细胞将水分传递到较低的位置。我们还看到沿海单元中的水分少得多,因为它们将水分排入水下单元。为了减弱这种影响,我们还需要在确定单元格是否较低时使用水位,即取视在高度。 int elevationDelta = neighbor.ViewElevation - cell.ViewElevation;
使用可见高度。渗水
水不仅向下流动,而且扩散,渗入水位地形,并被邻近水体的土地吸收。该效果可能影响不大,但是对于平滑湿度分布很有用,因此我们将其添加到仿真中。让我们创建他自己的自定义系数,默认情况下等于0.125。 [Range(0f, 1f)] public float seepageFactor = 0.125f;
泄漏滑块。渗漏类似于排水沟,除了在邻居的可见高度与小区本身的可见高度相同时使用。 float runoff = cellClimate.moisture * runoffFactor * (1f / 6f); float seepage = cellClimate.moisture * seepageFactor * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … int elevationDelta = neighbor.ViewElevation - cell.ViewElevation; if (elevationDelta < 0) { cellClimate.moisture -= runoff; neighborClimate.moisture += runoff; } else if (elevationDelta == 0) { cellClimate.moisture -= seepage; neighborClimate.moisture += seepage; } climate[neighbor.Index] = neighborClimate; }
增加了一点泄漏。统一包装雨影
尽管我们已经创建了一个值得水循环的模拟,但它看起来并不十分有趣,因为它没有雨影,而雨影最清楚地表明了气候差异。与邻近地区相比,降雨阴影是降雨严重缺乏的地区。之所以存在这些区域,是因为山脉阻止了云层的传播。他们的创作需要高山和主导的风向。风
首先,向模拟添加主要的风向。尽管主要的风向在地球表面变化很大,但我们将通过可自定义的全球风向进行管理。让我们默认使用西北。此外,让我们将风力从1调整为10,默认值为4。 public HexDirection windDirection = HexDirection.NW; [Range(1f, 10f)] public float windStrength = 4f;
风的方向和强度。优势风的强度相对于云层的总散布来表示。如果风力为1,则在所有方向上的散射都是相同的。当它为2时,在风向的散射比在其他方向的散射高两个,依此类推。我们可以通过更改云散布公式中的除数来实现。而不是六个,它将等于五个加风力。 float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
另外,风的方向决定了风的吹向。因此,我们需要使用相反的方向作为散射的主要方向。 HexDirection mainDispersalDirection = windDirection.Opposite(); float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
现在我们可以检查邻居是否在散射的主要方向上。如果是这样,那么我们必须将云的散射乘以风的力量。 ClimateData neighborClimate = climate[neighbor.Index]; if (d == mainDispersalDirection) { neighborClimate.clouds += cloudDispersal * windStrength; } else { neighborClimate.clouds += cloudDispersal; }
西北风,风4。主风为陆地上的水分分布增加了方向性。风越强,效果越强。绝对高度
增加雨影的第二个因素是山脉。我们对山是没有严格的分类,就像大自然也没有一样。仅绝对高度很重要。实际上,当空气在山上移动时,它被迫上升,被冷却并可能包含较少的水,这导致空气在山上通过之前发生降水。结果,另一方面,我们得到干燥的空气,即雨影。最重要的是,空气上升得越多,所含的水就越少。在我们的模拟中,我们可以将其想象为每个单元的最大云值的强制限制。可见像元高度越高,此最大值应越低。最简单的方法是将最大值设置为1减去表观高度,再除以最大高度。但实际上,让我们除以最大负1。这将使一小部分云甚至可以穿过最高的单元。我们在计算降水之后和散射之前分配此最大值。 float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation; float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); HexDirection mainDispersalDirection = windDirection.Opposite();
如果结果是我们得到的云多于可接受的程度,那么我们只需将多余的云转换为湿度。实际上,这就是我们增加额外降水的方式,因为它发生在真实的山区。 float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); if (cellClimate.clouds > cloudMaximum) { cellClimate.moisture += cellClimate.clouds - cloudMaximum; cellClimate.clouds = cloudMaximum; }
高空造成的雨影。统一包装我们完成模拟
在这一阶段,我们已经对水循环进行了非常高质量的部分模拟。让我们对其进行一点排序,然后将其应用于确定单元浮雕的类型。并行运算
如前所述,扰流板的形成顺序会影响模拟结果。理想情况下,不应该这样,从本质上讲,我们并行形成所有单元。这可以通过将当前形成阶段的所有变化应用于第二种气候清单来完成nextClimate
。 List<ClimateData> climate = new List<ClimateData>(); List<ClimateData> nextClimate = new List<ClimateData>();
像其他所有人一样,清除并初始化此列表。然后,我们将在每个周期交换列表。在这种情况下,模拟将交替使用这两个列表,并应用当前和下一个气候数据。 void CreateClimate () { climate.Clear(); nextClimate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(initialData); } for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } List<ClimateData> swap = climate; climate = nextClimate; nextClimate = swap; } }
当一个细胞影响邻居的气候时,我们必须更改以下气候数据,而不是当前的。 for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = nextClimate[neighbor.Index]; … nextClimate[neighbor.Index] = neighborClimate; }
我们没有将以下气候数据复制回当前气候列表,而是获取了以下气候数据,将当前湿度添加到它们中,然后全部复制到下一个列表中。之后,我们重置当前列表中的数据,以便在下一个周期更新。
在执行此操作时,我们还要将湿度级别设置为最大1,以使陆地细胞不会比水下更湿。 nextCellClimate.moisture += cellClimate.moisture; if (nextCellClimate.moisture > 1f) { nextCellClimate.moisture = 1f; } nextClimate[cellIndex] = nextCellClimate;
并行计算。原始湿度
该模拟可能会产生过多的旱地,尤其是在土地比例较高的情况下。为了改善图片质量,我们可以添加默认值0.1的自定义初始湿度水平。 [Range(0f, 1f)] public float startingMoisture = 0.1f;
上面是原始湿度的滑块。我们将此值用于初始气候列表的湿度,但不用于以下内容。 ClimateData initialData = new ClimateData(); initialData.moisture = startingMoisture; ClimateData clearData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(clearData); }
具有原始湿度。定义生物群落
我们通过使用湿度而不是高度来指定细胞释放的类型来得出结论。让我们在完全干燥的土地上使用雪,在干旱地区,我们使用雪,然后是石头,足够潮湿的草,以及用于水饱和和水下细胞的土地。最简单的方法是以0.2为增量使用五个间隔。 void SetTerrainType () { for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float moisture = climate[i].moisture; if (!cell.IsUnderwater) { if (moisture < 0.2f) { cell.TerrainTypeIndex = 4; } else if (moisture < 0.4f) { cell.TerrainTypeIndex = 0; } else if (moisture < 0.6f) { cell.TerrainTypeIndex = 3; } else if (moisture < 0.8f) { cell.TerrainTypeIndex = 1; } else { cell.TerrainTypeIndex = 2; } } else { cell.TerrainTypeIndex = 2; } cell.SetMapData(moisture); } }
生物群落。使用均匀分布时,效果不是很好,并且看起来不自然。最好使用其他阈值,例如0.05、0.12、0.28和0.85。 if (moisture < 0.05f) { cell.TerrainTypeIndex = 4; } else if (moisture < 0.12f) { cell.TerrainTypeIndex = 0; } else if (moisture < 0.28f) { cell.TerrainTypeIndex = 3; } else if (moisture < 0.85f) { cell.TerrainTypeIndex = 1; }
修改过的生物群落。统一包装第26部分:生物群落和河流
- 我们创建的河流源于湿度高的单元格。
- 我们创建一个简单的温度模型。
- 我们将生物群系矩阵用于细胞,然后对其进行更改。
在这一部分中,我们将用河流和温度补充水循环,并为细胞分配更多有趣的生物群落。该教程是使用Unity 2017.3.0p3创建的。热量和水使地图更加生动。河流产生
河流是水循环的结果。实际上,它们是由径流在渠道侵蚀的帮助下流失而形成的。这意味着您可以根据单元排水量的值添加河流。但是,这不能保证我们会得到类似于真实河流的东西。当我们下河时,它必须尽可能地流过,可能会穿过许多单元。这与我们并行处理细胞的水循环模拟不一致。另外,通常需要控制地图上的河流数量。由于河流截然不同,我们将分别生成它们。我们使用水循环模拟的结果来确定河流的位置,但是河流反过来不会影响模拟。为什么有时候河水错了?TriangulateWaterShore
, . , . , , . , . , , . («»).
void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary( e1, e2, cell.HasIncomingRiver && cell.IncomingRiver == direction, indices ); } … }
高湿度电池
在我们的地图上,一个像元可能有也可能没有河。此外,它们可以分支或连接。实际上,河流要灵活得多,但是我们必须顺应这种近似,它只能创建大河。最重要的是,我们需要确定一条随机选择的大河的起点。由于河流需要水,因此河流的源头必须位于湿度较高的牢房中。但这还不够。河流顺着山坡流下,因此理想情况下,水源应具有较高的高度。高于水位的像元越高,就越适合作为河源的作用。通过将像元高度除以最大高度,我们可以将其可视化为地图数据。为了获得相对于水位的结果,我们将在划分之前从两个高度中减去它。 void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … float data = (float)(cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data); } }
湿度和高度。具有默认设置的大型地图,编号为1208905299。最佳候选者是同时具有高湿度和高高度的那些单元。我们可以通过相乘来组合这些条件。结果将是河流来源的适应度或权重值。 float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data);
河流来源的权重。理想情况下,我们将使用这些权重拒绝源单元的随机选择。尽管我们可以创建具有正确权重的列表并从中进行选择,但这是不平凡的方法,它会减慢生成过程的速度。将重要性分为四个级别的简单分类就足够了。第一个候选者将是权重大于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); }
河源权重的类别。通过这种分类方案,我们很可能获得河流源头在地图最高和最湿润的地区。然而,仍然存在在相对干燥或低洼地区形成河流的可能性,这增加了可变性。添加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++) { …
河点
我们需要多少条河流?此参数必须可自定义。由于河流的长度各不相同,因此在河流点的帮助下对其进行控制将更加合乎逻辑,河流点决定了河流应包含的陆地单元的数量。让我们将它们表示为百分比,最大为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; } }
在内部,CreateRivers
河流点的数量现在可以像在中一样计算CreateLand
。 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."); }
另外,我们添加了直接创建河流的方法。作为参数,他需要一个初始单元格,完成后必须返回河流的长度。我们从存储返回零长度的方法开始。 int CreateRiver (HexCell origin) { int length = 0; return length; }
我们将在刚刚添加的循环结束时调用此方法CreateRivers
,以减少剩余点的数量。我们确保仅在选定单元格没有河流流过的情况下才创建新河流。 while (riverBudget > 0 && riverOrigins.Count > 0) { … if (!origin.HasRiver) { riverBudget -= CreateRiver(origin); } }
当前河流
创造出流向大海或其他水域的河流是合乎逻辑的。从源头开始,我们立即获得长度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 =
使用这种新方法,我们可能有零个可用流向。发生这种情况时,河水将不再流动,必须终止。如果此时长度为1,则意味着我们无法从原始单元中泄漏,也就是说,根本没有河流。在这种情况下,河的长度为零。 flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (flowDirections.Count == 0) { return length > 1 ? length : 0; }
保存完好的河流。快下来
现在,我们保存已经创建的河流,但是仍然可以获取河流的孤立片段。这是因为我们忽略了高度。每次我们迫使河流流向更高的高度时,都会HexCell.SetOutgoingRiver
中断这种尝试,从而导致河流破裂。因此,我们还需要跳过导致河流向上流动的方向。 if (!neighbor || neighbor.HasRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } flowDirections.Add(d);
河流顺流而下。因此,我们摆脱了许多河流碎片,但仍然存在。从这一刻起,摆脱最丑陋的河流就变得很精致。首先,河流倾向于尽可能快地流下。他们不一定会选择最短的路线,但是这样做的可能性很大。为了模拟这一点,我们将方向添加了3次到列表中。 if (delta > 0) { continue; } if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } flowDirections.Add(d);
避免急转弯
除了流下来,水还具有惯性。与突然急转弯相比,河流更可能径直或略微弯曲。我们可以通过跟踪河流的最后方向来添加这种失真。如果电流的电位方向与该方向的偏离不大,则将其重新添加到列表中。对于来源而言,这不是问题,因此我们将始终将其再次添加。 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; }
这大大降低了河流之字形看上去难看的可能性。更少的急转弯。河流汇合
有时,事实证明这条河就在先前创建的河源旁边流动。如果这条河的水源不在更高的高度,那么我们可以决定新河流入老河。结果,我们得到了一条长长的河,而不是两条相邻的河。为此,只有在其中有入水河流或它是当前河流的源头时,我们才会让邻居通过。确定该方向没有朝上后,我们检查是否有流出的河流。如果有的话,我们又找到了旧河。由于这种情况很少发生,因此我们将不参与检查其他邻近水源,并将立即合并河流。 HexCell neighbor = cell.GetNeighbor(d);
汇流前后的河流。保持距离
由于通常将源角色的合适人选组合在一起,因此我们将获得河流集群。另外,我们可能有河流将水源紧接在水库旁边,导致河流的长度为1。我们可以分配水源,丢弃河流或水库附近的水源。我们这样做是在循环中绕过选定源的邻居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); } }
尽管河流仍将彼此并排,但它们往往会覆盖更大的区域。没有距离并且有距离。我们以湖结束河
并非所有的河流都到达水库,有些河流被卡在山谷中或被其他河流阻塞。这不是一个特别的问题,因为通常真实的河流似乎也消失了。例如,如果它们流到地下,散布在沼泽地或干out,就会发生这种情况。我们的河流无法可视化,因此只能结束。但是,我们可以尝试减少此类情况的数量。尽管我们不能团结河流或使河流流淌,但我们可以使它们流向湖泊,而湖泊经常在现实中发现并且看起来不错。为此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 (flowDirections.Count == 0) {
没有湖泊而有湖泊的河流尽头。在这种情况下,河流的百分比为20。请注意,现在我们的水下单元格可能高于用于生成地图的水位。它们表示海拔以上的湖泊。其他湖泊
即使我们没有陷入困境,我们也可以创造湖泊。这可能导致河流流入和流出湖泊。如果我们没有陷入困境,那么可以通过升高水位然后升高当前像元的高度,然后降低像元的高度来创建湖泊。这仅在邻居的最小高度至少等于当前单元格的高度时适用。我们在河流循环结束时以及在移至下一个单元之前执行此操作。 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; }
其他湖泊。如何创建一个细胞以上的湖泊呢?, , , . . : . , . , , , .
统一包装温度范围
水只是可以决定细胞生物群落的因素之一。另一个重要因素是温度。尽管我们可以像模拟水一样模拟温度的流动和扩散,但要创建一个有趣的气候,我们只需要一个复杂的因素。因此,让我们保持简单的温度并为每个单元设置温度。温度和纬度
对温度的最大影响是纬度。赤道很热,两极很冷,而且两极之间平滑过渡。让我们创建一个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; }
注意,低温不一定要低于高温。如果需要,可以将它们翻转。半球
现在,如果我们先进行温度测量,就可以模拟南半球,甚至可能模拟北半球。但是,使用单独的配置选项在半球之间切换会更加方便。让我们为其创建一个枚举和一个字段。因此,我们还将添加创建两个半球的选项,默认情况下适用。 public enum HemisphereMode { Both, North, South } public HemisphereMode hemisphere;
半球的选择。如果需要北半球,则可以简单地翻转纬度,将其减去1。要模拟两个半球,两极应在地图的下方和上方,赤道应在中间。您可以通过将纬度加倍来做到这一点,而较低的半球将被正确处理,而较高的半球将具有1到2的纬度。要解决此问题,我们将超过2的纬度从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; }
两个半球。值得一提的是,这可能会创建一个奇异的地图,在该地图中,赤道很冷,两极都很温暖。越冷
除了纬度,温度还受海拔高度显着影响。平均而言,我们爬得越高,就越冷。我们可以像对待河流候选人那样将其变成一个因素。在这种情况下,我们使用像元高度。此外,该指标随高度降低,即等于1减去高度除以相对于水位的最大值。为了使最高级别的指标不会降为零,我们添加了除数。然后使用此指示器缩放温度。 float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); return temperature;
高度会影响温度。温度波动
通过添加随机温度波动,我们可以使温度梯度的简单性不那么明显。使它更逼真的机会很小,但是波动太大,它们看起来会很随意。让我们自定义温度波动的幂,并将其表示为默认值为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; }
最大力作用下不同的温度波动。统一包装生物群落
现在我们有了湿度和温度的数据,我们可以创建一个生物群落矩阵。通过索引此矩阵,我们可以将生物群落分配给所有单元,从而创建比仅使用一个数据维更复杂的景观。生物群落矩阵
气候模型很多,但我们不会使用其中的任何一种。我们将使其变得非常简单,我们仅对逻辑感兴趣。干燥表示沙漠(冷或热),因为我们使用沙子。寒冷和潮湿意味着下雪。湿热意味着很多植被,也就是草。在它们之间,我们将有一个针叶林或苔原,我们将其指定为灰色的地球纹理。 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
中进行定义。到目前为止,它仅包含凹凸索引以及相应的构造方法。 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) };
生物群落矩阵,具有一维数组的索引。生物群落定义
为了确定SetTerrainType
生物群系中的细胞,我们将在循环中的温度和湿度范围内确定所需的基质指数。我们使用它们来获得所需的生物群系并指定细胞形貌的类型。 void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float temperature = DetermineTemperature(cell);
基于生物群落矩阵的救济。生物群落设置
我们可以超越矩阵中定义的生物群落。例如,在矩阵中,所有干燥的生物群落都被定义为沙漠,但并非所有干燥的沙漠都充满了沙子。有许多看起来很不一样的多石沙漠。因此,让我们用石头代替一些沙漠细胞。我们将仅根据高度进行此操作:沙子处于低海拔,通常在上面发现裸露的岩石。假设当电池的高度比最大高度近于水位时,沙子变成石头。这是我们在一开始就可以计算出的岩石沙漠的高度线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; }
雪帽在最大高度。植物
现在,让生物群落决定植物细胞的水平。为此,将其添加到Biome
植物字段并将其包含在构造函数中。 struct Biome { public int terrain, plant; public Biome (int terrain, int plant) { this.terrain = terrain; this.plant = plant; } }
在最冷最干燥的生物群落中,根本没有植物。在所有其他方面,气候越温暖和湿润,植物越多。第二列湿度仅接收最热行的第一级植物,因此为[0,0,0,1]。除雪外,第三列将级别增加一,即[0,1,1,2]。最湿的色谱柱再次增加它们,即结果为[0,2,2,3]。biomes
通过添加工厂配置来更改阵列。 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;
改性植物。水下生物群落
在那一刻之前,我们完全忽略了水下细胞。让我们为它们添加一些变化,并且我们将不会对所有这些对象都使用大地的纹理。一个基于高度的简单解决方案已经足以创建更有趣的图片。例如,让我们在水位以下一级使用草作为单元格。让我们还将草用于水位以上的单元格,即用于河流形成的湖泊。负高度的单元格是深海区域,因此我们使用石头作为它们。所有其他单元仍然接地。 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;
我们有机会使用许多配置选项生成看起来很有趣且自然的随机卡。统一包装第27部分:折叠卡片
- 我们将卡片分为可移动的列。
- 将存储卡在相机中居中。
- 我们崩溃了一切。
在最后一部分中,我们将添加支持以最小化地图,连接东西方边缘。该教程是使用Unity 2017.3.0p3创建的。折叠使世界运转。折叠卡
我们的地图可用于对不同大小的区域进行建模,但始终限于矩形。我们可以创建一个岛或整个大陆,而不是整个星球的地图。行星是球形的,它们没有刚性的边界,不会阻碍其表面的运动。如果您继续向一个方向移动,那么迟早您将返回起点。我们不能在球体周围包裹六边形网格;这种重叠是不可能的。在最佳近似中,使用二十面体拓扑,其中十二个单元必须是五边形。但是,网格可以缠绕圆柱体而没有任何变形或异常。为此,只需连接地图的东西边缘即可。除了换行逻辑外,其他所有内容均保持不变。圆柱体不能很好地近似于球体,因为我们无法建模极点。但这并不能阻止许多游戏的开发人员使用东西向折页来模拟行星地图。极地地区根本不属于游戏区。南北转弯怎么样?, . , , . -, -. .
有两种方法可以实现圆柱形折叠。首先是通过弯曲地图的表面及其上的所有内容以使圆柱图实际上是圆柱形的,从而使东西边缘接触。现在您将不再在平坦的表面上演奏,而是在真实的圆柱体上演奏。第二种方法是保存平面图,并使用隐形传送或复制来折叠。大多数游戏使用第二种方法,因此我们将采用第二种方法。可选折叠
是否需要折叠地图取决于其比例-局部还是行星。通过将折叠设为可选,我们可以使用两者的支持。为此,将新开关添加到“ 创建新地图”菜单,默认情况下折叠处于打开状态。新地图的菜单,其中包含折叠选项。添加到NewMapMenu
用于跟踪选择的字段以及更改选择的方法。让我们在开关状态更改时调用此方法。 bool wrapping = true; … public void ToggleWrapping (bool toggle) { wrapping = toggle; }
当请求一个新的地图时,我们传递最小化选项的值。 void CreateMap (int x, int z) { if (generateMaps) { mapGenerator.GenerateMap(x, z, wrapping); } else { hexGrid.CreateMap(x, z, wrapping); } HexMapCamera.ValidatePosition(); Close(); }
对其进行更改HexMapGenerator.GenerateMap
,使其接受此新参数,然后将其传递给HexGrid.CreateMap
。 public void GenerateMap (int x, int z, bool wrapping) { … grid.CreateMap(x, z, wrapping); … }
代码> HexGrid应该知道我们是否正在折叠,因此请向其添加一个字段并进行CreateMap
设置。其他类应根据是否最小化网格来更改其逻辑,因此我们将使该领域具有一般性。另外,它允许您通过检查器设置默认值。 public int cellCountX = 20, cellCountZ = 15; public bool wrapping; … public bool CreateMap (int x, int z, bool wrapping) { … cellCountX = x; cellCountZ = z; this.wrapping = wrapping; … }
HexGrid
CreateMap
在两个地方拥有自己的电话。我们可以简单地使用它自己的字段作为折叠参数。 void Awake () { … CreateMap(cellCountX, cellCountZ, wrapping); } … public void Load (BinaryReader reader, int header) { … if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z, wrapping)) { return; } } … }
默认情况下,网格折叠开关处于打开状态。保存和加载
由于为每张卡设置了折叠,因此必须保存并装入。这意味着您需要更改文件保存格式,因此请增加中的版本常量SaveLoadMenu
。 const int mapFileVersion = 5;
保存时,HexGrid
只需在地图尺寸后写布尔值折叠值即可。 public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); writer.Write(wrapping); … }
加载时,我们将仅使用正确版本的文件进行读取。如果不同,则该卡是旧卡,因此不应将其最小化。将此信息保存在局部变量中,并将其与折叠的当前状态进行比较。如果不同,则我们无法以与加载其他大小的地图相同的方式重用现有的地图拓扑。 public void Load (BinaryReader reader, int header) { ClearPath(); ClearUnits(); int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } bool wrapping = header >= 5 ? reader.ReadBoolean() : false; if (x != cellCountX || z != cellCountZ || this.wrapping != wrapping) { if (!CreateMap(x, z, wrapping)) { return; } } … }
折叠指标
最小化地图将需要对逻辑进行重大更改,例如,在计算距离时。因此,他们可以触摸没有直接链接到网格的代码。与其将这些信息作为参数传递,不如将其添加到中HexMetrics
。添加一个静态整数,该整数包含与地图宽度匹配的折叠大小。如果它大于零,那么我们正在处理可折叠卡。要验证这一点,请添加一个属性。 public static int wrapSize; public static bool Wrapping { get { return wrapSize > 0; } }
我们需要为每个呼叫设置折叠大小HexGrid.CreateMap
。 public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; HexMetrics.wrapSize = wrapping ? cellCountX : 0; … }
由于这些数据在播放模式下无法重新编译,因此我们将其设置为OnEnable
。 void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; HexMetrics.wrapSize = wrapping ? cellCountX : 0; ResetVisibility(); } }
像元宽度
当使用可折叠卡片时,我们经常不得不处理沿X轴的位置(以单元的宽度衡量)。尽管它可以用于此目的HexMetrics.innerRadius * 2f
,但是如果我们不每次都添加乘法,它将更加方便。因此,让我们添加一个常量HexMetrics.innerDiameter
。 public const float innerRadius = outerRadius * outerToInner; public const float innerDiameter = innerRadius * 2f;
我们已经可以在三个地方使用直径了。首先,HexGrid.CreateCell
在放置新单元格时。 void CreateCell (int x, int z, int i) { Vector3 position; position.x = (x + z * 0.5f - z / 2) * HexMetrics.innerDiameter; … }
其次,HexMapCamera
限制摄像机的位置。 Vector3 ClampPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); … }
并且还在HexCoordinates
从位置到坐标的转换中。 public static HexCoordinates FromPosition (Vector3 position) { float x = position.x / HexMetrics.innerDiameter; … }
统一包装卡居中
当地图不塌陷时,它会清楚地定义东西边缘,因此也有清晰的水平中心。但是,对于折叠卡,一切都不同。它既没有东部,也没有西部边缘,也没有中心。作为替代方案,我们可以假设中心位于相机所在的位置。这将很有用,因为我们希望地图始终以我们的观点为中心。这样,无论我们身在何处,都不会看到地图的东边或西边。映射片段列
为了使地图可视化相对于相机居中,我们需要根据相机的移动来更改元素的放置。如果它向西移动,那么我们需要将当前在东部边缘的东西移到西部边缘。相反的方向也一样。理想情况下,一旦摄像机移至相邻的单元格列,我们应立即将最远的单元格列移至另一侧。但是,我们不必如此精确。相反,我们可以传输整个地图片段。这使我们无需修改网格即可移动地图的各个部分。由于我们同时移动片段的整个列,因此,我们通过为每个组创建一个父列对象来对它们进行分组。为这些对象添加一个数组HexGrid
,我们将在中对其进行初始化CreateChunks
。我们仅将它们用作容器,因此我们只需要跟踪到其组件的链接Transform
。与片段一样,它们的初始位置位于网格坐标的本地原点。 Transform[] columns; … void CreateChunks () { columns = new Transform[chunkCountX]; for (int x = 0; x < chunkCountX; x++) { columns[x] = new GameObject("Column").transform; columns[x].SetParent(transform, false); } … }
现在,片段应成为对应列而不是网格的子代。 void CreateChunks () { … chunks = new HexGridChunk[chunkCountX * chunkCountZ]; for (int z = 0, i = 0; z < chunkCountZ; z++) { for (int x = 0; x < chunkCountX; x++) { HexGridChunk chunk = chunks[i++] = Instantiate(chunkPrefab); chunk.transform.SetParent(columns[x], false); } } }
片段分为几列。由于所有碎片现在都成为了列的子级,CreateMap
因此我们足以直接销毁所有列,而不是碎片。因此,我们将摆脱子碎片。 public bool CreateMap (int x, int z, bool wrapping) { … if (columns != null) { for (int i = 0; i < columns.Length; i++) { Destroy(columns[i].gameObject); } } … }
传送柱
将位置X作为参数添加到HexGrid
新方法中CenterMap
。将位置转换为列索引,将其除以Unity单位中的片段宽度。这将是相机当前所在的列的索引,即它将成为地图的中心列。 public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); }
仅在中心列的索引更改时,更改地图的可视化就足够了。因此,让我们在现场进行跟踪。创建地图时,我们使用默认值-1,以便新地图始终居中。 int currentCenterColumnIndex = -1; … public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; currentCenterColumnIndex = -1; … } … public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); if (centerColumnIndex == currentCenterColumnIndex) { return; } currentCenterColumnIndex = centerColumnIndex; }
现在我们知道了中心列的索引,我们可以通过简单地减去并增加一半列数来确定最小和最大索引。由于我们使用具有奇数列的整数值,因此可以完美地工作。在偶数的情况下,不能有一个完美居中的列,因此索引之一将比必要的距离更远。这会在地图的最远边缘的方向上产生一列偏移,但是对我们来说这不是问题。 currentCenterColumnIndex = centerColumnIndex; int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2;
请注意,这些索引可以为负数,也可以大于自然最大列索引。仅当相机位于地图的自然中心附近时,最小值才为零。我们的任务是移动列,使其对应于这些相对索引。这可以通过更改循环中每列的局部X坐标来完成。 int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2; Vector3 position; position.y = position.z = 0f; for (int i = 0; i < columns.Length; i++) { position.x = 0f; columns[i].localPosition = position; }
对于每一列,我们检查最小索引的索引是否小于。如果是这样,那么它离中心的左侧太远了。他必须传送到地图的另一侧。这可以通过使其X坐标等于地图的宽度来完成。同样,如果列索引大于最大索引,则该列在中心的右侧太远,应传送到另一侧。 for (int i = 0; i < columns.Length; i++) { if (i < minColumnIndex) { position.x = chunkCountX * (HexMetrics.innerDiameter * HexMetrics.chunkSizeX); } else if (i > maxColumnIndex) { position.x = chunkCountX * -(HexMetrics.innerDiameter * HexMetrics.chunkSizeX); } else { position.x = 0f; } columns[i].localPosition = position; }
相机移动
进行更改,HexMapCamera.AdjustPosition
以便在使用可折叠卡时,他改为ClampPosition
致电WrapPosition
。首先,只需使新方法成为WrapPosition
重复方法ClampPosition
,但唯一的区别是:最后,它将调用CenterMap
。 void AdjustPosition (float xDelta, float zDelta) { … transform.localPosition = grid.wrapping ? WrapPosition(position) : ClampPosition(position); } … Vector3 WrapPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); grid.CenterMap(position.x); return position; }
为了使卡片立即居中,我们调用OnEnable
方法ValidatePosition
。 void OnEnable () { instance = this; ValidatePosition(); }
以相机为中心左右移动。尽管我们仍然限制摄影机的移动,但是地图现在尝试相对于摄影机居中,并在必要时传送地图片段的列。在小地图和远程相机的情况下,这是清晰可见的,但是在大地图上,传送的碎片在相机的可视范围之外。显然,只有地图的初始东部和西部边缘是明显的,因为它们之间还没有三角剖分。要折叠相机,我们要取消其X坐标的限制WrapPosition
。取而代之的是,当X坐标小于零时,我们将继续通过地图的宽度增加X坐标,而当X坐标大于地图的宽度时,将减小X坐标。 Vector3 WrapPosition (Vector3 position) {
汇总相机沿地图移动。可折叠着色器纹理
除了三角测量空间外,在游戏模式下最小化相机应该是不可察觉的。但是,发生这种情况时,一半的地形和水会发生视觉变化。发生这种情况是因为我们使用世界上的某个位置来采样这些纹理。碎片的急剧传送会改变纹理的位置。我们可以通过使纹理出现在片段大小倍数的图块中来解决此问题。片段大小是根据中的常量计算得出的HexMetrics
,因此让我们创建HexMetrics.cginc着色器包含文件,并将相应的定义粘贴到其中。基本切片比例是根据片段大小和像元的外半径来计算的。如果您使用其他指标,则需要相应地修改文件。 #define OUTER_TO_INNER 0.866025404 #define OUTER_RADIUS 10 #define CHUNK_SIZE_X 5 #define TILING_SCALE (1 / (CHUNK_SIZE_X * 2 * OUTER_RADIUS / OUTER_TO_INNER))
这使我们的缩放比例为0.00866025404。如果我们使用此值的整数倍,则纹理化将不受片段的隐形传送的影响。此外,在正确地将三角网格的连接正确三角化之后,地图东部和西部边缘的纹理将无缝连接。我们使用0.02作为Terrain着色器中的UV比例。相反,我们可以使用加倍的缩放比例,即0.01732050808。获得的比例比实际要少一些,并且纹理的比例略有增加,但是在视觉上它是不可见的。 #include "../HexMetrics.cginc" #include "../HexCellData.cginc" … float4 GetTerrainColor (Input IN, int index) { float3 uvw = float3( IN.worldPos.xz * (2 * TILING_SCALE), IN.terrain[index] ); … }
在用于UV噪声的Roads着色器中,我们使用0.025的比例。而是可以使用三重平铺比例。这给出了0.02598076212,非常接近。 #include "HexMetrics.cginc" #include "HexCellData.cginc" … void surf (Input IN, inout SurfaceOutputStandardSpecular o) { float4 noise = tex2D(_MainTex, IN.worldPos.xz * (3 * TILING_SCALE)); … }
最后,在Water.cginc中,泡沫使用0.015,波浪使用0.025。在这里,我们可以再次使用两倍和三倍的切片比例替换这些值。 #include "HexMetrics.cginc" float Foam (float shore, float2 worldXZ, sampler2D noiseTex) { shore = sqrt(shore) * 0.9; float2 noiseUV = worldXZ + _Time.y * 0.25; float4 noise = tex2D(noiseTex, noiseUV * (2 * TILING_SCALE)); … } … float Waves (float2 worldXZ, sampler2D noiseTex) { float2 uv1 = worldXZ; uv1.y += _Time.y; float4 noise1 = tex2D(noiseTex, uv1 * (3 * TILING_SCALE)); float2 uv2 = worldXZ; uv2.x += _Time.y; float4 noise2 = tex2D(noiseTex, uv2 * (3 * TILING_SCALE)); … }
统一包装东西方的结合
在此阶段,最小化地图的唯一视觉证据是最东端和最西端的列之间存在很小的差距。之所以会出现此间隙,是因为我们尚未对地图相对两侧的像元之间的边和角的连接进行三角剖分而没有折叠。边缘空间。折邻居
要对东西连接进行三角测量,我们需要使相对两侧的单元彼此相邻。到目前为止,我们还没有这样做,因为HexGrid.CreateCell
仅当前一个单元格在X中的索引大于零时,才会建立E-W连接。要折叠此连接,需要在折叠地图打开时将行的最后一个单元格与同一行中的第一个单元格连接。 void CreateCell (int x, int z, int i) { … if (x > 0) { cell.SetNeighbor(HexDirection.W, cells[i - 1]); if (wrapping && x == cellCountX - 1) { cell.SetNeighbor(HexDirection.E, cells[i - x]); } } … }
建立邻居EW的连接后,我们获得了间隙的部分三角剖分。边缘的连接不是理想的,因为变形被错误地隐藏了。我们稍后会处理。化合物E –W。我们还需要折叠NE – SW链接。这可以通过将每个偶数行的第一个单元格与前一行的最后一个单元格连接来完成。它只是前一个单元格。 if (z > 0) { if ((z & 1) == 0) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX]); if (x > 0) { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX - 1]); } else if (wrapping) { cell.SetNeighbor(HexDirection.SW, cells[i - 1]); } } else { … } }
NE – SW连接。最后,在每个奇数行的第一个以下的末尾建立SE – NW连接。这些单元格必须连接到上一行的第一个单元格。 if (z > 0) { if ((z & 1) == 0) { … } else { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX]); if (x < cellCountX - 1) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX + 1]); } else if (wrapping) { cell.SetNeighbor( HexDirection.SE, cells[i - cellCountX * 2 + 1] ); } } }
化合物SE – NW。噪音折叠
为了完美地隐藏间隙,我们需要确保地图的东边和西边与用于使顶点位置变形的噪声完全匹配。我们可以使用与着色器相同的技巧,但将0.003的噪声比例用于失真。为了确保平铺,您需要显着增加比例,这将导致顶点更混乱的变形。另一种解决方案不是消除泰勒噪声,而是使地图边缘的噪声平滑衰减。如果沿一个像元的宽度执行平滑衰减,则失真将创建平滑过渡而没有间隙。该区域中的噪声将被稍微平滑,并且从远处看,变化看起来会很尖锐,但是当使用顶点的轻微失真时,变化并不明显。如果我们不折叠卡片,那么我们可以得到一个HexMetrics.SampleNoise
样本。但是折叠时有必要增加衰减。因此,在返回样本之前,请将其保存在变量中。 public static Vector4 SampleNoise (Vector3 position) { Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); return sample; }
最小化时,我们需要与第二个样本混合。我们将在地图的东部执行过渡,因此第二个样本需要移至西部。 Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); }
在一个像元的整个宽度上,使用简单的线性插值从西部到东部进行衰减。 if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) ); }
噪声混合,不完美的解决方案结果是,由于东侧的某些像元具有X坐标负值,因此我们无法获得完全匹配的结果。 if (Wrapping && position.x < innerDiameter * 1.5f) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) - 0.5f ); }
正确衰减。单元格编辑
现在,三角剖分似乎正确了,让我们确保可以在地图上和折叠接缝处编辑所有内容。事实证明,在传送的碎片中,坐标是错误的,并且大的刷子被接缝割掉了。刷子被修剪。要解决此问题,我们需要报告HexCoordinates
折叠。我们可以通过在构造方法中匹配X坐标来实现。我们知道,轴向坐标X是从偏移量的X坐标减去Z坐标的一半而获得的,您可以使用此信息执行逆变换,并检查零坐标是否小于零。如果是这样,那么我们的坐标超出了展开地图的东边。由于在每个方向上传送的地图不超过地图的一半,因此将折叠大小一次添加到X就足够了。当偏移坐标大于折叠尺寸时,我们需要进行减法运算。 public HexCoordinates (int x, int z) { if (HexMetrics.Wrapping) { int oX = x + z / 2; if (oX < 0) { x += HexMetrics.wrapSize; } else if (oX >= HexMetrics.wrapSize) { x -= HexMetrics.wrapSize; } } this.x = x; this.z = z; }
有时,在编辑地图的底部或顶部时会出现错误。这是由于顶点的变形导致光标出现在地图外部的单元格行中。这是一个错误,因为我们没有将坐标HexGrid.GetCell
与vector参数匹配。可以通过应用GetCell
以坐标为参数的方法来执行必要的检查来解决此问题。 public HexCell GetCell (Vector3 position) { position = transform.InverseTransformPoint(position); HexCoordinates coordinates = HexCoordinates.FromPosition(position);
沿海折叠
三角剖分在地形上效果很好,但沿东西方的缝隙,水海岸没有边缘。实际上,它们只是不会崩溃。它们被翻转并拉伸到地图的另一侧。缺少水的边缘。发生这种情况是因为在对沿海水域进行三角剖分时,我们使用了邻居的位置。要解决此问题,我们需要确定要处理的内容(位于卡的另一侧)。为了简化任务,我们将HexCell
在索引的属性中添加一个单元格列。 public int ColumnIndex { get; set; }
将此索引分配给HexGrid.CreateCell
。它仅等于偏移坐标X除以片段大小。 void CreateCell (int x, int z, int i) { … cell.Index = i; cell.ColumnIndex = x / HexMetrics.chunkSizeX; … }
现在我们可以HexGridChunk.TriangulateWaterShore
通过比较当前单元格及其邻居的列索引来确定最小化的内容。如果邻居的列的索引小于小一级,那么我们在西侧,而邻居在东侧。因此,我们需要将我们的邻居向西。方向相反。 Vector3 center2 = neighbor.Position; if (neighbor.ColumnIndex < cell.ColumnIndex - 1) { center2.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (neighbor.ColumnIndex > cell.ColumnIndex + 1) { center2.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; }
海岸的肋骨,但没有角落。因此,我们照顾了海岸的肋骨,但到目前为止,还没有处理过弯道。我们需要对下一个邻居做同样的事情。 if (nextNeighbor != null) { Vector3 center3 = nextNeighbor.Position; if (nextNeighbor.ColumnIndex < cell.ColumnIndex - 1) { center3.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (nextNeighbor.ColumnIndex > cell.ColumnIndex + 1) { center3.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; } Vector3 v3 = center3 + (nextNeighbor.IsUnderwater ? HexMetrics.GetFirstWaterCorner(direction.Previous()) : HexMetrics.GetFirstSolidCorner(direction.Previous())); … }
适当减少海岸。卡生成
连接东侧和西侧的选项会影响地图的生成。当最小化地图时,生成算法也应该最小化。这将导致创建另一个地图,但是当使用非零的Map Border X时,折叠并不明显。具有默认设置的大地图1208905299。有折叠和没有折叠。最小化时无厘头使用地图边界的X。但是我们不能仅仅摆脱它,因为同时区域将合并。当最小化时,我们可以只使用RegionBorder。我们改变HexMapGenerator.CreateRegions
,在任何情况下都mapBorderX
被取代borderX
。此新变量将等于或regionBorder
或mapBorderX
,具体取决于折叠选项的值。下面,我仅显示第一种情况的更改。 int borderX = grid.wrapping ? regionBorder : mapBorderX; MapRegion region; switch (regionCount) { default: region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; … }
同时,这些区域保持分离,但是只有在地图的东侧和西侧存在不同的区域时,才有必要。在两种情况下不遵守此规定。首先是当我们只有一个区域时。第二个是当有两个区域将地图水平划分时。在这些情况下,我们可以将borderX
值分配为零,这将使土地块穿越东西缝。 switch (regionCount) { default: if (grid.wrapping) { borderX = 0; } region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; case 2: if (Random.value < 0.5f) { … } else { if (grid.wrapping) { borderX = 0; } region.xMin = borderX; region.xMax = grid.cellCountX - borderX; 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; … }
一个地区正在崩溃。乍看之下,似乎一切正常,但实际上接缝处有缝隙。如果将“ 侵蚀百分比”设置为零,这将变得更加明显。禁用腐蚀后,凸版上的接缝将变得明显。出现缝隙是因为接缝阻止了浮雕碎片的生长。为了确定首先添加的内容,使用了从像元到片段中心的距离,并且地图另一侧的像元可能相距很远,因此它们几乎永远不会打开。当然,这是错误的。我们需要确保我们HexCoordinates.DistanceTo
了解最小化地图。我们计算之间的距离HexCoordinates
,将沿三个轴的绝对距离相加,并将结果减半。沿Z的距离始终是正确的,但是沿Z的折叠可能会影响X和Y的距离。因此,让我们从X + Y的单独计算开始。 public int DistanceTo (HexCoordinates other) {
确定折叠是否会为任意像元创建更短的距离并不是一件容易的事,因此,对于在向西折叠另一个坐标的情况,我们只计算X + Y即可。如果该值小于原始X + Y,则使用它。 int xy = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (HexMetrics.Wrapping) { other.x += HexMetrics.wrapSize; int xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } }
如果这没有导致更短的距离,则可以在另一个方向上变短,因此我们将对其进行检查。 if (HexMetrics.Wrapping) { other.x += HexMetrics.wrapSize; int xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } else { other.x -= 2 * HexMetrics.wrapSize; xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } } }
现在,我们始终可以在可折叠地图上获得最短距离。地形碎片不再被接缝所阻塞,从而使土地块卷曲。正确折叠凸版,没有侵蚀和侵蚀。统一包装环游世界
在考虑了地图生成和三角剖分之后,现在让我们继续检查小队,探索和可见性。测试缝
我们在将小队环游世界时遇到的第一个障碍是地图的边缘,这是无法探索的。卡的接缝无法检查。沿地图边缘的单元格未进行探索,以隐藏地图的突然完成。但是,当地图最小化时,仅应标记北部和南部方格,而不能标记东部和西部。进行更改HexGrid.CreateCell
以考虑到这一点。 if (wrapping) { cell.Explorable = z > 0 && z < cellCountZ - 1; } else { cell.Explorable = x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1; }
救济特征的可见性
现在,让我们检查可见性是否沿接缝起作用。它适用于地形,但不适用于地形对象。看起来像折叠的对象可以看到最后一个没有折叠的单元格。对象的可见性不正确。发生这种情况是因为为使用的纹理折叠模式HexCellShaderData
设置了夹紧模式。要解决该问题,只需将其钳位模式更改为重复即可。但是我们只需要对U的坐标执行此操作,因此Initialize
我们将单独设置wrapModeU
它wrapModeV
。 public void Initialize (int x, int z) { if (cellTexture) { cellTexture.Resize(x, z); } else { cellTexture = new Texture2D( x, z, TextureFormat.RGBA32, false, true ); cellTexture.filterMode = FilterMode.Point;
小队和列
另一个问题是单元尚未崩溃。移动它们所在的列后,这些单元将保留在同一位置。本机未传输并且在错误的一侧。像对片段一样,可以通过对小队的子元素进行分组来解决此问题。首先,我们将不再使它们成为的网格的直接子代HexGrid.AddUnit
。 public void AddUnit (HexUnit unit, HexCell location, float orientation) { units.Add(unit); unit.Grid = this;
由于单位在移动,它们可能会出现在另一列中,也就是说,有必要更改其父代。为了使之成为可能,我们添加了HexGrid
常规方法MakeChildOfColumn
,并将Transform
子元素的组成部分和列索引作为参数传递给它。 public void MakeChildOfColumn (Transform child, int columnIndex) { child.SetParent(columns[columnIndex], false); }
设置属性后,我们将调用此方法HexUnit.Location
。 public HexCell Location { … set { … Grid.MakeChildOfColumn(transform, value.ColumnIndex); } }
这解决了创建单元的问题。但是我们还需要在移动时使它们移动到所需的列。为此,您需要跟踪HexUnit.TravelPath
索引中的当前列。在此方法的开头,它是路径开头的单元格列的索引;如果移动被重新编译中断,则为当前索引。 IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; yield return LookAt(pathToTravel[1].Position);
在每次移动的迭代过程中,我们将检查下一列的索引是否不同,如果不同,则将更改订单的父级。 int currentColumn = currentTravelLocation.ColumnIndex; float t = Time.deltaTime * travelSpeed; for (int i = 1; i < pathToTravel.Count; i++) { … Grid.IncreaseVisibility(pathToTravel[i], VisionRange); int nextColumn = currentTravelLocation.ColumnIndex; if (currentColumn != nextColumn) { Grid.MakeChildOfColumn(transform, nextColumn); currentColumn = nextColumn; } … }
这将使单位可以类似地移动到碎片。但是,在卡片的接缝中移动时,单元尚未折叠。相反,他们突然开始朝错误的方向前进。无论接缝的位置如何,都会发生这种情况,但是最明显的是当它们在整个地图上跳跃时。整个地图上的赛马。在这里,我们可以使用与海岸相同的方法,只有这次,我们才将转向移动的曲线。如果下一列向东旋转,则我们将曲线也向东传送,与另一个方向类似。您需要更改曲线的控制点a
和b
,这也会影响控制点c
。 for (int i = 1; i < pathToTravel.Count; i++) { currentTravelLocation = pathToTravel[i]; a = c; b = pathToTravel[i - 1].Position;
折叠运动。最后要做的是改变小队在其将要进入的第一个像元时的初始转向。如果此单元位于东西缝的另一侧,则该单元的方向将错误。最小化地图时,有两种方法可以查看不完全位于北部或南部的点。您可以向东或向西看。在与到该点最近距离相对应的方向上看是合乎逻辑的,因为它也是移动方向,所以我们在中使用它LookAt
。最小化时,我们将检查沿X轴的相对距离,如果该距离小于地图宽度的负一半,则应向西看,这可以通过将点向西旋转来完成。否则,如果距离超过地图宽度的一半,那么我们必须向东塌陷。 IEnumerator LookAt (Vector3 point) { if (HexMetrics.Wrapping) { float xDistance = point.x - transform.localPosition.x; if (xDistance < -HexMetrics.innerRadius * HexMetrics.wrapSize) { point.x += HexMetrics.innerDiameter * HexMetrics.wrapSize; } else if (xDistance > HexMetrics.innerRadius * HexMetrics.wrapSize) { point.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize; } } … }
因此,我们有一个功能齐全的最小化地图。到此结束了有关六角图的教程系列。如前几节所述,可以考虑其他主题,但它们并非特定于六边形图。也许我会在以后的系列教程中考虑它们。我下载了最后一个程序包,并在“播放”模式下出现转弯错误, Rotation . . . 5.
我下载了最后一个程序包,图形不如屏幕截图中的漂亮. - .
我下载了最后一个软件包,它不断生成相同的卡seed (1208905299), . , Use Fixed Seed .
统一包装