1. 程式人生 > >Unity 六邊形地圖系列(二十四) :地區和侵蝕

Unity 六邊形地圖系列(二十四) :地區和侵蝕

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

機翻+個人潤色

  • 在地圖周圍加一圈水域。
  • 將地圖分割成多個區域。
  • 對懸崖進行侵蝕。
  • 移動土地來平滑地形。

這是關於六邊形地圖的系列教程的第24部分。在上一部分中,我們為程式化生成地圖奠定了基礎。這次我們將限制土地可能出現的地方,並使其受到侵蝕的影響。

這篇教程基於2017.1.0製作

1地圖邊界

因為我們隨機地推動地塊上升,陸地是有可能最終接觸到地圖邊緣的。這可能是不可取的。一幅以水為邊界的地圖包含了一個自然屏障,可以讓玩家遠離邊緣。所以,如果我們能阻止接近邊緣的地塊上升超過水平面,那就太好了。

1.1邊緣大小

允許土地離地圖邊緣有多近?這個問題沒有一個通用的答案,所以讓我們讓它可配置。我們將在HexMapGenerator元件中新增兩個滑動條,一個用於X邊緣的邊框,另一個用於Z邊緣的邊框。這使得在一維中使用更寬的邊界成為可能,或者對單個維度使用一個邊界。讓我們使用0到10個單元格的範圍,其中5個單元格都是預設值。

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

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

地圖邊界拖動條

1.2約束地塊中心點

沒有邊界,所有單元格都是有效的。當邊界生效時,最小有效偏移座標增加,而最大有效座標減少。因為在生成地塊時我們需要知道有效範圍,所以讓我們用四個整數字段跟蹤這個範圍。

int xMin, xMax, zMin, zMax;

在GenerateMap中,在建立土地之前初始化座標限制。我們將使用這些值作為呼叫Random.Range的引數,所以最大值實際上是排他的。沒有邊界,它們等於維度的單元格計數,所以不能- 1。

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

我們不會嚴格執行土地不會出現在邊界線上的規定,因為那樣只會產生過於生硬的邊界。我們將只限制用於開始生成塊的單元格。因此地塊的中心受到邊界的限制,但部分地塊可以延伸到邊界區域。這是通過調整GetRandomCell來完成的,這樣它就可以允許在有效偏移範圍內選擇一個單元格。

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

地圖邊界為0X5,5X5,10X10,0X10

所有的地圖設定都在預設值下時,5的邊框將可靠地防止陸地接觸地圖的邊緣。然而,這並不能百分百的保證。土地有時可以在多個地方觸控接近邊緣。

陸地能否穿越整個邊界區域取決於邊界的大小和最大的地塊的大小。沒有不規則化,地塊是六邊形。半徑為r的全六邊形包含3r^2+3r+1個單元格。如果有半徑等於邊界大小的六邊形,那麼它們就能穿過邊界。半徑為5的完整六邊形包含91個單元格。由於預設的地塊的最大值是每塊100個單元格,這意味著陸地可以彌補5個單元格的差距,特別是在不規則化的時候。為了確保不會發生這種情況,可以減少最大地塊大小或增加邊界大小。

如何推導六邊形區域有多少個單元格?

在半徑為0時,我們處理的是一個單元格。這就是1的來源。在半徑為1時,在中心周圍有6個額外的單元格,所以是6+1。你可以把這6個單元格想象成6個三角形的頂點,它們接觸到中心。在半徑2處,第二行被新增到這些三角形中,所以每個三角形多兩個單元格,總共是6(1+2)+1。在半徑為3的地方,增加了第三行,每個三角形增加了3個單元格,總共是6(1+2+3)+1,以此類推。一般來說,公式是:

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

要清楚地看到這一點,您可以將邊界大小固定為200。因為半徑為8的完整六邊形包含217個單元格,所以是可能觸碰地圖邊緣的。至少在使用預設邊界大小為5時是這樣。將邊界增加到10個將使這種可能性大大降低。

地塊大小固定為200,地圖邊界為5和10

1.3泛大陸

請注意,當您增加地圖邊界同時保持土地百分比不變時,您將強迫土地在較小的區域內形成。因此,預設的大地圖很可能會產生一個大的大陸——超級大陸——可能還有幾個小島。增加邊界的大小將使這更有可能,直到你幾乎可以保證得到一個超級大陸。然而,當土地的比例過高時,大部分可用的區域就會被填滿,最終形成一個看起來很像矩形的陸地。為了防止這種情況的發生,你可以降低土地的比例。

                                   40%的陸地和10的地圖邊界

泛大陸這個名字從何而來?

很久以前,它是地球上最後一個已知的超級大陸的名字。這個名字也被寫成Pangaea。它來源於希臘語單詞pan和Gaia。它的意思是整個大地,或整個大地。

1.4防範生成錯誤的地圖

我們只需要在得到了我們想要的土地之前不斷地提高土塊,就可以得到我們想要的土地數量。這是可行的,因為最終我們可以把每一個單元都升到水面以上。然而,當使用地圖邊框時,不可能所有單元格都被擡升。當期望的土地比例過高時,這將導致生成器永遠試圖提高更多的土地,從而陷入無限迴圈,並最終失敗。這將使我們的應用程式陷入死迴圈,這是不應該發生的。

我們不可能預先發現不可能的配置,但我們可以防止無限迴圈。簡單的記錄下我們在CreateLand中迴圈了多少次。如果我們迭代的次數多得離譜,我們很可能卡住了,應該停止。

對於一個大的地圖,多達1000次的迭代似乎是可以接受的,但是10000次的迭代確實是荒謬的。我們用10000作為截止點。

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

如果我們最終得到一個生成的地圖,那麼就不會花費那麼多時間通過10000次迭代,因為大多數單元會很快達到最大的高度,然後阻止新的塊的增長。

即使在中止迴圈之後,我們仍然有一個有效的地圖。但是它不會有想要的土地數量,也不會看起來很有趣。讓我們記錄一個關於這方面的警告,報告我們還有多少沒有使用土地預算。

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

                               95%的陸地和10單元的地圖邊界,沒有用完土地預算

                                                          為什麼失敗的地圖仍然有多樣性?

海岸線的變化是多樣的,因為一旦地塊的中心海拔變得太高,新的地塊就會被阻止向外生長。同樣的概念可以防止大塊大塊的土地變成小塊的土地,這些土地還沒有達到最高海拔,只是碰巧被忽略了。此外,通過不斷下沉的塊不斷增加多樣性。

 

unitypackage

2.地圖分割槽

現在我們有了一個地圖邊界,我們已經將地圖分成了兩個不同的區域。邊界區域和地塊衍生區域。由於衍生區域才是真正重要的,所以我們可以將其視為一個單區域場景。這個區域並沒有覆蓋整個地圖。但如果可以,那麼我們就可以將地圖切割成多個分離的衍生區域。這樣就有可能迫使多個大陸獨立形成,代表不同的大陸。

2.1地塊生成區域

讓我們用結構體來t表示單個地圖的生成區域。這使得生成多個區域更加容易。為此建立MapRegion結構體,它只包含邊界欄位。由於我們不會在HexMapGenerator外部使用這個結構,所以我們可以將它定義為一個私有的內部結構體。然後,可以用單個MapRegion欄位替換四個整數字段。

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

	MapRegion region;

為了程式正常執行,我們現在必須在在GenerateMap中的min-max欄位前加上字首region.。

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

在GetRandomCell中也做同樣的事情

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

2.2多個區域

為了支援多個區域,請使用區域列表替換單個的MapRegion欄位。

//	MapRegion region;
	List<MapRegion> regions;

在這一點上,新增專門的方法去建立區域是一個好主意。它應該建立所需的列表,如果已經有一個列表,就清除它。然後,像前面那樣定義單個區域並將其新增到列表中。

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

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

在GenerateMap中呼叫此方法,而不是直接建立區域。

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

給GetRandomCell一個MapRegion引數,讓它 在任意區域工作。

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

RaiseTerraion和SinkTerrain方法現在必須將正確的區域傳遞給GetRandomCell。為此,它們也需要每個區域引數。

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

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

CreateLand方法必須確定要為哪個區域提高塊或下沉塊。要平衡區域之間的土地,只需重複迴圈遍歷區域列表。

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

然而,我們也應該注意均勻地分配大塊的下沉。這可以在所有區域迴圈之前來確定是否下沉來。

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

最後,為了確保我們完全用完我們的土地預算,我們必須在預算達到零的時候停止這個過程。這可能發生在區域迴圈中的任何一點。因此,將零預算檢查移動到內部迴圈。實際上,我們可以限制只有在土地已經被提高之後進行這個檢查,因為下沉的地塊永遠不會用完預算。完成後,我們可以直接退出CreateLand方法。

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

2.3兩個區域

雖然我們現在支援多個區域,但仍然只定義了一個區域。讓我們通過調整CreateRegions來改變它,所以它垂直地將地圖一分為二。為此,將新增的區域的xMax值減半,然後對xMin使用相同的值,對xMax再次使用原始值,將其用作第二個區域。

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

做到在這一點後生成地圖的和原來沒有任何區別。儘管我們已經定義了兩個區域,但它們覆蓋的區域與原來的單個區域相同。要把它們分開,我們必須在它們之間留出空隙。為此,我們將新增一個區域邊界的滑塊,使用與地圖邊界相同的範圍和預設值。

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

                                                                   區域邊界拖動條

由於陸地可以在區域之間的空間的任何一邊形成,所以在地圖的邊緣更有可能形成陸橋。為了解決這個問題,我們將使用區域邊界在允許生成地塊的區域之間的分界線中間定義一個不會產生陸地的的區域。這意味著相鄰區域之間的距離是區域邊界大小的兩倍。

要應用區域邊界,從第一個區域的xMax中減去它,並將它新增到第二個區域的xMin中。

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

 

                                                 將地圖垂直分成2個區域

使用預設設定,這將生成具有兩個明顯分開的區域的地圖,儘管就像一個區域和一個大的地圖邊界一樣,我們不能保證得到兩個確切的陸地。大多數時候,它會是兩個大的陸地,可能每個都有幾個島嶼。有時候,一個區域最終會包含兩個或更多的大島。有時這兩個大陸會通過陸橋相連。

當然,也可以水平分割對映,用X和Z軸替換方法。我們隨機選擇兩個可能的方向中的一個。

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

   水平分成兩個區域的地圖

因為我們使用的是寬地圖,水平分割會產生更寬更薄的區域。這就更有可能使一些地區最終形成多個互不相連的大陸塊。

2.4升級到四個區域

讓我們配置區域的數量,支援其中的1到4個區域。

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

區域數量的拖動條

我們可以使用switch語句選擇要執行的正確區域程式碼。首先,重新構造單個區域的程式碼,使用它作為預設值,同時在case 2中保留兩個區域的程式碼。

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

                                                                       什麼是switch語句?

它是編寫一個序列if-else-if-else語句的替代方法。switch應用於變數,標籤用於指示執行哪些程式碼。還有一個預設標籤,它的功能與final else塊類似。每個案例都必須用break語句或return語句終止。

為了保持switch塊的易讀性,通常最好保持用例的簡短,最好是一條語句或一個方法呼叫。對於示例區域程式碼,我沒有這麼做,但如果要建立更有趣的區域,我建議使用單獨的方法。例如:

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

三個區域的工作原理與兩個區域相似,只是我們使用了三分之二而不是二分之一。在這種情況下,水平分割會產生過窄的區域,所以我們只支援垂直分割。還要注意,我們最終得到的區域邊界空間是兩個區域的兩倍,因此生成塊的空間比兩個區域的要少。

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

三個區域

四個區域可以通過合併水平和垂直分割完成,在地圖的每個角落建立一個區域。

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

                                                     四個區域

我們在這裡使用的方法是劃分地圖的最直接的方法。它生成的區域在陸地上大致相等,其多樣性可以通過其他地圖生成設定來控制。然而,至少相當明顯的是,地圖是沿著直線分割的。你想要的控制越多,結果就越不自然。因此,出於遊戲玩法的原因,如果你需要多個相當平等的區域,這是很好的。但如果你想要最多樣化、最自由的土地,你選擇使用一個區域。

話雖如此,還有其他方法來劃分地圖。不用侷限於使用直線作為邊界線。您也不用侷限於使用大小相同的區域,也不需要使用區域覆蓋整個地圖。你也可以留下空洞。你也可以有區域重疊,或者改變區域間的土地分佈。甚至可以為每個區域定義不同的生成器設定(儘管這更復雜),例如,確保地圖同時包含一個大大陸和一個群島。

unitypackage

3.侵蝕

到目前為止,我們生成的所有地圖都顯得相當粗糙和參差不齊。真實的地形可能是這樣的,但隨著時間的推移,它會變得更加平滑和光滑,尖銳的特徵會因為侵蝕而消失。為了改進我們的地圖,我們也應該應用這個侵蝕過程。我們將在建立粗糙的土地之後,用另一種方法來做這件事。

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

3.1侵蝕的百分比

時間過得越久,侵蝕就越嚴重。所以我們想要多少侵蝕不是固定的,它必須是可配置的。至少我們目前生成的地圖的情況是沒有侵蝕的。最大的情況是完全侵蝕,這意味著進一步使用侵蝕力將不再改變地形。所以侵蝕設定應該是從0到100的百分比,我們將使用50作為預設值。

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

侵蝕的拖動條

3.2找到受侵蝕的單元

侵蝕使地形更加光滑。在我們的例子中,唯一真實的地形特徵是懸崖。這些就是侵蝕過程的目標。如果懸崖存在,侵蝕會使其縮小,直到最終變成斜坡。我們不會再把坡度變平,因為那樣會產生無趣的地形。要做到這一點,我們必須弄清楚哪些單元位於懸崖頂上,並降低它們的海拔。這些是我們的可蝕單元。

讓我們建立一個方法來確定單元格是否可侵蝕。它通過檢視單元格的鄰居來完成這項工作,直到找到足夠大的高程差。由於懸崖至少需要兩個高差,因此如果一個或多個相鄰單元至少比它低兩級,單元就會被侵蝕。如果沒有這樣的鄰居,單元就不能被侵蝕。

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

我們可以在ErodeLand中使用此方法迴圈遍歷所有單元格,並在臨時列表中跟蹤所有可蝕單元格。

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

		ListPool<HexCell>.Add(erodibleCells);
	}

一旦我們知道了可蝕單元的總量,我們就可以使用侵蝕百分比來確定應該保留多少可蝕單元。例如,如果百分比是50,那麼我們應該侵蝕單元,直到我們得到原來數量的一半。如果百分比是100,我們不會停止,直到所有的可單元胞消失。

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

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

		ListPool<HexCell>.Add(erodibleCells);
	}

                                                 我們不應該只計算可侵蝕的陸地單元嗎?

水下也會發生侵蝕。侵蝕有不同的型別,但我們不必擔心這些細節,可以使用單一的通用方法。

3.3降低單元

讓我們從天真的假設開始,簡單地降低一個可蝕單元的高度將使它不再可蝕。如果這是真的,我們只需要從列表中隨機選擇單元格,遞減它們的高度,然後從列表中刪除它們。我們重複這個過程,直到達到所需的可蝕單元數量。

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

			cell.Elevation -= 1;

			erodibleCells.Remove(cell);
		}

		ListPool<HexCell>.Add(erodibleCells);

為了防止erodibleCells.Remove有搜尋需求。只需用列表中的最後一個覆蓋當前單元格,然後刪除最後一個元素。我們不用管他們的順序是什麼。

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

侵蝕單元百分比為0%和100%,地圖的隨機種子為1957632474

3.4持續的侵蝕

我們天真的方法確實有一些侵蝕作用,但還遠遠不夠。這是因為一個單元在其海拔下降一次之後,仍然是可侵蝕的。所以只有當單元不再是可侵蝕的時候才移除它。

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

100%侵蝕,同時保持可蝕單元在列表中。

這產生了更強的侵蝕作用,但仍然不能完全消除懸崖。這是因為當一個單元的海拔降低時,它的一個相鄰單元可能會變得可蝕。所以最終我們可能會得到比開始更多的可侵蝕單元。

降低單元格後,我們必須檢查它的所有鄰居。如果它們現在是可侵蝕的,但還不在列表中,我們必須將它們新增到列表中。

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

所有的可蝕單元都被降低了

3.5保護大陸

我們的侵蝕過程現在可以繼續到所有的懸崖都被清除。這對土地的影響是巨大的。大量的陸地消失了,我們最終得到的土地比例遠遠低於預期。這是因為我們從地圖上刪除了土地。

實際的侵蝕不會破壞物質。它把物質從一個地方拿走,然後存放到另一個地方。我們可以做同樣的事情。每當我們降低一個單元格,我們應該提高它的一個鄰居。單一的海拔高度有效地遷移到較低的細胞。這儲存了地圖的總高度,只是把它弄平了。

要做到這一點,我們必須確定好把被侵蝕的材料移到哪裡。這是我們的侵蝕目標。讓我們建立一個方法來確定目標,給定一個我們將要侵蝕的單元格。由於該單元格必須有一個懸崖,因此選擇懸崖底部的單元格作為目標。但可蝕細胞可能有多個懸崖。讓我們檢查一下所有的鄰邊,把所有的候選項放入一個臨時列表中,然後隨機選擇其中一個。

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

在ErodeLand中,選擇可蝕單元后直接確定目標單元。然後依次遞減和遞增單元格高度。這可能使目標單元本身具有可蝕性,但當我們檢查剛剛侵蝕的單元的相鄰單元時,就會發現這一點。

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

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

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

因為我們提高了目標單元,一些單元的鄰居現在可能不再是可侵蝕的。如果它們不在列表中,我們也必須檢查它們是否可侵蝕。但是如果它們在列表中,那麼我們必須從列表中刪除它們。

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

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

                                     保護陸地後的100%侵蝕的地圖

侵蝕現在會使地形更加光滑,在降低一些地區的同時提高另一些地區。因此,陸地既可能增加,也可能減少。這可以在兩個方向上調整了土地的百分比,但沒有較大的偏差。因此,侵蝕越多,對最終土地百分比的控制力就越少。

3.6更快的侵蝕

雖然我們不需要太擔心我們的侵蝕演算法的效率,但可以變得更快一些。首先,請注意,我們明確檢查我們侵蝕的單元是否仍然是可侵蝕的。否則,我們將從列表中刪除它。所以我們在遍歷目標單元格的鄰居時可以跳過檢查這個單元格。

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

其次,我們只需要檢查目標單元格的鄰居,如果他們之間曾經有一個懸崖,但現在沒有了。只有當相鄰單元格比目標單元格高一級時,才會出現這種情況。如果是,那麼鄰居肯定在列表中,所以我們不需要驗證這個,這意味著我們可以跳過不必要的搜尋。

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

第三,我們可以使用類似的技巧檢查可蝕細胞的鄰居。如果現在他們之間有一個懸崖,那麼鄰居是可侵蝕的。我們不需要呼叫IsErodible來找出這個。

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

然而,我們仍然需要檢查目標單元格是否可侵蝕,但是上面的迴圈現在不再處理這個問題。對目標單元格顯式執行此操作。

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

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

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

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

相對於最初生成的懸崖數量,我們現在可以更快地應用侵蝕,達到我們想要的百分比。注意,由於我們稍微更改了目標單元格新增到可侵蝕列表的位置,因此與優化之前相比,最終結果將略有變化。

                                                                           25% 50% 75% 100%侵蝕。

還要注意的是,雖然海岸線的形狀發生了變化,但拓撲結構並沒有發生根本性的改變。大陸塊要麼保持連線,要麼保持分離。只有小島才能完全沉沒。細節被抹平,但整體形狀保持不變。一個狹窄的連線可能會消失,但它也可能增長一點。狹窄的縫隙可能會被填滿,也可能會稍微變大。因此,侵蝕不會顯著地把遙遠的地區粘合在一起。

                                

                                                                            四個完全侵蝕的區域,仍然分開

下一篇教程:水迴圈

原文:Hex Map 25 Water Cycle

 

專案工程檔案下載地址:unitypackage

專案文件下載地址:PDF

 

【版權宣告】

原文作者未做權利宣告,視為共享智慧財產權進入公共領域,自動獲得授權。