1. 程式人生 > >Unity 六邊形地圖系列(二十三) :自動生成地形

Unity 六邊形地圖系列(二十三) :自動生成地形

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

機翻+個人潤色

·通過自動生成地形填充地圖。

·將地圖塊升到水面上或者沉下去一些。

·控制哪些地塊出現,有多高,以及不規律。

·支援多種配置選項來建立不同的地圖0 。

·可以重新生成一張同樣的地圖

這是 hexagon maps的第23章教程。這是第一篇寫了一些包含如何使用程式生成地圖的教程。

這篇教程是基於Unity2017.1.0製作

                                                     這是一張程式生成的地圖

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

 

【版權宣告】

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