Teile 1-3: Netz, Farben und ZellenhöhenTeile 4-7: Unebenheiten, Flüsse und StraßenTeile 8-11: Wasser, Landformen und WälleTeile 12-15: Speichern und Laden, Texturen, EntfernungenTeile 16-19: Weg finden, Spielerkader, AnimationenTeile 20-23: Nebel des Krieges, Kartenforschung, VerfahrensgenerierungTeile 24-27: Wasserkreislauf, Erosion, Biomes, zylindrische KarteTeil 24: Regionen und Erosion
- Fügen Sie einen Wasserrand um die Karte hinzu.
- Wir teilen die Karte in mehrere Regionen.
- Wir verwenden Erosion, um Klippen abzuschneiden.
- Wir bewegen das Land, um das Relief zu glätten.
Im vorigen Teil haben wir den Grundstein für die prozedurale Kartengenerierung gelegt. Dieses Mal werden wir die Orte des möglichen Auftretens von Land begrenzen und mit Erosion darauf einwirken.
Dieses Tutorial wurde in Unity 2017.1.0 erstellt.
Trenne und glatte das Land.Kartenrand
Da wir Landflächen zufällig erhöhen, kann es vorkommen, dass Land den Rand der Karte berührt. Dies kann unerwünscht sein. Die wasserbegrenzte Karte enthält eine natürliche Barriere, die Spieler daran hindert, sich dem Rand zu nähern. Daher wäre es schön, wenn wir dem Land verbieten würden, sich in der Nähe des Kartenrandes über den Wasserspiegel zu erheben.
Randgröße
Wie nah sollte das Land am Rand der Karte sein? Es gibt keine richtige Antwort auf diese Frage, daher werden wir diesen Parameter anpassbar machen. Wir werden der
HexMapGenerator
Komponente zwei Schieberegler
HexMapGenerator
, einen für Ränder entlang der Kanten entlang der X-Achse und einen für Ränder entlang der Z-Achse. So können wir einen breiteren Rand in einer der Dimensionen verwenden oder sogar einen Rand in nur einer Dimension erstellen. Verwenden wir ein Intervall von 0 bis 10 mit einem Standardwert von 5.
[Range(0, 10)] public int mapBorderX = 5; [Range(0, 10)] public int mapBorderZ = 5;
Schieberegler für Kartenränder.Wir begrenzen die Zentren von Landflächen
Ohne Rahmen sind alle Zellen gültig. Wenn es Grenzen gibt, nehmen die minimal zulässigen Versatzkoordinaten zu und die maximal zulässigen Koordinaten ab. Da wir zum Generieren der Diagramme das zulässige Intervall kennen müssen, verfolgen wir es anhand von vier ganzzahligen Feldern.
int xMin, xMax, zMin, zMax;
Wir initialisieren die Einschränkungen in
GenerateMap
bevor wir Sushi erstellen. Wir verwenden diese Werte als Parameter für
Random.Range
Aufrufe, sodass die Höhen tatsächlich außergewöhnlich sind. Ohne Rand sind sie gleich der Anzahl der Messzellen, also nicht minus 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(); … }
Wir werden das Erscheinen von Land jenseits der Grenze nicht strikt verbieten, da dies zu scharf geschnittenen Kanten führen würde. Stattdessen beschränken wir nur die Zellen, die zum Starten der Erstellung von Plots verwendet werden. Das heißt, die ungefähren Zentren der Standorte werden begrenzt sein, aber Teile der Standorte können über das Grenzgebiet hinausgehen. Dies kann durch Ändern von
GetRandomCell
sodass eine Zelle im Bereich der zulässigen Offsets ausgewählt wird.
HexCell GetRandomCell () {
Die Ränder der Karte sind 0 × 0, 5 × 5, 10 × 10 und 0 × 10.Wenn alle Kartenparameter auf ihre Standardwerte eingestellt sind, schützt ein Rand der Größe 5 den Rand der Karte zuverlässig vor Landberührungen. Dies ist jedoch nicht garantiert. Das Land kann sich manchmal dem Rand nähern und es manchmal an mehreren Stellen berühren.
Die Wahrscheinlichkeit, dass Land die gesamte Grenze überschreitet, hängt von der Größe der Grenze und der maximalen Größe des Standorts ab. Ohne zu zögern bleiben die Abschnitte Sechsecke. Volles Sechseck mit Radius
enthält
Zellen. Wenn es Sechsecke mit einem Radius gibt, der der Größe des Randes entspricht, können sie diesen überqueren. Ein volles Sechseck mit einem Radius von 5 enthält 91 Zellen. Da das Maximum standardmäßig 100 Zellen pro Abschnitt beträgt, bedeutet dies, dass Land eine Brücke über 5 Zellen legen kann, insbesondere wenn Vibrationen auftreten. Um dies zu verhindern, verringern Sie entweder die maximale Größe des Diagramms oder vergrößern Sie den Rand.
Wie wird die Formel für die Anzahl der Zellen in der hexagonalen Region abgeleitet?Bei einem Radius von 0 handelt es sich um eine einzelne Zelle. Es kam von 1. Mit einem Radius von 1 um die Mitte gibt es sechs zusätzliche Zellen, das heißt . Diese sechs Zellen können als die Enden von sechs Dreiecken betrachtet werden, die die Mitte berühren. Mit einem Radius von 2 wird diesen Dreiecken eine zweite Reihe hinzugefügt, dh zwei weitere Zellen werden auf dem Dreieck erhalten, und insgesamt . Mit einem Radius von 3 wird eine dritte Zeile hinzugefügt, dh drei weitere Zellen pro Dreieck und insgesamt . Usw. Das heißt, im Allgemeinen sieht die Formel so aus .
Um dies klarer zu sehen, können wir die Rahmengröße auf 200 einstellen. Da ein volles Sechseck mit einem Radius von 8 217 Zellen enthält, berührt Land wahrscheinlich den Rand der Karte. Zumindest wenn Sie den Standardwert für die Rahmengröße (5) verwenden. Wenn Sie den Rand auf 10 erhöhen, verringert sich die Wahrscheinlichkeit erheblich.
Das Grundstück hat eine konstante Größe von 200, die Ränder der Karte sind 5 und 10.Pangaea
Beachten Sie, dass wir das Land zwingen, eine kleinere Fläche zu bilden, wenn Sie den Kartenrand vergrößern und den gleichen Prozentsatz an Land beibehalten. Infolgedessen erzeugt eine große Karte standardmäßig sehr wahrscheinlich eine einzige große Landmasse - den Superkontinent Pangaea - möglicherweise mit mehreren kleinen Inseln. Mit zunehmender Größe der Grenze steigt die Wahrscheinlichkeit, dass dies geschieht, und bei bestimmten Werten ist fast garantiert, dass wir einen Superkontinent bekommen. Wenn jedoch der Landanteil zu groß ist, füllen sich die meisten verfügbaren Flächen und als Ergebnis erhalten wir eine fast rechteckige Landmasse. Um dies zu verhindern, müssen Sie den Landanteil reduzieren.
40% Sushi mit einem Kartenrand von 10.Woher kommt der Name Pangaea?Das war der Name des letzten bekannten Superkontinents, der vor vielen Jahren auf der Erde existierte. Der Name besteht aus den griechischen Wörtern pan und Gaia und bedeutet so etwas wie "alle Natur" oder "alles Land".
Wir schützen vor unmöglichen Karten
Wir erzeugen die richtige Menge Land, indem wir das Land einfach weiter anheben, bis wir die gewünschte Landmasse erreicht haben. Dies funktioniert, weil wir früher oder später jede Zelle auf dem Wasserspiegel anheben werden. Wenn Sie jedoch den Rand der Karte verwenden, können wir nicht jede Zelle erreichen. Wenn ein zu hoher Prozentsatz an Land benötigt wird, führt dies zu endlosen „Versuchen und Fehlern“ des Generators, mehr Land zu heben, und es bleibt in einem endlosen Zyklus stecken. In diesem Fall friert die Anwendung ein, dies sollte jedoch nicht geschehen.
Wir können unmögliche Konfigurationen nicht zuverlässig im Voraus finden, aber wir können uns vor endlosen Zyklen schützen. Wir werden einfach die Anzahl der in
CreateLand
ausgeführten Zyklen
CreateLand
. Wenn es zu viele Iterationen gibt, stecken wir höchstwahrscheinlich fest und sollten aufhören.
Für eine große Karte scheinen tausend Iterationen akzeptabel zu sein, und zehntausend Iterationen scheinen bereits absurd. Verwenden wir diesen Wert also als Endpunkt.
void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
Wenn wir eine beschädigte Karte erhalten, nehmen 10.000 Iterationen nicht viel Zeit in Anspruch, da viele Zellen schnell die maximale Höhe erreichen, wodurch das Wachstum neuer Bereiche verhindert wird.
Selbst nach dem Durchbrechen der Schleife erhalten wir immer noch die richtige Karte. Es gibt einfach nicht die richtige Menge an Sushi und es wird nicht sehr interessant aussehen. Lassen Sie uns eine Benachrichtigung darüber in der Konsole anzeigen und uns mitteilen, welches verbleibende Land wir nicht ausgegeben haben.
void CreateLand () { … if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); } }
95% des Landes mit einer Kartengrenze von 10 konnten nicht den gesamten Betrag ausgeben.Warum weist eine fehlerhafte Karte immer noch Abweichungen auf?Die Küste ist variabel, denn wenn die Höhen innerhalb des Erstellungsbereichs zu hoch werden, können sie in neuen Bereichen nicht nach außen wachsen. Das gleiche Prinzip erlaubt es Parzellen nicht, in kleine Landflächen zu wachsen, bis sie die maximale Höhe erreicht haben und sich einfach als vermisst herausstellen. Außerdem nimmt die Variabilität beim Absenken der Diagramme zu.
EinheitspaketEine Karte partitionieren
Nachdem wir den Kartenrand haben, haben wir die Karte im Wesentlichen in zwei separate Regionen unterteilt: die Grenzregion und die Region, in der die Diagramme erstellt wurden. Da uns nur die Region der Schöpfung wichtig ist, können wir einen solchen Fall als eine Situation mit einer Region betrachten. Die Region deckt einfach nicht die gesamte Karte ab. Wenn dies jedoch unmöglich ist, hindert uns nichts daran, die Karte in mehrere nicht zusammenhängende Regionen der Landschöpfung zu unterteilen. Dadurch können sich Landmassen unabhängig voneinander bilden und verschiedene Kontinente bestimmen.
Kartenregion
Beginnen wir damit, eine Region der Karte als Struktur zu beschreiben. Dies wird unsere Arbeit mit mehreren Regionen vereinfachen. Erstellen
MapRegion
hierfür eine
MapRegion
Struktur, die einfach die Randfelder der Region enthält. Da wir diese Struktur nicht außerhalb von
HexMapGenerator
, können wir sie innerhalb dieser Klasse als private interne Struktur definieren. Dann können vier ganzzahlige Felder durch ein
MapRegion
Feld ersetzt werden.
Damit alles funktioniert, müssen wir das Regionspräfix zu den Minimum-Maximum-Feldern in
GenerateMap
hinzufügen
region.
.
region.xMin = mapBorderX; region.xMax = x - mapBorderX; region.zMin = mapBorderZ; region.zMax = z - mapBorderZ;
Und auch in
GetRandomCell
.
HexCell GetRandomCell () { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); }
Mehrere Regionen
Ersetzen Sie ein
MapRegion
Feld
MapRegion
Liste von Regionen, um mehrere Regionen zu unterstützen.
An dieser Stelle wäre es schön, eine separate Methode zum Erstellen von Regionen hinzuzufügen. Es sollte die gewünschte Liste erstellen oder löschen, falls sie bereits vorhanden ist. Danach bestimmt er wie zuvor eine Region und fügt sie der Liste hinzu.
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); }
Wir werden diese Methode in
GenerateMap
aufrufen und die Region nicht direkt erstellen.
Damit
GetRandomCell
mit einer beliebigen Region arbeiten kann, geben Sie ihm den
MapRegion
Parameter.
HexCell GetRandomCell (MapRegion region) { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); }
Jetzt sollten die
SinkTerrain
RaiseTerraion
und
SinkTerrain
die entsprechende Region an
GetRandomCell
. Dazu benötigt jeder von ihnen auch einen Regionsparameter.
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); … }
Die
CreateLand
Methode sollte für jede Region festlegen, dass die Abschnitte
CreateLand
oder
CreateLand
werden sollen. Um das Land zwischen den Regionen auszugleichen, werden wir einfach wiederholt die Liste der Regionen im Zyklus durchgehen.
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."); } }
Wir müssen jedoch die Absenkung der Parzellen gleichmäßig verteilen. Dies kann erfolgen, während für alle Regionen entschieden wird, ob sie weggelassen werden sollen.
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);
Um genau die gesamte Landmenge zu nutzen, müssen wir den Prozess stoppen, sobald die Menge Null erreicht. Dies kann in jeder Phase des Zyklus der Region geschehen. Daher verschieben wir die Nullsummenprüfung in die innere Schleife. Tatsächlich können wir diese Überprüfung nur durchführen, nachdem wir Land angehoben haben, da beim Absenken der Betrag niemals ausgegeben wird. Wenn wir fertig sind, können wir die
CreateLand
Methode sofort
CreateLand
.
Zwei Regionen
Obwohl wir jetzt die Unterstützung mehrerer Regionen haben, fragen wir immer noch nur eine. Ändern Sie die
CreateRegions
so, dass die Karte vertikal in zwei
CreateRegions
. Dazu halbieren wir den
xMax
Wert der hinzugefügten Region. Dann verwenden wir denselben Wert für
xMin
und erneut den ursprünglichen Wert für
xMax
, wobei
xMax
ihn als zweiten Bereich verwenden.
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);
Das Generieren von Karten in dieser Phase macht keinen Unterschied. Obwohl wir zwei Regionen identifiziert haben, besetzen sie dieselbe Region wie eine alte Region. Um sie auseinander zu verteilen, müssen Sie einen leeren Raum zwischen ihnen lassen. Dies kann durch Hinzufügen eines Schiebereglers zum Rand der Region erfolgen, wobei das gleiche Intervall und der gleiche Standardwert wie für die Ränder der Karte verwendet werden.
[Range(0, 10)] public int regionBorder = 5;
Regionsrand-Schieberegler.Da Land auf beiden Seiten des Raums zwischen Regionen gebildet werden kann, steigt die Wahrscheinlichkeit, Landbrücken an den Rändern der Karte zu erstellen. Um dies zu verhindern, verwenden wir den Rand der Region, um eine landfreie Zone zwischen der Trennlinie und der Region zu definieren, in der die Diagramme beginnen können. Dies bedeutet, dass der Abstand zwischen benachbarten Regionen zwei größer ist als die Größe der Region.
Um diese Bereichsgrenze anzuwenden, subtrahieren Sie sie vom
xMax
ersten Region und fügen Sie die zweite Region zu
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);
Die Karte ist vertikal in zwei Regionen unterteilt.Mit den Standardeinstellungen werden zwei deutlich getrennte Regionen erstellt. Wie bei einer Region und einem großen Kartenrand wird jedoch nicht garantiert, dass wir genau zwei Landmassen erhalten. Meistens sind es zwei große Kontinente, möglicherweise mit mehreren Inseln. Manchmal können jedoch zwei oder mehr große Inseln in einer Region erstellt werden. Und manchmal können zwei Kontinente durch eine Landenge verbunden werden.
Natürlich können wir die Karte auch horizontal teilen und die Ansätze für die Messung von X und Z ändern. Wählen wir zufällig eine von zwei möglichen Ausrichtungen.
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); }
Karte horizontal in zwei Regionen unterteilt.Da wir eine breite Karte verwenden, werden breitere und dünnere Regionen mit horizontaler Trennung erstellt. Infolgedessen bilden diese Regionen mit größerer Wahrscheinlichkeit mehrere geteilte Landmassen.
Vier Regionen
Lassen Sie uns die Anzahl der Regionen anpassbar machen und Unterstützung von 1 bis 4 Regionen erstellen.
[Range(1, 4)] public int regionCount = 1;
Schieberegler für die Anzahl der Regionen.Mit der
switch
können wir die Ausführung des entsprechenden Regionalcodes auswählen. Wir beginnen mit der Wiederholung des Codes für eine Region, die standardmäßig verwendet wird, und belassen den Code für die beiden Regionen für Fall 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; }
Was ist die switch-Anweisung?Dies ist eine Alternative zum Schreiben einer Folge von if-else-if-else-Anweisungen. Der Schalter wird auf die Variable angewendet, und Beschriftungen geben an, welcher Code ausgeführt werden muss. Es gibt auch eine
default
, die als letzter
else
Block verwendet wird. Jede Option muss entweder mit einer
break
Anweisung oder einer
return
enden.
Um den
switch
lesbar zu halten, ist es normalerweise am besten, alle Fälle kurz zu halten, idealerweise mit einer einzelnen Anweisung oder einem Methodenaufruf. Ich werde dies nicht als Beispiel für einen Regionalcode tun, aber wenn Sie interessantere Regionen erstellen möchten, empfehle ich, separate Methoden zu verwenden. Zum Beispiel:
switch (regionCount) { default: CreateOneRegion(); break; case 2: CreateTwoRegions(); break; case 3: CreateThreeRegions(); break; case 4: CreateFourRegions(); break; }
Drei Regionen ähneln zwei, nur Drittel werden anstelle der Hälfte verwendet. In diesem Fall werden durch die horizontale Unterteilung zu enge Bereiche erstellt, sodass nur die vertikale Unterteilung unterstützt wird. Beachten Sie, dass wir dadurch den Grenzbereich der Region verdoppelt haben, sodass der Platz zum Erstellen neuer Websites geringer ist als bei zwei Regionen.
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; }
Drei Regionen.Es können vier Regionen erstellt werden, indem die horizontale und vertikale Trennung kombiniert und jeder Ecke der Karte eine Region hinzugefügt wird.
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; } }
Vier Regionen.Der hier verwendete Ansatz ist der einfachste Weg, eine Karte zu teilen. Es erzeugt ungefähr die gleichen Regionen nach Landmasse, und ihre Variabilität wird durch andere Parameter der Kartenerzeugung gesteuert. Es wird jedoch immer ziemlich offensichtlich sein, dass die Karte in gerade Linien geteilt wurde. Je mehr Kontrolle wir brauchen, desto weniger organisch wird das Ergebnis aussehen. Daher ist dies normal, wenn Sie für das Gameplay ungefähr gleiche Regionen benötigen. Wenn Sie jedoch das abwechslungsreichste und unbegrenzteste Land benötigen, müssen Sie es mit Hilfe einer Region schaffen.
Darüber hinaus gibt es andere Möglichkeiten, die Karte zu teilen. Wir können uns nicht nur auf gerade Linien beschränken. Wir müssen nicht einmal Regionen gleicher Größe verwenden und die gesamte Karte damit abdecken. Wir können Löcher hinterlassen. Sie können auch Schnittpunkte von Regionen zulassen oder die Landverteilung zwischen Regionen ändern. Sie können sogar Ihre eigenen Generatorparameter für jede Region festlegen (obwohl dies komplizierter ist), um beispielsweise einen großen Kontinent und einen Archipel auf der Karte zu haben.
EinheitspaketErosion
Bisher sahen alle Karten, die wir generiert haben, ziemlich unhöflich und kaputt aus.
Eine echte Erleichterung mag so aussehen, aber mit der Zeit wird sie immer glatter, und ihre scharfen Teile werden durch Erosion stumpf. Um die Karten zu verbessern, können wir diesen Erosionsprozess anwenden. Wir werden dies tun, nachdem wir raues Land in einer separaten Methode geschaffen haben. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); SetTerrainType(); … } … void ErodeLand () {}
Erosionsprozentsatz
Je mehr Zeit vergeht, desto mehr Erosion tritt auf. Daher möchten wir, dass die Erosion nicht dauerhaft, sondern anpassbar ist. Die Erosion ist mindestens Null, was den zuvor erstellten Karten entspricht. Bei maximaler Erosion ist umfassend, dh die weitere Anwendung von Erosionskräften verändert das Gelände nicht mehr. Das heißt, der Erosionsparameter sollte ein Prozentsatz von 0 bis 100 sein, und standardmäßig nehmen wir 50. [Range(0, 100)] public int erosionPercentage = 50;
Erosionsschieber.Suche nach erosionszerstörenden Zellen
Erosion macht das Relief glatter. In unserem Fall sind die einzigen scharfen Teile die Klippen. Daher werden sie das Ziel des Erosionsprozesses sein. Wenn eine Klippe vorhanden ist, sollte sie durch Erosion verringert werden, bis sie schließlich zu einem Hang wird. Wir werden die Pisten nicht glätten, da dies zu einem langweiligen Gelände führen wird. Dazu müssen wir bestimmen, welche Zellen sich oben auf den Klippen befinden, und ihre Höhe senken. Dies sind erosionsanfällige Zellen.Lassen Sie uns eine Methode erstellen, die bestimmt, ob eine Zelle anfällig für Erosion ist. Er ermittelt dies, indem er die Nachbarn der Zelle überprüft, bis er einen ausreichend großen Höhenunterschied findet. Da Klippen einen Höhenunterschied von mindestens einer oder zwei Höhenstufen erfordern, ist die Zelle einer Erosion ausgesetzt, wenn sich einer oder mehrere ihrer Nachbarn mindestens zwei Stufen darunter befinden. Wenn es keinen solchen Nachbarn gibt, kann die Zelle keine Erosion erfahren. 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; }
Wir können diese Methode verwenden ErodeLand
, um alle Zellen zu durchlaufen und alle erosionsanfälligen Zellen in eine temporäre Liste zu schreiben. 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); }
Sobald wir die Gesamtzahl der erosionsanfälligen Zellen kennen, können wir den Prozentsatz der Erosion verwenden, um die Anzahl der verbleibenden erosionsanfälligen Zellen zu bestimmen. Wenn der Prozentsatz beispielsweise 50 beträgt, müssen wir Erosionszellen erodieren, bis die Hälfte der ursprünglichen Menge übrig bleibt. Wenn der Prozentsatz 100 beträgt, werden wir nicht aufhören, bis wir alle erosionsanfälligen Zellen zerstört haben. 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); }
Sollten wir nicht nur Zellen berücksichtigen, die zu Landerosion neigen?. , , .
Zellreduktion
Beginnen wir mit einem naiven Ansatz und nehmen wir an, dass eine einfache Verringerung der Höhe der durch Erosion zerstörten Zellen dazu führt, dass sie nicht mehr anfällig für Erosion sind. Wenn dies wahr wäre, könnten wir einfach zufällige Zellen aus der Liste nehmen, ihre Höhe verringern und sie dann aus der Liste entfernen. Wir würden diesen Vorgang wiederholen, bis wir die gewünschte Anzahl erosionsanfälliger Zellen erreicht haben. 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);
Um die erforderliche Suche zu verhindern erodibleCells.Remove
, überschreiben wir die aktuelle Zelle zuletzt in der Liste und löschen dann das letzte Element. Ihre Bestellung ist uns immer noch egal.
Naive Abnahme von 0% und 100% erosionsanfälliger Zellen, Samenkarte 1957632474.Erosionsverfolgung
Unser naiver Ansatz ermöglicht es uns, Erosion anzuwenden, aber nicht im richtigen Maße. Dies geschieht, weil die Zelle nach einer Verringerung der Höhe immer noch anfällig für Erosion bleiben kann. Daher werden wir eine Zelle nur dann aus der Liste entfernen, wenn sie nicht mehr der Erosion ausgesetzt ist. if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); }
100% Erosion unter Beibehaltung erosionsanfälliger Zellen in der Liste.Wir bekommen also eine viel stärkere Erosion, aber wenn wir 100% verwenden, werden wir immer noch nicht alle Klippen los. Der Grund ist, dass nach dem Verringern der Höhe der Zelle einer ihrer Nachbarn anfällig für Erosion werden kann. Infolgedessen haben wir möglicherweise mehr erosionsanfällige Zellen als ursprünglich.Nachdem wir die Zelle abgesenkt haben, müssen wir alle Nachbarn überprüfen. Wenn sie jetzt anfällig für Erosion sind, aber noch nicht auf der Liste stehen, müssen Sie sie dort hinzufügen. 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); } }
Alle erodierten Zellen werden weggelassen.Wir sparen viel Land
Jetzt kann der Erosionsprozess fortgesetzt werden, bis alle Klippen verschwunden sind. Dies wirkt sich stark auf das Land aus. Der größte Teil der Landmasse verschwand und wir bekamen viel weniger als den Prozentsatz des benötigten Landes. Es ist passiert, weil wir Land von der Karte entfernen.Wahre Erosion zerstört keine Materie. Sie nimmt es von einem Ort und platziert es woanders. Wir können das Gleiche tun. Mit einer Abnahme in einer Zelle müssen wir einen ihrer Nachbarn großziehen. Tatsächlich wird eine Höhenstufe auf eine untere Zelle übertragen. Dies spart die Gesamtmenge der Kartenhöhen und glättet sie einfach.Um dies zu realisieren, müssen wir entscheiden, wohin Erosionsprodukte übertragen werden sollen. Dies wird unser Erosionsziel sein. Erstellen wir eine Methode, um den Zielpunkt einer zu erodierenden Zelle zu bestimmen. Da diese Zelle eine Unterbrechung enthält, wäre es logisch, die unter dieser Unterbrechung befindliche Zelle als Ziel auszuwählen. Eine erosionsgefährdete Zelle kann jedoch mehrere Pausen haben. Daher überprüfen wir alle Nachbarn und setzen alle Kandidaten auf eine temporäre Liste. Anschließend wählen wir zufällig einen von ihnen aus. 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; }
In ErodeLand
definieren wir die Zielzelle unmittelbar nach Auswahl der Erosionszelle. Dann verringern und erhöhen wir die Zellhöhen unmittelbar nacheinander. In diesem Fall kann die Zielzelle selbst anfällig für Erosion werden. Diese Situation wird jedoch behoben, wenn wir die Nachbarn der neu erodierten Zelle überprüfen. 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); }
Da wir die Zielzelle angehoben haben, ist ein Teil der Nachbarn dieser Zelle möglicherweise nicht mehr der Erosion ausgesetzt. Es ist notwendig, um sie herumzugehen und zu prüfen, ob sie anfällig für Erosion sind. Wenn nicht, aber sie sind in der Liste enthalten, müssen Sie sie daraus entfernen. 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% Erosion unter Beibehaltung der Landmasse.Durch Erosion kann das Gelände jetzt viel besser geglättet werden, indem einige Bereiche abgesenkt und andere angehoben werden. Infolgedessen kann die Landmasse sowohl zunehmen als auch sich verengen. Dies kann den Landanteil in der einen oder anderen Richtung um mehrere Prozent verändern, es treten jedoch selten schwerwiegende Abweichungen auf. Das heißt, je mehr Erosion wir anwenden, desto weniger Kontrolle haben wir über den resultierenden Prozentsatz an Land.Beschleunigte Erosion
Obwohl wir uns nicht wirklich um die Effektivität des Erosionsalgorithmus kümmern müssen, können wir ihn einfach verbessern. Beachten Sie zunächst, dass wir explizit prüfen, ob die von uns erodierte Zelle erodiert werden kann. Wenn nicht, entfernen wir es im Wesentlichen aus der Liste. Daher können Sie das Überprüfen dieser Zelle überspringen, wenn Sie die Nachbarn der Zielzelle durchlaufen. 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); } }
Zweitens mussten wir die Nachbarn der Zielzelle nur überprüfen, wenn zwischen ihnen eine Pause bestand, aber jetzt ist dies nicht notwendig. Dies geschieht nur, wenn der Nachbar jetzt einen Schritt höher als die Zielzelle ist. Wenn ja, ist der Nachbar garantiert auf der Liste, sodass wir dies nicht überprüfen müssen, dh wir können die unnötige Suche überspringen. HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && neighbor != cell && neighbor.Elevation == targetCell.Elevation + 1 && !IsErodible(neighbor)
Drittens können wir einen ähnlichen Trick anwenden, wenn wir die Nachbarn einer erosionsanfälligen Zelle überprüfen. Befindet sich jetzt eine Klippe zwischen ihnen, ist der Nachbar anfällig für Erosion. Um das herauszufinden, müssen wir nicht anrufen IsErodible
. HexCell neighbor = cell.GetNeighbor(d); if ( neighbor && neighbor.Elevation == cell.Elevation + 2 &&
Wir müssen jedoch noch prüfen, ob die Zielzelle anfällig für Erosion ist, aber der oben gezeigte Zyklus tut dies nicht mehr. Daher führen wir dies explizit für die Zielzelle durch. 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++) { … }
Jetzt können wir die Erosion schnell genug und auf den gewünschten Prozentsatz im Verhältnis zur anfänglichen Anzahl der erzeugten Klippen anwenden. Beachten Sie, dass sich das Ergebnis aufgrund der Tatsache, dass wir die Stelle, an der die Zielzelle zur erosionsgefährdeten Liste hinzugefügt wird, geringfügig geändert haben, gegenüber dem Ergebnis vor den Optimierungen geringfügig geändert hat.25%, 50%, 75% und 100% Erosion.Beachten Sie auch, dass sich die Topologie trotz der veränderten Form der Küste nicht grundlegend geändert hat. Landmassen bleiben normalerweise entweder verbunden oder getrennt. Nur kleine Inseln können vollständig ertrinken. Die Reliefdetails werden geglättet, die allgemeinen Formen bleiben jedoch gleich. Ein schmales Gelenk kann verschwinden oder ein wenig wachsen. Eine kleine Lücke kann sich leicht füllen oder ausdehnen. Daher wird die Erosion die geteilten Regionen nicht stark zusammenhalten.Vier vollständig erodierte Regionen bleiben immer noch getrennt.EinheitspaketTeil 25: Der Wasserkreislauf
- Zeigen Sie rohe Kartendaten an.
- Wir bilden ein Klima von Zellen.
- Erstellen Sie eine Teilsimulation des Wasserkreislaufs.
In diesem Teil werden wir Feuchtigkeit an Land hinzufügen.Dieses Tutorial wurde in Unity 2017.3.0 erstellt.Wir verwenden den Wasserkreislauf, um Biomes zu bestimmen.Die Wolken
Bis zu diesem Punkt hat der Kartengenerierungsalgorithmus nur die Zellenhöhe geändert. Der größte Unterschied zwischen den Zellen bestand darin, ob sie sich über oder unter Wasser befanden. Obwohl wir verschiedene Geländetypen definieren können, ist dies nur eine einfache Visualisierung der Höhe. Angesichts des lokalen Klimas ist es besser, die Arten der Erleichterung anzugeben.Das Erdklima ist ein sehr komplexes System. Glücklicherweise müssen wir keine realistischen Klimasimulationen erstellen. Wir werden etwas brauchen, das natürlich genug aussieht. Der wichtigste Aspekt des Klimas ist der Wasserkreislauf, denn Flora und Fauna benötigen flüssiges Wasser, um zu überleben. Die Temperatur ist ebenfalls sehr wichtig, aber im Moment konzentrieren wir uns auf Wasser, wobei die globale Temperatur im Wesentlichen konstant bleibt und nur die Luftfeuchtigkeit geändert wird.Der Wasserkreislauf beschreibt die Bewegung von Wasser in der Umwelt. Einfach ausgedrückt, die Teiche verdunsten, was zur Bildung von Regenwolken führt, die wieder in die Teiche fließen. Das System hat viel mehr Aspekte, aber die Simulation dieser Schritte kann bereits ausreichen, um eine natürlich aussehende Wasserverteilung auf der Karte zu erzielen.Datenvisualisierung
Bevor wir mit dieser Simulation beginnen, ist es hilfreich, die relevanten Daten direkt anzuzeigen. Dazu ändern wir den Terrain- Shader . Wir fügen ihm eine umschaltbare Eigenschaft hinzu, die in den Datenvisualisierungsmodus umgeschaltet werden kann, in dem anstelle der üblichen Relieftexturen Rohkartendaten angezeigt werden. Dies kann mithilfe einer float-Eigenschaft mit einem umschaltbaren Attribut implementiert werden, das das Schlüsselwort definiert. Aus diesem Grund wird es im Materialinspektor als Flag angezeigt, das die Definition eines Schlüsselworts steuert. Der Name der Immobilie selbst ist nicht wichtig, wir interessieren uns nur für das Schlüsselwort. Wir verwenden 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 }
Wechseln Sie zur Anzeige der Kartendaten.Fügen Sie eine Shader-Funktion hinzu, um die Keyword-Unterstützung zu aktivieren. #pragma multi_compile _ GRID_ON #pragma multi_compile _ HEX_MAP_EDIT_MODE #pragma shader_feature SHOW_MAP_DATA
Wir werden dafür sorgen, dass ein einzelner Float angezeigt wird, wie dies bei den übrigen Reliefdaten der Fall ist. Um dies zu implementieren, fügen wir der Struktur ein Input
Feld hinzu , mapData
wenn das Schlüsselwort definiert ist. struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; float4 visibility;
Im Vertex-Programm verwenden wir den Z-Kanal dieser Zellen zum Ausfüllen mapData
, wie immer zwischen Zellen interpoliert. 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 }
Wenn Sie Zelldaten anzeigen müssen, verwenden Sie diese direkt als Albedofragment anstelle der üblichen Farbe. Multiplizieren Sie es mit dem Raster, damit das Raster beim Rendern der Daten weiterhin aktiviert ist. 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 … }
Um Daten tatsächlich an einen Shader zu übertragen. Wir müssen der HexCellShaderData
Methode hinzufügen, die etwas in den blauen Texturdatenkanal schreibt. Daten sind ein einzelner Gleitkommawert, der auf 0–1 begrenzt ist. public void SetMapData (HexCell cell, float data) { cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 255f) : (byte)255); enabled = true; }
Diese Entscheidung wirkt sich jedoch auf das Forschungssystem aus. Ein blauer Kanaldatenwert 255 wird verwendet, um anzuzeigen, dass sich die Sichtbarkeit der Zelle im Übergang befindet. Damit dieses System weiterhin funktioniert, müssen wir maximal den Bytewert 254 verwenden. Beachten Sie, dass durch die Bewegung der Abteilung alle Kartendaten gelöscht werden. Dies passt jedoch zu uns, da sie zum Debuggen der Kartengenerierung verwendet werden. cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 254f) : (byte)254);
Fügen Sie eine Methode mit demselben Namen und in hinzu HexCell
. Die Anforderung wird an die Shader-Daten übertragen. public void SetMapData (float data) { ShaderData.SetMapData(this, data); }
Um die Funktionsweise des Codes zu überprüfen, ändern wir ihn HexMapGenerator.SetTerrainType
so, dass die Daten jeder Zelle der Karte festgelegt werden. Lassen Sie uns die Höhe visualisieren, die im Intervall 0–1 von Integer in Float konvertiert wurde. Dies erfolgt durch Subtrahieren der minimalen Höhe von der Zellenhöhe, gefolgt vom Teilen durch die maximale Höhe minus die minimale. Lassen Sie uns die Division Gleitkomma machen. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData( (cell.Elevation - elevationMinimum) / (float)(elevationMaximum - elevationMinimum) ); } }
Jetzt werden wir zwischen normalen Erleichterung und Visualisierung von Daten mit Hilfe der Kontrollkästchen wechseln können Karte zeigen Daten Inventar Material das Terrain .Karte 1208905299, normales Gelände und Höhenvisualisierung.Klimaschöpfung
Um das Klima zu simulieren, müssen wir Klimadaten verfolgen. Da die Karte aus diskreten Zellen besteht, hat jede von ihnen ihr eigenes lokales Klima. Erstellen Sie eine Struktur ClimateData
zum Speichern aller relevanten Daten. Natürlich können Sie den Zellen selbst Daten hinzufügen, aber wir werden sie nur beim Generieren der Karte verwenden. Daher werden wir sie separat speichern. Dies bedeutet, dass wir diese Struktur intern definieren können HexMapGenerator
, wie z MapRegion
. Wir beginnen damit, nur Wolken zu verfolgen, die mit einem einzigen Float-Feld implementiert werden können. struct ClimateData { public float clouds; }
Fügen Sie eine Liste hinzu, um Klimadaten für alle Zellen zu verfolgen. List<ClimateData> climate = new List<ClimateData>();
Jetzt brauchen wir eine Methode, um eine Klimakarte zu erstellen. Zunächst sollte die Liste der Klimazonen gelöscht und dann für jede Zelle ein Element hinzugefügt werden. Die anfänglichen Klimadaten sind einfach Null, dies kann mit einem Standardkonstruktor erreicht werden ClimateData
. void CreateClimate () { climate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); } }
Das Klima sollte nach der Exposition gegenüber Landerosion geschaffen werden, bevor die Arten von Reliefs festgelegt werden. In Wirklichkeit wird Erosion hauptsächlich durch die Bewegung von Luft und Wasser verursacht, die Teil des Klimas sind, aber wir werden dies nicht simulieren. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); SetTerrainType(); … }
Ändern Sie dies SetTerrainType
so, dass Cloud-Daten anstelle der Zellenhöhe angezeigt werden. Anfangs sieht es aus wie eine schwarze Karte. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData(climate[i].clouds); } }
Klima ändern
Der erste Schritt in der Klimasimulation ist die Verdunstung. Wie viel Wasser sollte verdunsten? Lassen Sie uns diesen Wert mit dem Schieberegler steuern. Ein Wert von 0 bedeutet keine Verdunstung, 1 - maximale Verdunstung. Standardmäßig verwenden wir 0,5. [Range(0f, 1f)] public float evaporation = 0.5f;
Verdunstungsschieber.Lassen Sie uns eine andere Methode speziell zur Gestaltung des Klimas einer Zelle entwickeln. Wir geben ihm den Zellindex als Parameter und verwenden ihn, um die entsprechende Zelle und ihre Klimadaten zu erhalten. Befindet sich die Zelle unter Wasser, handelt es sich um ein Reservoir, das verdunsten muss. Wir verwandeln den Dampf sofort in Wolken (ohne Berücksichtigung der Taupunkte und der Kondensation), sodass wir den Wert der Zellwolken direkt verdampfen. Wenn Sie damit fertig sind, kopieren Sie die Klimadaten zurück in die Liste. void EvolveClimate (int cellIndex) { HexCell cell = grid.GetCell(cellIndex); ClimateData cellClimate = climate[cellIndex]; if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } climate[cellIndex] = cellClimate; }
Rufen Sie diese Methode für jede Zelle in auf CreateClimate
. void CreateClimate () { … for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } }
Das reicht aber nicht. Um eine komplexe Simulation zu erstellen, müssen wir das Klima der Zellen mehrmals formen. Je öfter wir dies tun, desto besser wird das Ergebnis sein. Wählen wir einfach einen konstanten Wert. Ich benutze 40 Zyklen. for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } }
Da wir zwar nur den Wert der Wolken über den mit Wasser überfluteten Zellen erhöhen, erhalten wir dadurch schwarzes Land und weiße Stauseen.Verdunstung über Wasser.Wolkenstreuung
Wolken sind nicht ständig an einem Ort, besonders wenn immer mehr Wasser verdunstet. Der Druckunterschied bewirkt, dass sich die Luft bewegt, was sich in Form von Wind manifestiert, wodurch sich auch die Wolken bewegen.Wenn es keine dominante Windrichtung gibt, verteilen sich die Zellwolken im Durchschnitt gleichmäßig in alle Richtungen und erscheinen in benachbarten Zellen. Wenn Sie im nächsten Zyklus neue Wolken erzeugen, verteilen wir alle Wolken in der Zelle auf die Nachbarn. Das heißt, jeder Nachbar erhält ein Sechstel aus den Zellwolken, wonach es zu einer lokalen Abnahme auf Null kommt. if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float cloudDispersal = cellClimate.clouds * (1f / 6f); cellClimate.clouds = 0f; climate[cellIndex] = cellClimate;
Um Ihren Nachbarn tatsächlich Wolken hinzuzufügen, müssen Sie sie in einer Schleife umgehen, ihre Klimadaten abrufen, den Wert der Wolken erhöhen und sie zurück in die Liste kopieren. 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;
Wolken streuen.Dadurch entsteht eine fast weiße Karte, da Unterwasserzellen mit jedem Zyklus mehr und mehr Wolken zum globalen Klima hinzufügen. Nach dem ersten Zyklus haben Landzellen neben Wasser auch Wolken, die verteilt werden müssen. Dieser Vorgang wird fortgesetzt, bis der größte Teil der Karte mit Wolken bedeckt ist. Bei der Karte 1208905299 mit den Standardparametern blieb nur der innere Teil der großen Landmasse im Nordosten vollständig unbedeckt.Beachten Sie, dass Teiche unendlich viele Wolken erzeugen können. Der Wasserstand ist nicht Teil der Klimasimulation. In der Realität bleiben Stauseen nur erhalten, weil Wasser mit etwa der Verdunstungsrate in sie zurückfließt. Das heißt, wir simulieren nur einen Teilwasserkreislauf. Dies ist normal, aber wir müssen verstehen, je länger die Simulation stattfindet, desto mehr Wasser wird dem Klima hinzugefügt. Bisher tritt Wasserverlust nur an den Rändern der Karte auf, wo verstreute Wolken aufgrund des Mangels an Nachbarn verloren gehen.Sie können den Wasserverlust oben auf der Karte sehen, insbesondere in den Zellen oben rechts. In der letzten Zelle gibt es überhaupt keine Wolken, weil es die letzte bleibt, in der sich das Klima bildet. Sie hat noch keine Wolken von einem Nachbarn erhalten.Sollte sich nicht das Klima aller Zellen parallel bilden?, . - , . 40 . - , .
Niederschlag
Wasser bleibt nicht für immer kalt. Irgendwann sollte sie wieder zu Boden fallen. Dies geschieht normalerweise in Form von Regen, aber manchmal kann es Schnee, Hagel oder nasser Schnee sein. All dies wird allgemein als Niederschlag bezeichnet. Das Ausmaß und die Rate des Verschwindens von Wolken variieren stark, aber wir verwenden nur eine benutzerdefinierte globale Niederschlagsrate. Ein Wert von 0 bedeutet kein Niederschlag, ein Wert von 1 bedeutet, dass alle Wolken sofort verschwinden. Der Standardwert ist 0,25. Dies bedeutet, dass in jedem Zyklus ein Viertel der Wolken verschwindet. [Range(0f, 1f)] public float precipitationFactor = 0.25f;
Regler für den Niederschlagskoeffizienten.Wir werden Niederschläge nach der Verdunstung und vor der Wolkenstreuung simulieren. Dies bedeutet, dass ein Teil des aus den Reservoirs verdampften Wassers sofort ausfällt, sodass die Anzahl der sich zerstreuenden Wolken abnimmt. Über Land führt Niederschlag zum Verschwinden von Wolken. if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; float cloudDispersal = cellClimate.clouds * (1f / 6f);
Verschwindende Wolken.Wenn wir jetzt in jedem Zyklus 25% der Wolken zerstören, ist das Land wieder fast schwarz. Die Wolken bewegen sich nur wenige Schritte landeinwärts und werden dann unsichtbar.EinheitspaketLuftfeuchtigkeit
Obwohl Regen Wolken zerstört, sollten sie dem Klima kein Wasser entziehen. Nach dem Sturz auf den Boden wird Wasser nur in einem anderen Zustand gespeichert. Es kann in vielen Formen existieren, die wir allgemein als Feuchtigkeit betrachten.Feuchtigkeitsverfolgung
Wir werden das Klimamodell verbessern, indem wir zwei Wasserbedingungen verfolgen: Wolken und Feuchtigkeit. Fügen Sie dazu das ClimateData
Feld hinzu, um dies zu implementieren moisture
. struct ClimateData { public float clouds, moisture; }
In seiner allgemeinsten Form ist Verdunstung der Prozess der Umwandlung von Feuchtigkeit in Wolken, zumindest in unserem einfachen Klimamodell. Dies bedeutet, dass die Verdunstung kein konstanter Wert sein sollte, sondern ein weiterer Faktor. Daher führen wir ein Refactoring-Umbenennen evaporation
in durch evaporationFactor
. [Range(0f, 1f)] public float evaporationFactor = 0.5f;
Wenn sich die Zelle unter Wasser befindet, geben wir einfach an, dass die Luftfeuchtigkeit 1 beträgt. Dies bedeutet, dass die Verdunstung gleich dem Verdunstungskoeffizienten ist. Jetzt können wir aber auch aus Sushi-Zellen verdunsten. In diesem Fall müssen wir die Verdunstung berechnen, von der Luftfeuchtigkeit abziehen und das Ergebnis zu den Wolken addieren. Danach wird der Feuchtigkeit Niederschlag zugesetzt. 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;
Da Wolken jetzt durch Verdunstung von oben unterstützt werden, können wir sie weiter ins Landesinnere bewegen. Jetzt ist der Großteil des Landes grau geworden.Wolken mit Verdunstung der Feuchtigkeit.Ändern wir es SetTerrainType
so, dass es Feuchtigkeit anstelle von Wolken anzeigt, da wir es verwenden werden, um die Arten von Reliefs zu bestimmen. cell.SetMapData(climate[i].moisture);
Feuchtigkeitsanzeige.Zu diesem Zeitpunkt sieht die Luftfeuchtigkeit den Wolken ziemlich ähnlich (außer dass alle Unterwasserzellen weiß sind), aber das wird sich bald ändern.Niederschlag abfließen
Verdunstung ist nicht der einzige Weg, auf dem Feuchtigkeit die Zelle verlassen kann. Der Wasserkreislauf sagt uns, dass der größte Teil der Feuchtigkeit, die dem Land hinzugefügt wird, irgendwie im Wasser landet. Der auffälligste Prozess ist der Wasserfluss über Land unter dem Einfluss der Schwerkraft. Wir werden keine echten Flüsse simulieren, sondern einen benutzerdefinierten Niederschlagsabflusskoeffizienten verwenden. Es zeigt den Prozentsatz des Wassers an, das in die unteren Bereiche abfließt. Standardmäßig beträgt die Aktie 25%. [Range(0f, 1f)] public float runoffFactor = 0.25f;
Schieberegler entleeren.Wir werden keine Flüsse erzeugen?.
Der Wasserabfluss wirkt wie eine Wolkenstreuung, jedoch mit drei Unterschieden. Erstens wird nicht die gesamte Feuchtigkeit aus der Zelle entfernt. Zweitens trägt es Feuchtigkeit, keine Wolken. Drittens geht es runter, das heißt nur zu Nachbarn mit geringerer Höhe. Der Abflusskoeffizient beschreibt die Menge an Feuchtigkeit, die aus der Zelle herausfließen würde, wenn alle Nachbarn niedriger wären, aber oft sind sie geringer. Dies bedeutet, dass wir die Zellfeuchtigkeit nur reduzieren, wenn wir unten einen Nachbarn finden. 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; }
Wasser läuft auf eine niedrigere Höhe ab.Infolgedessen haben wir eine vielfältigere Verteilung der Luftfeuchtigkeit, da hohe Zellen ihre Feuchtigkeit auf die niedrigere übertragen. Wir sehen auch viel weniger Feuchtigkeit in den Küstenzellen, weil sie die Feuchtigkeit in die Unterwasserzellen ableiten. Um diesen Effekt abzuschwächen, müssen wir auch den Wasserstand verwenden, um festzustellen, ob die Zelle niedriger ist, dh die scheinbare Höhe nehmen. int elevationDelta = neighbor.ViewElevation - cell.ViewElevation;
Verwenden Sie die sichtbare Höhe.Versickerung
Wasser fließt nicht nur nach unten, es breitet sich aus, sickert durch die ebene Topographie und wird vom Land neben den Gewässern absorbiert. Dieser Effekt hat zwar nur geringe Auswirkungen, ist jedoch nützlich, um die Verteilung der Luftfeuchtigkeit zu glätten. Fügen Sie ihn daher der Simulation hinzu. Erstellen wir einen eigenen benutzerdefinierten Koeffizienten, der standardmäßig 0,125 beträgt. [Range(0f, 1f)] public float seepageFactor = 0.125f;
Leckage-Schieberegler.Das Versickern ähnelt einem Abfluss, außer dass es verwendet wird, wenn der Nachbar die gleiche sichtbare Höhe wie die Zelle selbst hat. 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; }
Ein wenig Leckage hinzugefügt.EinheitspaketRegenschatten
Obwohl wir bereits eine würdige Simulation des Wasserkreislaufs erstellt haben, sieht es nicht sehr interessant aus, da es keine Regenschatten gibt, die die klimatischen Unterschiede am deutlichsten zeigen. Regenschatten sind Gebiete, in denen im Vergleich zu benachbarten Gebieten ein erheblicher Niederschlagsmangel besteht. Solche Gebiete existieren, weil Berge verhindern, dass die Wolken sie erreichen. Ihre Schaffung erfordert hohe Berge und eine dominante Windrichtung.Der Wind
Beginnen wir mit dem Hinzufügen einer dominanten Windrichtung zur Simulation. Obwohl die dominanten Windrichtungen auf der Erdoberfläche stark variieren, werden wir mit einer anpassbaren globalen Windrichtung auskommen. Verwenden wir standardmäßig Nordwesten. Lassen Sie uns außerdem die Windkraft von 1 bis 10 mit einem Standardwert von 4 einstellbar machen. public HexDirection windDirection = HexDirection.NW; [Range(1f, 10f)] public float windStrength = 4f;
Die Richtung und Stärke des Windes.Die Stärke des dominanten Windes wird relativ zur Gesamtstreuung der Wolken ausgedrückt. Wenn die Windkraft 1 ist, ist die Streuung in alle Richtungen gleich. Wenn es 2 ist, ist die Streuung in Windrichtung um zwei höher als in andere Richtungen und so weiter. Wir können dies tun, indem wir den Divisor in der Wolkenstreuungsformel ändern. Anstelle von sechs entspricht dies fünf plus Windkraft. float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
Zusätzlich bestimmt die Windrichtung die Richtung, aus der der Wind weht. Daher müssen wir die entgegengesetzte Richtung als Hauptstreurichtung verwenden. HexDirection mainDispersalDirection = windDirection.Opposite(); float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
Jetzt können wir überprüfen, ob sich der Nachbar in der Hauptstreurichtung befindet. Wenn ja, dann müssen wir die Streuung der Wolken mit der Kraft des Windes multiplizieren. ClimateData neighborClimate = climate[neighbor.Index]; if (d == mainDispersalDirection) { neighborClimate.clouds += cloudDispersal * windStrength; } else { neighborClimate.clouds += cloudDispersal; }
Nordwestwind, Kraft 4.Der dominierende Wind erhöht die Richtungsverteilung der Feuchtigkeit über Land. Je stärker der Wind, desto stärker wird der Effekt.Absolute Höhe
Die zweite Zutat, um Regenschatten zu bekommen, sind die Berge. Wir haben keine strenge Klassifizierung dessen, was ein Berg ist, so wie es die Natur auch nicht hat. Nur die absolute Höhe ist wichtig. Wenn sich die Luft über den Berg bewegt, muss sie aufsteigen, wird gekühlt und enthält möglicherweise weniger Wasser, was zu Niederschlägen führt, bevor Luft über den Berg strömt. Infolgedessen erhalten wir auf der anderen Seite trockene Luft, dh einen Regenschatten.Je höher die Luft steigt, desto weniger Wasser kann sie enthalten. In unserer Simulation können wir uns dies als eine erzwungene Einschränkung des maximalen Wolkenwerts für jede Zelle vorstellen. Je höher die sichtbare Zellenhöhe ist, desto niedriger sollte dieses Maximum sein. Der einfachste Weg, dies zu tun, besteht darin, das Maximum auf 1 abzüglich der scheinbaren Höhe, geteilt durch die maximale Höhe, zu setzen. Tatsächlich teilen wir jedoch durch ein Maximum von minus 1. Dadurch kann ein kleiner Teil der Wolken auch durch die höchsten Zellen gelangen. Wir weisen dieses Maximum nach der Berechnung des Niederschlags und vor der Streuung zu. float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation; float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); HexDirection mainDispersalDirection = windDirection.Opposite();
Wenn wir dadurch mehr Wolken als akzeptabel bekommen, wandeln wir die überschüssigen Wolken einfach in Feuchtigkeit um. Auf diese Weise fügen wir zusätzlichen Niederschlag hinzu, wie es in echten Bergen der Fall ist. float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); if (cellClimate.clouds > cloudMaximum) { cellClimate.moisture += cellClimate.clouds - cloudMaximum; cellClimate.clouds = cloudMaximum; }
Regenschatten durch Höhenlage.EinheitspaketWir vervollständigen die Simulation
Zu diesem Zeitpunkt haben wir bereits eine sehr hochwertige Teilsimulation des Wasserkreislaufs. Lassen Sie es uns ein wenig ordnen und dann anwenden, um die Art des Reliefs der Zellen zu bestimmen.Paralleles Rechnen
Wie bereits unter dem Spoiler erwähnt, beeinflusst die Reihenfolge, in der Zellen gebildet werden, das Simulationsergebnis. Idealerweise sollte dies nicht so sein und im Wesentlichen bilden wir alle Zellen parallel. Dies kann erreicht werden, indem alle Änderungen des aktuellen Bildungsstadiums auf die zweite Liste des Klimas angewendet werden nextClimate
. List<ClimateData> climate = new List<ClimateData>(); List<ClimateData> nextClimate = new List<ClimateData>();
Löschen und initialisieren Sie diese Liste wie alle anderen auch. Dann werden wir Listen zu jedem Zyklus austauschen. In diesem Fall verwendet die Simulation abwechselnd die beiden Listen und wendet die aktuellen und nächsten Klimadaten an. 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; } }
Wenn eine Zelle das Klima ihres Nachbarn beeinflusst, müssen wir die folgenden Klimadaten ändern, nicht die aktuellen. 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; }
Und anstatt die folgenden Klimadaten zurück in die aktuelle Klimaliste zu kopieren, erhalten wir die folgenden Klimadaten, fügen die aktuelle Luftfeuchtigkeit hinzu und kopieren sie alle in die nächste Liste. Danach setzen wir die Daten in der aktuellen Liste zurück, damit sie für den nächsten Zyklus aktualisiert werden.
Stellen wir dabei auch die Luftfeuchtigkeit auf maximal 1 ein, damit Landzellen nicht feuchter als unter Wasser sind. nextCellClimate.moisture += cellClimate.moisture; if (nextCellClimate.moisture > 1f) { nextCellClimate.moisture = 1f; } nextClimate[cellIndex] = nextCellClimate;
Paralleles Rechnen.Quellfeuchtigkeit
Es besteht die Möglichkeit, dass die Simulation zu viel trockenes Land erzeugt, insbesondere bei einem hohen Prozentsatz an Land. Um das Bild zu verbessern, können wir eine benutzerdefinierte Anfangsfeuchtigkeit mit einem Standardwert von 0,1 hinzufügen. [Range(0f, 1f)] public float startingMoisture = 0.1f;
Oben ist der Schieberegler der ursprünglichen Luftfeuchtigkeit.Wir verwenden diesen Wert für die Luftfeuchtigkeit der anfänglichen Klimaliste, jedoch nicht für Folgendes. ClimateData initialData = new ClimateData(); initialData.moisture = startingMoisture; ClimateData clearData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(clearData); }
Mit ursprünglicher Luftfeuchtigkeit.Biomes definieren
Wir schließen mit der Verwendung von Feuchtigkeit anstelle von Höhe, um die Art der Zellentlastung festzulegen. Verwenden wir Schnee für vollständig trockenes Land, für trockene Regionen verwenden wir Schnee, dann gibt es Stein, Gras für ausreichend Feuchtigkeit und Land für wassergesättigte und Unterwasserzellen. Am einfachsten ist es, fünf Intervalle in Schritten von 0,2 zu verwenden. 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); } }
Biomes.Bei gleichmäßiger Verteilung ist das Ergebnis nicht sehr gut und sieht unnatürlich aus. Es ist besser, andere Schwellenwerte zu verwenden, z. B. 0,05, 0,12, 0,28 und 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; }
Modifizierte Biome.EinheitspaketTeil 26: Biomes und Flüsse
- Wir schaffen die Flüsse, die aus hohen Zellen mit Feuchtigkeit stammen.
- Wir erstellen ein einfaches Temperaturmodell.
- Wir verwenden die Biomatrix für die Zellen und ändern sie dann.
In diesem Teil werden wir den Wasserkreislauf mit Flüssen und Temperaturen ergänzen und den Zellen interessantere Biome zuweisen.Das Tutorial wurde mit Unity 2017.3.0p3 erstellt.Hitze und Wasser beleben die Karte.Flusserzeugung
Flüsse sind eine Folge des Wasserkreislaufs. Tatsächlich werden sie durch Abflüsse gebildet, die mit Hilfe der Kanalerosion herausgerissen werden. Dies bedeutet, dass Sie Flüsse basierend auf dem Wert der Zellabläufe hinzufügen können. Dies garantiert jedoch nicht, dass wir etwas bekommen, das echten Flüssen ähnelt. Wenn wir den Fluss starten, muss er so weit wie möglich fließen, möglicherweise durch viele Zellen. Dies steht nicht im Einklang mit unserer Simulation des Wasserkreislaufs, bei dem Zellen parallel verarbeitet werden. Darüber hinaus ist normalerweise die Kontrolle der Anzahl der Flüsse auf einer Karte erforderlich.Da die Flüsse sehr unterschiedlich sind, werden wir sie separat erzeugen. Wir verwenden die Ergebnisse der Wasserkreislaufsimulation, um den Standort der Flüsse zu bestimmen, aber die Flüsse haben wiederum keinen Einfluss auf die Simulation.Warum ist der Fluss manchmal falsch?TriangulateWaterShore
, . , . , , . , . , , . («»).
void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary( e1, e2, cell.HasIncomingRiver && cell.IncomingRiver == direction, indices ); } … }
Zellen mit hoher Luftfeuchtigkeit
Auf unseren Karten kann eine Zelle einen Fluss haben oder nicht. Darüber hinaus können sie verzweigen oder verbinden. In Wirklichkeit sind Flüsse viel flexibler, aber wir müssen mit dieser Annäherung auskommen, die nur große Flüsse erzeugt. Am wichtigsten ist, dass wir den Ort des Beginns eines großen Flusses bestimmen, der zufällig ausgewählt wird.Da Flüsse Wasser benötigen, muss sich die Quelle des Flusses in einer Zelle mit hoher Luftfeuchtigkeit befinden. Das reicht aber nicht. Flüsse fließen die Hänge hinunter, daher sollte die Quelle idealerweise eine große Höhe haben. Je höher die Zelle über dem Wasserspiegel ist, desto besser ist sie für die Rolle der Flussquelle geeignet. Wir können dies als Kartendaten visualisieren, indem wir die Zellenhöhe durch die maximale Höhe teilen. Damit das Ergebnis relativ zum Wasserstand erhalten wird, werden wir es vor dem Teilen von beiden Höhen abziehen. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … float data = (float)(cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data); } }
Luftfeuchtigkeit und Höhe. Große Kartennummer 1208905299 mit Standardeinstellungen.Die besten Kandidaten sind Zellen mit hoher Luftfeuchtigkeit und hoher Höhe. Wir können diese Kriterien kombinieren, indem wir sie multiplizieren. Das Ergebnis ist der Wert der Fitness oder des Gewichts für die Quellen der Flüsse. float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data);
Gewichte für die Quellen von Flüssen.Idealerweise würden wir diese Gewichte verwenden, um die zufällige Auswahl der Quellzelle abzulehnen. Obwohl wir eine Liste mit den richtigen Gewichten erstellen und daraus auswählen können, ist dies ein nicht trivialer Ansatz, der den Generierungsprozess verlangsamt. Eine einfachere Klassifizierung der Signifikanz in vier Ebenen wird uns ausreichen. Die ersten Kandidaten sind Gewichte mit Werten über 0,75. Gute Kandidaten haben Gewichte von 0,5. Geeignete Kandidaten sind größer als 0,25. Alle anderen Zellen werden verworfen. Lassen Sie uns zeigen, wie es grafisch aussieht. 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); }
Gewichtskategorien von Flussquellen.Mit diesem Klassifizierungsschema erhalten wir wahrscheinlich Flüsse mit Quellen in den höchsten und feuchtesten Bereichen der Karte. Dennoch bleibt die Wahrscheinlichkeit bestehen, Flüsse in relativ trockenen oder niedrigen Gebieten zu erzeugen, was die Variabilität erhöht.Fügen Sie eine Methode hinzu CreateRivers
, die eine Liste von Zellen basierend auf diesen Kriterien füllt. Geeignete Zellen werden dieser Liste einmal, gute zweimal und die Hauptkandidaten viermal hinzugefügt. Unterwasserzellen werden immer verworfen, daher können Sie sie nicht überprüfen. 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); }
Diese Methode muss nachher aufgerufen werden, CreateClimate
damit uns Feuchtigkeitsdaten zur Verfügung stehen. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); CreateRivers(); SetTerrainType(); … }
Nachdem Sie die Klassifizierung abgeschlossen haben, können Sie die Visualisierung der Daten auf der Karte entfernen. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { …
Flusspunkte
Wie viele Flüsse brauchen wir? Dieser Parameter muss anpassbar sein. Da die Länge der Flüsse variiert, ist es logischer, sie mit Hilfe von Flusspunkten zu steuern, die die Anzahl der Landzellen bestimmen, in denen die Flüsse enthalten sein sollen. Lassen Sie uns sie als Prozentsatz mit maximal 20% und einem Standardwert von 10% ausdrücken. Wie der Prozentsatz an Sushi ist dies ein Zielwert, kein garantierter. Infolgedessen haben wir möglicherweise zu wenige Kandidaten oder Flüsse, die zu kurz sind, um die erforderliche Landmenge abzudecken. Deshalb sollte der maximale Prozentsatz nicht zu groß sein. [Range(0, 20)] public int riverPercentage = 10;
Slider Prozent Flüsse.Um Flusspunkte zu bestimmen, ausgedrückt als Anzahl der Zellen, müssen wir uns daran erinnern, in wie vielen Landzellen erzeugt wurden 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; } }
Im Inneren kann die CreateRivers
Anzahl der Flusspunkte jetzt auf die gleiche Weise berechnet werden wie in 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); }
Außerdem werden wir weiterhin zufällige Zellen aus der ursprünglichen Liste nehmen und entfernen, solange wir noch Punkte und Quellzellen haben. Wenn die Anzahl der Punkte erreicht ist, wird in der Konsole eine Warnung angezeigt. 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."); }
Zusätzlich fügen wir eine Methode zum direkten Erstellen von Flüssen hinzu. Als Parameter benötigt er eine Anfangszelle und muss nach Fertigstellung die Länge des Flusses zurückgeben. Wir beginnen mit dem Speichern einer Methode, die die Länge Null zurückgibt. int CreateRiver (HexCell origin) { int length = 0; return length; }
Wir werden diese Methode am Ende des Zyklus, den wir gerade hinzugefügt haben CreateRivers
, aufrufen , um die Anzahl der verbleibenden Punkte zu reduzieren. Wir stellen sicher, dass ein neuer Fluss nur dann erstellt wird, wenn in der ausgewählten Zelle kein Fluss fließt. while (riverBudget > 0 && riverOrigins.Count > 0) { … if (!origin.HasRiver) { riverBudget -= CreateRiver(origin); } }
Aktuelle Flüsse
Es ist logisch, Flüsse zu schaffen, die zum Meer oder zu anderen Gewässern fließen. Wenn wir von der Quelle ausgehen, erhalten wir sofort die Länge 1. Danach wählen wir einen zufälligen Nachbarn aus und erhöhen die Länge. Wir bewegen uns weiter, bis wir die Unterwasserzelle erreichen. 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; }
Zufällige Flüsse.Als Ergebnis eines solchen naiven Ansatzes erhalten wir zufällig verstreute Flussfragmente, hauptsächlich aufgrund des Ersatzes zuvor erzeugter Flüsse. Dies kann sogar zu Fehlern führen, da wir nicht prüfen, ob der Nachbar tatsächlich existiert. Wir müssen alle Richtungen in der Schleife überprüfen und sicherstellen, dass dort ein Nachbar ist. Wenn dies der Fall ist, fügen wir diese Richtung der Liste der möglichen Strömungsrichtungen hinzu, jedoch nur, wenn der Fluss noch nicht durch diesen Nachbarn fließt. Wählen Sie dann einen zufälligen Wert aus dieser Liste. 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 =
Mit diesem neuen Ansatz stehen möglicherweise keine Strömungsrichtungen zur Verfügung. In diesem Fall kann der Fluss nicht mehr weiter fließen und muss enden. Wenn in diesem Moment die Länge 1 ist, bedeutet dies, dass wir nicht aus der ursprünglichen Zelle austreten konnten, das heißt, es kann überhaupt keinen Fluss geben. In diesem Fall ist die Länge des Flusses Null. flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (flowDirections.Count == 0) { return length > 1 ? length : 0; }
Erhaltene Flüsse.Lauf runter
Jetzt retten wir die bereits geschaffenen Flüsse, aber wir können immer noch isolierte Fragmente der Flüsse erhalten. Dies geschieht, weil wir die Höhen ignoriert haben. Jedes Mal, wenn wir den Fluss zwangen, in eine größere Höhe zu fließen, wurde HexCell.SetOutgoingRiver
dieser Versuch unterbrochen, was zu Brüchen in den Flüssen führte. Daher müssen wir auch Richtungen überspringen, die dazu führen, dass Flüsse hochfließen. if (!neighbor || neighbor.HasRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } flowDirections.Add(d);
Flüsse fließen herab.So werden wir viele Fragmente von Flüssen los, aber einige bleiben noch übrig. Von diesem Moment an ist es eine Frage der Verfeinerung, die hässlichsten Flüsse loszuwerden. Flüsse fließen zunächst lieber so schnell wie möglich ab. Sie werden nicht unbedingt die kürzest mögliche Route wählen, aber die Wahrscheinlichkeit dafür ist groß. Um dies zu simulieren, fügen wir der Liste dreimal Anweisungen hinzu. if (delta > 0) { continue; } if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } flowDirections.Add(d);
Vermeiden Sie scharfe Kurven
Wasser fließt nicht nur nach unten, sondern hat auch Trägheit. Ein Fluss fließt eher gerade oder biegt sich leicht als eine plötzliche scharfe Kurve. Wir können diese Verzerrung hinzufügen, indem wir die letzte Richtung des Flusses verfolgen. Wenn die potenzielle Richtung des Stroms nicht zu stark von dieser Richtung abweicht, fügen Sie sie erneut zur Liste hinzu. Dies ist kein Problem für die Quelle, daher fügen wir es einfach immer wieder hinzu. 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; }
Dies verringert die Wahrscheinlichkeit von Flüssen im Zickzack, die hässlich aussehen, erheblich.Weniger scharfe Kurven.Zusammenfluss des Flusses
Manchmal stellt sich heraus, dass der Fluss direkt neben der Quelle des zuvor geschaffenen Flusses fließt. Wenn sich die Quelle dieses Flusses nicht in einer höheren Höhe befindet, können wir entscheiden, dass der neue Fluss in den alten fließt. Als Ergebnis erhalten wir einen langen Fluss und nicht zwei benachbarte.Dazu lassen wir den Nachbarn nur passieren, wenn sich ein Fluss darin befindet oder wenn er die Quelle des aktuellen Flusses ist. Nachdem wir festgestellt haben, dass diese Richtung nicht stimmt, prüfen wir, ob ein abfließender Fluss vorhanden ist. Wenn ja, dann haben wir wieder den alten Fluss gefunden. Da dies ziemlich selten vorkommt, werden wir uns nicht mit der Überprüfung anderer benachbarter Quellen befassen und die Flüsse sofort kombinieren. HexCell neighbor = cell.GetNeighbor(d);
Flüsse vor und nach dem Pooling.Abstand halten
Da gute Kandidaten für die Quellrolle normalerweise in Gruppen zusammengefasst sind, erhalten wir Flusscluster. Außerdem haben wir möglicherweise Flüsse, die die Quelle direkt neben dem Stausee nehmen, was zu Flüssen der Länge 1 führt. Wir können die Quellen verteilen und diejenigen verwerfen, die sich in der Nähe des Flusses oder Stausees befinden. Wir tun dies, indem wir die Nachbarn der ausgewählten Quelle in einer Schleife im Inneren umgehen CreateRivers
. Wenn wir einen Nachbarn finden, der gegen die Regeln verstößt, passt die Quelle nicht zu uns und wir müssen sie überspringen. 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); } }
Und obwohl die Flüsse immer noch nebeneinander fließen, bedecken sie tendenziell ein größeres Gebiet.Ohne Distanz und damit.Wir beenden den Fluss mit einem See
Nicht alle Flüsse erreichen den Stausee, einige bleiben in den Tälern stecken oder werden von anderen Flüssen blockiert. Dies ist kein besonderes Problem, da oft auch echte Flüsse zu verschwinden scheinen. Dies kann beispielsweise passieren, wenn sie unter der Erde fließen, sich in einem sumpfigen Gebiet verteilen oder austrocknen. Unsere Flüsse können sich das nicht vorstellen, also enden sie einfach.Wir können jedoch versuchen, die Anzahl solcher Fälle zu minimieren. Obwohl wir die Flüsse nicht vereinen oder zum Fließen bringen können, können wir sie in Seen enden lassen, die in der Realität oft vorkommen und gut aussehen. DafürCreateRiver
sollte den Wasserstand in der Zelle erhöhen, wenn sie stecken bleibt. Die Möglichkeit hierfür hängt von der Mindesthöhe der Nachbarn dieser Zelle ab. Um dies beim Studieren von Nachbarn zu verfolgen, ist daher eine kleine Änderung des Codes erforderlich. while (!cell.IsUnderwater) { int minNeighborElevation = int.MaxValue; flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d);
Wenn wir nicht weiterkommen, müssen wir zunächst prüfen, ob wir noch an der Quelle sind. Wenn ja, dann stornieren Sie einfach den Fluss. Andernfalls prüfen wir, ob alle Nachbarn mindestens so hoch sind wie die aktuelle Zelle. Wenn ja, können wir das Wasser auf dieses Niveau bringen. Dadurch entsteht aus einer Zelle ein See, es sei denn, die Zellenhöhe bleibt auf dem gleichen Niveau. Wenn ja, weisen Sie die Höhe einfach eine Ebene unter dem Wasserspiegel zu. if (flowDirections.Count == 0) {
Die Enden von Flüssen ohne Seen und mit Seen. In diesem Fall beträgt der Prozentsatz der Flüsse 20.Beachten Sie, dass wir jetzt möglicherweise Unterwasserzellen über dem Wasserspiegel haben, der zur Erstellung der Karte verwendet wurde. Sie werden Seen über dem Meeresspiegel bezeichnen.Zusätzliche Seen
Wir können auch Seen schaffen, auch wenn wir nicht festsitzen. Dies kann dazu führen, dass ein Fluss in den See hinein und aus ihm heraus fließt. Wenn wir nicht stecken bleiben, kann ein See geschaffen werden, indem der Wasserstand und dann die aktuelle Zellenhöhe erhöht und dann die Zellenhöhe verringert werden. Dies gilt nur, wenn die Mindesthöhe des Nachbarn mindestens der Höhe der aktuellen Zelle entspricht. Wir tun dies am Ende des Flusszyklus und bevor wir zur nächsten Zelle übergehen. while (!cell.IsUnderwater) { … if (minNeighborElevation >= cell.Elevation) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; } cell = cell.GetNeighbor(direction); }
Ohne zusätzliche Seen und mit ihnen.Einige Seen sind wunderschön, aber ohne Grenzen können wir zu viele Seen schaffen. Fügen wir daher eine benutzerdefinierte Wahrscheinlichkeit für zusätzliche Seen mit einem Standardwert von 0,25 hinzu. [Range(0f, 1f)] public float extraLakeProbability = 0.25f;
Wenn möglich, wird sie die Wahrscheinlichkeit der Erzeugung eines zusätzlichen Sees kontrollieren. if ( minNeighborElevation >= cell.Elevation && Random.value < extraLakeProbability ) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; }
Zusätzliche Seen.Was ist mit der Schaffung von Seen mit mehr als einer Zelle?, , , . . : . , . , , , .
EinheitspaketTemperatur
Wasser ist nur einer der Faktoren, die das Biom einer Zelle bestimmen können. Ein weiterer wichtiger Faktor ist die Temperatur. Obwohl wir den Fluss und die Diffusion von Temperaturen wie die Simulation von Wasser simulieren können, brauchen wir nur einen komplexen Faktor, um ein interessantes Klima zu schaffen. Lassen Sie uns daher die Temperatur einfach halten und für jede Zelle einstellen.Temperatur und Breitengrad
Der größte Einfluss auf die Temperatur ist der Breitengrad. Es ist heiß am Äquator, kalt an den Polen und es gibt einen reibungslosen Übergang zwischen ihnen. Erstellen wir eine Methode DetermineTemperature
, die die Temperatur einer bestimmten Zelle zurückgibt. Zu Beginn verwenden wir einfach die Z-Koordinate der Zelle geteilt durch die Dimension Z als Breitengrad und verwenden diesen Wert dann als Temperatur. float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; return latitude; }
Wir definieren die Temperatur in SetTerrainType
und verwenden sie als Kartendaten. 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; … } }
Breitengrad als Temperatur, südliche Hemisphäre.Wir erhalten einen linearen Temperaturgradienten, der von unten nach oben zunimmt. Sie können damit die südliche Hemisphäre simulieren, mit einer Stange unten und einem Äquator oben. Wir müssen aber nicht die gesamte Hemisphäre beschreiben. Mit einem kleineren Temperaturunterschied oder überhaupt keinem Unterschied können wir einen kleineren Bereich beschreiben. Dazu werden wir niedrige und hohe Temperaturen anpassbar machen. Wir werden diese Temperaturen im Bereich von 0 bis 1 einstellen und die Extremwerte als Standardwerte verwenden. [Range(0f, 1f)] public float lowTemperature = 0f; [Range(0f, 1f)] public float highTemperature = 1f;
Temperaturregler.Wir wenden den Temperaturbereich durch lineare Interpolation an, wobei der Breitengrad als Interpolator verwendet wird. Da wir den Breitengrad als Wert von 0 bis 1 ausdrücken, können wir ihn verwenden Mathf.LerpUnclamped
. float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); return temperature; }
Beachten Sie, dass niedrige Temperaturen nicht unbedingt niedriger als hoch sind. Falls gewünscht, können Sie sie umdrehen.Hemisphäre
Jetzt können wir die südliche und möglicherweise die nördliche Hemisphäre simulieren, wenn wir zuerst die Temperaturen messen. Es ist jedoch viel bequemer, eine separate Konfigurationsoption zu verwenden, um zwischen den Hemisphären zu wechseln. Lassen Sie uns eine Aufzählung und ein Feld dafür erstellen. Daher werden wir auch die Option hinzufügen, beide Hemisphären zu erstellen, die standardmäßig anwendbar ist. public enum HemisphereMode { Both, North, South } public HemisphereMode hemisphere;
Die Wahl der Hemisphäre.Wenn wir die nördliche Hemisphäre brauchen, können wir den Breitengrad einfach umdrehen und von 1 subtrahieren. Um beide Hemisphären zu simulieren, sollten sich die Pole unter und über der Karte befinden und der Äquator sollte in der Mitte liegen. Sie können dies tun, indem Sie den Breitengrad verdoppeln, während die untere Hemisphäre korrekt verarbeitet wird und die obere einen Breitengrad von 1 bis 2 hat. Um dies zu beheben, subtrahieren wir den Breitengrad von 2, wenn er 1 überschreitet. 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; }
Beide Hemisphären.Es ist erwähnenswert, dass dies die Möglichkeit schafft, eine exotische Karte zu erstellen, in der der Äquator kalt und die Pole warm sind.Je höher desto kälter
Neben dem Breitengrad wird die Temperatur auch maßgeblich von der Höhe beeinflusst. Je höher wir steigen, desto kälter wird es im Durchschnitt. Wir können dies zu einem Faktor machen, wie wir es bei den Flusskandidaten getan haben. In diesem Fall verwenden wir die Zellenhöhe. Außerdem nimmt dieser Indikator mit der Höhe ab, dh gleich 1 minus der Höhe geteilt durch das Maximum relativ zum Wasserstand. Damit der Indikator auf der höchsten Ebene nicht auf Null fällt, addieren wir zum Divisor. Verwenden Sie dann diesen Indikator, um die Temperatur zu skalieren. float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); return temperature;
Die Höhe beeinflusst die Temperatur.Temperaturschwankungen
Wir können die Einfachheit des Temperaturgradienten weniger bemerkbar machen, indem wir zufällige Temperaturschwankungen hinzufügen. Eine kleine Chance, es realistischer zu machen, aber mit zu viel Schwankung werden sie willkürlich aussehen. Lassen Sie uns die Stärke von Temperaturschwankungen anpassbar machen und sie als maximale Temperaturabweichung mit einem Standardwert von 0,1 ausdrücken. [Range(0f, 1f)] public float temperatureJitter = 0.1f;
Schieberegler für Temperaturschwankungen.Solche Schwankungen sollten mit geringfügigen lokalen Änderungen gleichmäßig sein. Hierfür können Sie unsere Rauschstruktur verwenden. Wir werden HexMetrics.SampleNoise
die Position der Zelle, skaliert mit 0,1, aufrufen und als Argument verwenden. Nehmen wir den Kanal W, zentrieren ihn und skalieren ihn mit dem Schwingungskoeffizienten. Dann addieren wir diesen Wert zur zuvor berechneten Temperatur. temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); temperature += (HexMetrics.SampleNoise(cell.Position * 0.1f).w * 2f - 1f) * temperatureJitter; return temperature;
Temperaturschwankungen mit Werten von 0,1 und 1.Wir können den Schwankungen auf jeder Karte eine leichte Variabilität hinzufügen, indem wir zufällig aus den vier Rauschkanälen auswählen. Stellen Sie den Kanal einmal ein SetTerrainType
und indizieren Sie dann die Farbkanäle 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; }
Unterschiedliche Temperaturschwankungen bei maximaler Kraft.EinheitspaketBiomes
Nachdem wir Daten zu Luftfeuchtigkeit und Temperatur haben, können wir eine Biomatrix erstellen. Durch Indizieren dieser Matrix können wir allen Zellen Biomes zuweisen, wodurch eine komplexere Landschaft entsteht als bei Verwendung nur einer Datendimension.Biom-Matrix
Es gibt viele Klimamodelle, aber wir werden keines davon verwenden. Wir werden es sehr einfach machen, wir interessieren uns nur für Logik. Trocken bedeutet Wüste (kalt oder heiß), dafür verwenden wir Sand. Kalt und nass bedeutet Schnee. Heiß und feucht bedeutet viel Vegetation, also Gras. Zwischen ihnen wird eine Taiga oder Tundra sein, die wir als graue Textur der Erde bezeichnen werden. Eine 4 × 4-Matrix reicht aus, um Übergänge zwischen diesen Biomen zu erzeugen.Zuvor haben wir Höhentypen basierend auf fünf Feuchtigkeitsintervallen zugewiesen. Wir senken einfach den trockensten Streifen auf 0,05 und speichern den Rest. Für Temperaturbänder verwenden wir 0,1, 0,3, 0,6 und höher. Der Einfachheit halber werden diese Werte in statischen Arrays festgelegt. static float[] temperatureBands = { 0.1f, 0.3f, 0.6f }; static float[] moistureBands = { 0.12f, 0.28f, 0.85f };
Obwohl wir nur den Relieftyp anhand des Bioms angeben, können wir damit andere Parameter bestimmen. Definieren wir daher eine HexMapGenerator
Struktur Biome
, die die Konfiguration eines einzelnen Bioms beschreibt. Bisher enthält es nur den Bump-Index sowie die entsprechende Konstruktormethode. struct Biome { public int terrain; public Biome (int terrain) { this.terrain = terrain; } }
Wir verwenden diese Struktur, um ein statisches Array mit Matrixdaten zu erstellen. Wir verwenden Feuchtigkeit als X-Koordinate und Temperatur als Y. Wir füllen die Linie mit der niedrigsten Temperatur mit Schnee, die zweite Linie mit Tundra und die anderen beiden mit Gras. Dann ersetzen wir die trockenste Säule durch die Wüste und definieren die Temperaturwahl neu. 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) };
Matrix von Biomen mit Indizes eines eindimensionalen Arrays.Biomdefinition
Um die SetTerrainType
Zellen im Biom zu bestimmen , werden wir die Temperatur- und Feuchtigkeitsbereiche im Zyklus durchlaufen, um die benötigten Matrixindizes zu bestimmen. Wir verwenden sie, um das gewünschte Biom zu erhalten und die Art der Zelltopographie zu spezifizieren. void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float temperature = DetermineTemperature(cell);
Relief basierend auf einer Biomatrix.Biom-Setup
Wir können über die in der Matrix definierten Biome hinausgehen. Beispielsweise werden in der Matrix alle trockenen Biome als Sandwüsten definiert, aber nicht alle trockenen Wüsten sind mit Sand gefüllt. Es gibt viele felsige Wüsten, die sehr unterschiedlich aussehen. Ersetzen wir deshalb einige der Wüstenzellen durch Steine. Wir werden dies einfach auf der Grundlage der Höhe tun: Sand befindet sich in geringer Höhe, und nackte Felsen befinden sich normalerweise oben.Angenommen, Sand verwandelt sich in Stein, wenn die Höhe der Zelle näher an der maximalen Höhe als am Wasserspiegel liegt. Dies ist die Höhenlinie der felsigen Wüsten, die wir zu Beginn berechnen können SetTerrainType
. Wenn wir einer Zelle mit Sand begegnen und ihre Höhe groß genug ist, verwandeln wir das Relief des Bioms in Stein. 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; } } }
Sandige und felsige Wüsten.Eine weitere Änderung basierend auf der Höhe besteht darin, Zellen in maximaler Höhe zu zwingen, sich unabhängig von ihrer Temperatur in Schneespitzen zu verwandeln, nur wenn sie nicht zu trocken sind. Dies erhöht die Wahrscheinlichkeit von Schneespitzen in der Nähe des heißen und feuchten Äquators. if (cellBiome.terrain == 0) { if (cell.Elevation >= rockDesertElevation) { cellBiome.terrain = 3; } } else if (cell.Elevation == elevationMaximum) { cellBiome.terrain = 4; }
Schneekappen in maximaler Höhe.Pflanzen
Lassen wir nun die Biomasse den Gehalt an Pflanzenzellen bestimmen. Fügen Sie dazu das Biome
Feld der Pflanzen hinzu und fügen Sie es in den Konstruktor ein. struct Biome { public int terrain, plant; public Biome (int terrain, int plant) { this.terrain = terrain; this.plant = plant; } }
In den kältesten und trockensten Biomen gibt es überhaupt keine Pflanzen. Im Übrigen sind die Pflanzen umso pflanzlicher, je wärmer und feuchter das Klima ist. Die zweite Feuchtigkeitssäule erhält nur die erste Pflanzenstufe für die heißeste Reihe, daher [0, 0, 0, 1]. Die dritte Spalte erhöht die Pegel um eins, mit Ausnahme von Schnee, dh [0, 1, 1, 2]. Und die feuchteste Säule erhöht sie wieder, das heißt, es stellt sich heraus [0, 2, 2, 3]. Ändern Sie das Array, biomes
indem Sie die Anlagenkonfiguration hinzufügen. 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) };
Matrix von Biomen mit Pflanzenebenen.Jetzt können wir das Niveau der Pflanzen für die Zelle einstellen. cell.TerrainTypeIndex = cellBiome.terrain; cell.PlantLevel = cellBiome.plant;
Biomes mit Pflanzen.Sehen Pflanzen jetzt anders aus?, . (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).
Wir können das Niveau der Pflanzen für Biome ändern. Zuerst müssen wir sicherstellen, dass sie nicht auf dem schneebedeckten Gelände erscheinen, das wir bereits einrichten konnten. Zweitens wollen wir das Niveau der Pflanzen entlang der Flüsse erhöhen, wenn es noch nicht das Maximum erreicht hat. 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;
Modifizierte Pflanzen.Unterwasserbiome
Bis zu diesem Moment haben wir die Unterwasserzellen völlig ignoriert. Fügen wir ihnen eine kleine Variation hinzu, und wir werden nicht für alle die Textur der Erde verwenden. Eine einfache Lösung basierend auf der Höhe reicht bereits aus, um ein interessanteres Bild zu erstellen. Verwenden wir zum Beispiel Gras für Zellen einen Schritt unter dem Wasserspiegel. Verwenden wir Gras auch für Zellen über dem Wasserspiegel, dh für Seen, die von Flüssen erzeugt werden. Zellen mit einer negativen Höhe sind Tiefseegebiete, daher verwenden wir Stein für sie. Alle anderen Zellen bleiben gemahlen. 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; } } }
Unterwasservariabilität.Fügen wir einige weitere Details für die Unterwasserzellen entlang der Küste hinzu. Dies sind Zellen mit mindestens einem Nachbarn über dem Wasser. Wenn eine solche Zelle flach ist, werden wir einen Strand schaffen. Und wenn es sich neben der Klippe befindet, ist es das dominierende visuelle Detail, und wir verwenden den Stein.Um dies festzustellen, werden wir die Nachbarn von Zellen überprüfen, die sich einen Schritt unter dem Wasserspiegel befinden. Zählen wir die Anzahl der Verbindungen durch Klippen und Hänge mit benachbarten Landzellen. 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; }
Jetzt können wir diese Informationen verwenden, um Zellen zu klassifizieren. Erstens, wenn mehr als die Hälfte der Nachbarn Land sind, dann haben wir es mit einem See oder einer Bucht zu tun. Für diese Zellen verwenden wir eine Grasstruktur. Wenn wir sonst Klippen haben, verwenden wir Stein. Wenn wir sonst Hänge haben, verwenden wir Sand, um einen Strand zu schaffen. Die einzige verbleibende Option ist ein flaches Gebiet vor der Küste, für das wir noch Gras verwenden. 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; } }
Variabilität der Küste.Lassen Sie uns abschließend überprüfen, ob wir im kältesten Temperaturbereich keine grünen Unterwasserzellen haben. Für solche Zellen benutzen wir die Erde. if (terrain == 1 && temperature < temperatureBands[0]) { terrain = 2; } cell.TerrainTypeIndex = terrain;
Wir hatten die Möglichkeit, zufällige Karten zu generieren, die mit vielen Konfigurationsoptionen sehr interessant und natürlich aussehen.EinheitspaketTeil 27: Eine Karte falten
- Wir teilen die Karten in Spalten, die verschoben werden können.
- Zentrieren Sie die Karte in der Kamera.
- Wir brechen alles zusammen.
In diesem letzten Teil werden wir Unterstützung für die Minimierung der Karte hinzufügen und die östlichen und westlichen Ränder verbinden.Das Tutorial wurde mit Unity 2017.3.0p3 erstellt.Durch das Falten dreht sich die Welt.Faltkarten
Unsere Karten können verwendet werden, um Bereiche unterschiedlicher Größe zu modellieren, sie sind jedoch immer auf eine rechteckige Form beschränkt. Wir können eine Karte einer Insel oder eines ganzen Kontinents erstellen, aber nicht des gesamten Planeten. Die Planeten sind kugelförmig, sie haben keine starren Grenzen, die die Bewegung auf ihrer Oberfläche behindern. Wenn Sie sich weiter in eine Richtung bewegen, kehren Sie früher oder später zum Ausgangspunkt zurück.Wir können kein Sechseckgitter um eine Kugel wickeln, eine solche Überlappung ist unmöglich. In bester Näherung wird die ikosaedrische Topologie verwendet, bei der die zwölf Zellen Pentagone sein müssen. Ohne Verzerrung oder Ausnahme kann das Netz jedoch um den Zylinder gewickelt werden. Verbinden Sie dazu einfach den östlichen und den westlichen Rand der Karte. Mit Ausnahme der Wrapping-Logik bleibt alles andere gleich.Ein Zylinder ist eine schlechte Annäherung an eine Kugel, da wir keine Pole modellieren können. Dies hinderte die Entwickler vieler Spiele jedoch nicht daran, das Falten von Ost nach West zum Modellieren von Planetenkarten zu verwenden. Polarregionen sind einfach nicht Teil der Spielzone.Wie wäre es nach Norden und Süden?, . , , . -, -. .
Es gibt zwei Möglichkeiten, eine zylindrische Faltung durchzuführen. Die erste besteht darin, die Karte tatsächlich zylindrisch zu machen, indem ihre Oberfläche und alles darauf gebogen werden, so dass die östlichen und westlichen Ränder in Kontakt sind. Jetzt spielen Sie nicht mehr auf einer ebenen Fläche, sondern auf einem echten Zylinder. Der zweite Ansatz besteht darin, eine flache Karte zu speichern und Teleportation oder Duplizierung zum Kollabieren zu verwenden. Die meisten Spiele verwenden den zweiten Ansatz, also werden wir es nehmen.Optionales Zusammenklappen
Die Notwendigkeit, die Karte zu reduzieren, hängt von ihrem Maßstab ab - lokal oder planetarisch. Wir können die Unterstützung von beiden nutzen, indem wir das Falten optional machen. Fügen Sie dazu dem Menü " Neue Karte erstellen" einen neuen Schalter hinzu, für den das Reduzieren standardmäßig aktiviert ist.Das Menü der neuen Karte mit der Option zum Reduzieren.Fügen Sie dem NewMapMenu
Feld eine Option zum Verfolgen der Auswahl sowie eine Methode zum Ändern der Auswahl hinzu. Lassen Sie uns diese Methode aufrufen, wenn sich der Status des Schalters ändert. bool wrapping = true; … public void ToggleWrapping (bool toggle) { wrapping = toggle; }
Wenn eine neue Karte angefordert wird, übergeben wir den Wert der Minimierungsoption. void CreateMap (int x, int z) { if (generateMaps) { mapGenerator.GenerateMap(x, z, wrapping); } else { hexGrid.CreateMap(x, z, wrapping); } HexMapCamera.ValidatePosition(); Close(); }
Ändern Sie es HexMapGenerator.GenerateMap
so, dass es dieses neue Argument akzeptiert und es dann an weiterleitet HexGrid.CreateMap
. public void GenerateMap (int x, int z, bool wrapping) { … grid.CreateMap(x, z, wrapping); … }
Code> HexGrid sollte wissen, ob wir zusammenbrechen. Fügen Sie also ein Feld hinzu und setzen Sie CreateMap
es. Andere Klassen sollten ihre Logik ändern, je nachdem, ob das Raster minimiert ist, daher werden wir das Feld allgemein machen. Darüber hinaus können Sie den Standardwert über den Inspektor festlegen. 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
Anrufe CreateMap
an zwei Orten besitzen. Wir können einfach ein eigenes Feld für das Kollapsargument verwenden. void Awake () { … CreateMap(cellCountX, cellCountZ, wrapping); } … public void Load (BinaryReader reader, int header) { … if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z, wrapping)) { return; } } … }
Der Gitterklappschalter ist standardmäßig aktiviert.Speichern und laden
Da für jede Karte eine Faltung festgelegt ist, muss sie gespeichert und geladen werden. Dies bedeutet, dass Sie das Dateispeicherformat ändern müssen, also erhöhen Sie die Versionskonstante in SaveLoadMenu
. const int mapFileVersion = 5;
Lassen HexGrid
Sie beim Speichern einfach den booleschen Faltwert nach der Kartengröße schreiben. public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); writer.Write(wrapping); … }
Beim Laden lesen wir es nur mit der richtigen Version der Datei. Wenn es anders ist, ist dies eine alte Karte und sollte nicht minimiert werden. Speichern Sie diese Informationen in einer lokalen Variablen und vergleichen Sie sie mit dem aktuellen Status der Faltung. Wenn dies anders ist, können wir die vorhandene Kartentopologie nicht auf die gleiche Weise wiederverwenden wie beim Laden einer Karte mit anderen Größen. 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; } } … }
Faltmetriken
Das Minimieren der Karte erfordert wesentliche Änderungen in der Logik, beispielsweise bei der Berechnung von Entfernungen. Daher können sie Code berühren, der keine direkte Verbindung zum Raster hat. Anstatt diese Informationen als Argumente zu übergeben, fügen wir sie hinzu HexMetrics
. Fügen Sie eine statische Ganzzahl hinzu, die die Faltgröße enthält, die der Breite der Karte entspricht. Wenn es größer als Null ist, handelt es sich um eine zusammenklappbare Karte. Fügen Sie eine Eigenschaft hinzu, um dies zu überprüfen. public static int wrapSize; public static bool Wrapping { get { return wrapSize > 0; } }
Wir müssen die Faltgröße für jeden Anruf einstellen HexGrid.CreateMap
. public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; HexMetrics.wrapSize = wrapping ? cellCountX : 0; … }
Da diese Daten die Neukompilierung im Wiedergabemodus nicht überleben, setzen wir sie ein OnEnable
. void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; HexMetrics.wrapSize = wrapping ? cellCountX : 0; ResetVisibility(); } }
Zellenbreite
Wenn wir mit zusammenklappbaren Karten arbeiten, müssen wir uns häufig mit Positionen entlang der X-Achse befassen, gemessen in der Breite der Zellen. Obwohl es dafür verwendet werden kann HexMetrics.innerRadius * 2f
, wäre es bequemer, wenn wir nicht jedes Mal eine Multiplikation hinzufügen würden. Fügen wir also eine Konstante hinzu HexMetrics.innerDiameter
. public const float innerRadius = outerRadius * outerToInner; public const float innerDiameter = innerRadius * 2f;
Wir können den Durchmesser bereits an drei Stellen verwenden. Erstens HexGrid.CreateCell
beim Positionieren einer neuen Zelle. void CreateCell (int x, int z, int i) { Vector3 position; position.x = (x + z * 0.5f - z / 2) * HexMetrics.innerDiameter; … }
Zweitens bei der HexMapCamera
Begrenzung der Position der Kamera. Vector3 ClampPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); … }
Und auch bei der HexCoordinates
Umrechnung von Position zu Koordinaten. public static HexCoordinates FromPosition (Vector3 position) { float x = position.x / HexMetrics.innerDiameter; … }
EinheitspaketKartenzentrierung
Wenn die Karte nicht kollabiert, hat sie klar definierte östliche und westliche Ränder und daher ein klares horizontales Zentrum. Bei einer zusammenklappbaren Karte ist jedoch alles anders. Es hat weder den östlichen noch den westlichen Rand noch das Zentrum. Alternativ können wir davon ausgehen, dass sich in der Mitte die Kamera befindet. Dies ist nützlich, da die Karte immer auf unserem Standpunkt zentriert sein soll. Dann werden wir, wo immer wir sind, die östlichen oder westlichen Ränder der Karte nicht sehen.Kartenfragmentspalten
Damit die Kartenvisualisierung relativ zur Kamera zentriert ist, müssen wir die Platzierung der Elemente abhängig von der Bewegung der Kamera ändern. Wenn es sich nach Westen bewegt, müssen wir das, was sich derzeit am Rand des östlichen Teils befindet, an den Rand des westlichen Teils verschieben. Gleiches gilt für die Gegenrichtung.Im Idealfall sollten wir die am weitesten entfernte Zellensäule sofort auf die andere Seite verschieben, sobald sich die Kamera zur benachbarten Zellensäule bewegt. Wir müssen jedoch nicht so genau sein. Stattdessen können wir ganze Kartenfragmente übertragen. Auf diese Weise können wir Teile der Karte verschieben, ohne die Netze ändern zu müssen.Da wir ganze Spalten von Fragmenten gleichzeitig verschieben, gruppieren wir sie, indem wir für jede Gruppe ein übergeordnetes Spaltenobjekt erstellen. Fügen Sie ein Array für diese Objekte hinzu HexGrid
, und wir werden es in initialisieren CreateChunks
. Wir werden sie nur als Container verwenden, daher müssen wir nur die Verknüpfung zu ihren Komponenten verfolgen Transform
. Wie bei Fragmenten befinden sich ihre Anfangspositionen am lokalen Ursprung der Gitterkoordinaten. 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); } … }
Jetzt sollte das Fragment ein untergeordnetes Element der entsprechenden Spalte werden, nicht des Rasters. 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); } } }
Fragmente in Spalten gruppiert.Da jetzt alle Fragmente zu Kindern der Spalten geworden sind, reicht CreateMap
es aus, alle Spalten direkt zu zerstören, nicht die Fragmente. Also werden wir Tochterfragmente los. public bool CreateMap (int x, int z, bool wrapping) { … if (columns != null) { for (int i = 0; i < columns.Length; i++) { Destroy(columns[i].gameObject); } } … }
Spalten teleportieren
Fügen Sie der HexGrid
neuen Methode CenterMap
Position X als Parameter hinzu. Konvertieren Sie die Position in den Spaltenindex und teilen Sie sie durch die Fragmentbreite in Einheitseinheiten. Dies ist der Index der Spalte, in der sich die Kamera gerade befindet, dh die mittlere Spalte der Karte. public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); }
Es reicht aus, die Visualisierung der Karte nur zu ändern, wenn sich der Index der zentralen Spalte ändert. Verfolgen wir es also vor Ort. Wir verwenden den Standardwert −1 beim Erstellen einer Karte, damit neue Karten immer zentriert werden. 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; }
Nachdem wir den Index der zentralen Spalte kennen, können wir den minimalen und maximalen Index bestimmen, indem wir einfach die Hälfte der Spalten subtrahieren und addieren. Da wir ganzzahlige Werte mit einer ungeraden Anzahl von Spalten verwenden, funktioniert dies perfekt. Bei einer geraden Zahl kann es keine perfekt zentrierte Spalte geben, sodass einer der Indizes einen Schritt weiter als nötig ist. Dies erzeugt einen Versatz von einer Spalte in Richtung des äußersten Randes der Karte, aber für uns ist dies kein Problem. currentCenterColumnIndex = centerColumnIndex; int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2;
Beachten Sie, dass diese Indizes möglicherweise negativ oder größer als der natürliche maximale Spaltenindex sind. Das Minimum ist nur dann Null, wenn sich die Kamera in der Nähe des natürlichen Mittelpunkts der Karte befindet. Unsere Aufgabe ist es, die Spalten so zu verschieben, dass sie diesen relativen Indizes entsprechen. Dies kann durch Ändern der lokalen X-Koordinate jeder Spalte in der Schleife erfolgen. 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; }
Für jede Spalte prüfen wir, ob der Index des Mindestindex kleiner ist. Wenn ja, dann ist es zu weit links von der Mitte. Er muss sich auf die andere Seite der Karte teleportieren. Dies kann erreicht werden, indem die X-Koordinate der Breite der Karte entspricht. Wenn der Spaltenindex größer als der maximale Index ist, befindet er sich zu weit rechts von der Mitte und sollte sich auf die andere Seite teleportieren. 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; }
Kamerabewegung
Ändern Sie dies HexMapCamera.AdjustPosition
so, dass er stattdessen ClampPosition
anruft , wenn er mit einer zusammenklappbaren Karte arbeitet WrapPosition
. Machen Sie die neue Methode zunächst einfach zu einem WrapPosition
Duplikat ClampPosition
, aber mit dem einzigen Unterschied: Am Ende wird sie aufgerufen 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; }
Damit die Karte sofort zentriert ist, rufen wir die OnEnable
Methode auf ValidatePosition
. void OnEnable () { instance = this; ValidatePosition(); }
Bewegen Sie sich nach links und rechts, wenn Sie auf der Kamera zentrieren.Obwohl wir die Bewegung der Kamera immer noch einschränken, versucht die Karte jetzt, relativ zur Kamera zu zentrieren, und teleportiert bei Bedarf Spalten mit Kartenfragmenten. Mit einer kleinen Karte und einer Remote-Kamera ist dies deutlich sichtbar, aber auf einer großen Karte befinden sich teleportierte Fragmente außerhalb des Sichtbereichs der Kamera. Offensichtlich sind nur die anfänglichen östlichen und westlichen Ränder der Karte erkennbar, da noch keine Triangulation zwischen ihnen besteht.Um die Kamera zu kollabieren, entfernen wir die Einschränkung ihrer X-Koordinate WrapPosition
. Stattdessen werden wir die X-Koordinate weiterhin um die Breite der Karte erhöhen, während sie unter Null liegt, und sie verringern, während sie größer als die Breite der Karte ist. Vector3 WrapPosition (Vector3 position) {
Die Roll-up-Kamera bewegt sich entlang der Karte.Zusammenklappbare Shader-Texturen
Mit Ausnahme des Triangulationsraums sollte eine Minimierung der Kamera im Spielemodus nicht wahrnehmbar sein. In diesem Fall tritt jedoch eine visuelle Veränderung in der Hälfte der Topographie und des Wassers auf. Dies geschieht, weil wir eine Position in der Welt verwenden, um diese Texturen abzutasten. Eine scharfe Teleportation des Fragments verändert die Position der Texturen.Wir können dieses Problem lösen, indem wir die Texturen in Kacheln anzeigen lassen, die ein Vielfaches der Fragmentgröße sind. Die Fragmentgröße wird aus den Konstanten in berechnet. HexMetrics
Erstellen Sie also die Shader-Include-Datei HexMetrics.cginc und fügen Sie die entsprechenden Definitionen ein. Die grundlegende Kachelskala wird aus der Fragmentgröße und dem Außenradius der Zelle berechnet. Wenn Sie andere Metriken verwenden, müssen Sie die Datei entsprechend ändern. #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))
Dies ergibt eine Kachelskala von 0,00866025404. Wenn wir ein ganzzahliges Vielfaches dieses Werts verwenden, wird die Texturierung durch die Teleportation von Fragmenten nicht beeinflusst. Außerdem werden die Texturen am östlichen und westlichen Rand der Karte nahtlos verbunden, nachdem wir ihre Verbindung korrekt trianguliert haben. Wir haben 0,02als UV-Skala im Terrain- Shader verwendet . Stattdessen können wir die doppelte Kachelskala verwenden, die 0,01732050808 beträgt. Die Skala wird etwas weniger erhalten als sie war, und die Skala der Textur nahm leicht zu, aber visuell ist sie unsichtbar. #include "../HexMetrics.cginc" #include "../HexCellData.cginc" … float4 GetTerrainColor (Input IN, int index) { float3 uvw = float3( IN.worldPos.xz * (2 * TILING_SCALE), IN.terrain[index] ); … }
Im Roads- Shader für UV-Rauschen haben wir eine Skala von 0,025 verwendet. Stattdessen können Sie die dreifache Kachelskala verwenden. Dies gibt uns 0.02598076212, was ziemlich nahe ist. #include "HexMetrics.cginc" #include "HexCellData.cginc" … void surf (Input IN, inout SurfaceOutputStandardSpecular o) { float4 noise = tex2D(_MainTex, IN.worldPos.xz * (3 * TILING_SCALE)); … }
Schließlich haben wir bei Water.cginc 0,015 für Schaum und 0,025 für Wellen verwendet. Hier können wir diese Werte wieder durch eine doppelte und dreifache Kachelskala ersetzen. #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)); … }
EinheitspaketDie Vereinigung von Ost und West
Zu diesem Zeitpunkt ist der einzige visuelle Beweis für die Minimierung der Karte eine kleine Lücke zwischen der östlichsten und der westlichsten Spalte. Diese Lücke tritt auf, weil wir die Verbindungen von Kanten und Winkeln zwischen Zellen auf gegenüberliegenden Seiten der Karte noch nicht trianguliert haben, ohne sie zu falten.Platz am Rand.Nachbarn falten
Um die Ost-West-Verbindung zu triangulieren, müssen wir die Zellen auf gegenüberliegenden Seiten zu Nachbarn machen. Bisher tun wir dies nicht, da die HexGrid.CreateCell
EW-Verbindung mit der vorherigen Zelle nur hergestellt wird, wenn ihr Index in X größer als Null ist. Um diese Verbindung zu trennen, müssen wir die letzte Zelle der Zeile mit der ersten Zelle in derselben Zeile verbinden, wenn die Karte gefaltet ist. 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]); } } … }
Nachdem wir die Verbindung der Nachbarn E - W hergestellt haben, erhalten wir eine partielle Triangulation der Lücke. Die Verbindung der Kanten ist nicht ideal, da die Verzerrung falsch ausgeblendet ist. Wir werden uns später darum kümmern.Verbindungen E - W.Wir müssen auch die NE-SW-Verbindungen reduzieren. Dies kann erreicht werden, indem die erste Zelle jeder geraden Zeile mit den letzten Zellen der vorherigen Zeile verbunden wird. Es wird nur die vorherige Zelle sein. 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 - Verbindungen.Schließlich werden SE-NW-Verbindungen am Ende jeder ungeraden Zeile unterhalb der ersten hergestellt. Diese Zellen müssen mit der ersten Zelle der vorherigen Zeile verbunden sein. 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] ); } } }
Verbindungen SE - NW.Geräuschfaltung
Um die Lücke perfekt zu verbergen, müssen wir sicherstellen, dass der östliche und der westliche Rand der Karte mit dem Rauschen übereinstimmen, das perfekt zum Verzerren der Positionen der Scheitelpunkte verwendet wird. Wir können den gleichen Trick verwenden, der für Shader verwendet wurde, aber für die Verzerrung wurde eine Rauschskala von 0,003 verwendet. Um das Kacheln sicherzustellen, müssen Sie den Maßstab erheblich erhöhen, was zu einer chaotischeren Verzerrung der Scheitelpunkte führt.Eine alternative Lösung besteht nicht darin, das Rauschen zu reduzieren, sondern das Rauschen an den Rändern der Karte gleichmäßig zu dämpfen. Wenn Sie eine gleichmäßige Dämpfung entlang der Breite einer Zelle durchführen, erzeugt die Verzerrung einen glatten Übergang ohne Lücken. Das Rauschen in diesem Bereich wird leicht geglättet, und aus großer Entfernung erscheint die Änderung scharf, aber dies ist nicht so offensichtlich, wenn eine leichte Verzerrung der Scheitelpunkte verwendet wird.Was ist mit Temperaturschwankungen?. , . , . , .
Wenn wir die Karte nicht kollabieren, können wir mit einer HexMetrics.SampleNoise
einzigen Probe auskommen . Beim Zusammenklappen muss jedoch eine Dämpfung hinzugefügt werden. Speichern Sie das Beispiel daher vor der Rückgabe in einer Variablen. public static Vector4 SampleNoise (Vector3 position) { Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); return sample; }
Beim Minimieren müssen wir mit der zweiten Probe mischen. Wir werden den Übergang im östlichen Teil der Karte durchführen, daher muss die zweite Stichprobe nach Westen verschoben werden. 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 ); }
Die Dämpfung erfolgt durch einfache lineare Interpolation vom westlichen zum östlichen Teil über die Breite einer Zelle. 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) ); }
Rauschmischung, eine unvollständige Lösung.Infolgedessen erhalten wir keine genaue Übereinstimmung, da einige der Zellen auf der Ostseite negative X-Koordinaten haben. Um sich diesem Bereich nicht zu nähern, verschieben wir den Übergangsbereich um die Hälfte der Zellbreite nach Westen. 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 ); }
Richtige Dämpfung.Zellbearbeitung
Nachdem die Triangulation korrekt erscheint, stellen wir sicher, dass wir alles auf der Karte und an der Naht der Faltung bearbeiten können. Wie sich herausstellt, sind in teleportierten Fragmenten die Koordinaten falsch und große Pinsel werden durch eine Naht abgeschnitten.Der Pinsel ist zugeschnitten.Um dies zu beheben, müssen wir das HexCoordinates
Falten melden . Wir können dies tun, indem wir die X-Koordinate in der Konstruktormethode abgleichen. Wir wissen, dass die Axialkoordinate X aus der X-Koordinate des Versatzes durch Subtrahieren der Hälfte der Z-Koordinate erhalten wird. Mit diesen Informationen können Sie die inverse Transformation durchführen und prüfen, ob die Nullkoordinate kleiner als Null ist. Wenn ja, dann haben wir die Koordinate jenseits der Ostseite der entfalteten Karte. Da wir in jede Richtung nicht mehr als die Hälfte der Karte teleportieren, reicht es aus, die Faltgröße einmal zu X hinzuzufügen. Und wenn die Versatzkoordinate größer als die Faltgröße ist, müssen wir eine Subtraktion durchführen. 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; }
Beim Bearbeiten des unteren oder oberen Randes der Kartetreten manchmal Fehler auf. Dies geschieht, wenn der Cursor aufgrund von Verzerrungen der Scheitelpunkte in der Zellenreihe außerhalb der Karte angezeigt wird. Dies ist ein Fehler, der auftritt, weil die Koordinaten nicht HexGrid.GetCell
mit dem Vektorparameter übereinstimmen . Dies kann behoben werden, indem eine Methode GetCell
mit Koordinaten als Parametern angewendet wird, die die erforderlichen Überprüfungen durchführt. public HexCell GetCell (Vector3 position) { position = transform.InverseTransformPoint(position); HexCoordinates coordinates = HexCoordinates.FromPosition(position);
Küstenfaltung
Die Triangulation funktioniert gut für das Gelände, aber entlang der Ost-West-Naht gibt es keine Ränder der Wasserküste. Tatsächlich brechen sie einfach nicht zusammen. Sie werden umgedreht und auf die andere Seite der Karte gestreckt.Fehlender Rand des Wassers.Dies geschieht, weil wir beim Triangulieren des Küstenwassers die Position eines Nachbarn verwenden. Um dies zu beheben, müssen wir feststellen, womit wir es zu tun haben, auf der anderen Seite der Karte. Um die Aufgabe zu vereinfachen, fügen wir der HexCell
Eigenschaft für den Index eine Zellenspalte hinzu. public int ColumnIndex { get; set; }
Weisen Sie diesen Index zu HexGrid.CreateCell
. Sie ist einfach gleich der Versatzkoordinate X geteilt durch die Fragmentgröße. void CreateCell (int x, int z, int i) { … cell.Index = i; cell.ColumnIndex = x / HexMetrics.chunkSizeX; … }
Jetzt können wir HexGridChunk.TriangulateWaterShore
bestimmen, was minimiert wird, indem wir den Spaltenindex der aktuellen Zelle und ihres Nachbarn vergleichen. Wenn der Index der Spalte des Nachbarn weniger als einen Schritt kleiner ist, befinden wir uns auf der Westseite und der Nachbar auf der Ostseite. Deshalb müssen wir unseren Nachbarn nach Westen wenden. Das gleiche und in die entgegengesetzte Richtung. 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; }
Rippen der Küste, aber keine Ecken.Also haben wir uns um die Rippen der Küste gekümmert, uns aber bisher nicht um Ecken gekümmert. Wir müssen dasselbe mit dem nächsten Nachbarn tun. 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())); … }
Richtig gekürzte Küste.Kartengenerierung
Die Möglichkeit, die Ost- und Westseite zu verbinden, wirkt sich auf die Erstellung von Karten aus. Bei der Minimierung der Karte sollte auch der Generierungsalgorithmus minimiert werden. Dies führt zur Erstellung einer weiteren Karte. Wenn Sie jedoch einen Kartenrand X ungleich Null verwenden , ist das Falten nicht offensichtlich.Große Karte 1208905299 mit Standardeinstellungen. Mit und ohne Falten.Wenn minimierter nicht sinnvoll zu verwenden , die Karte Border die X . Aber wir können es nicht einfach loswerden, weil gleichzeitig die Regionen verschmelzen werden. Beim Minimieren können wir einfach einen RegionBorder verwenden .Wir ändern uns HexMapGenerator.CreateRegions
und ersetzen in jedem Fall mapBorderX
durch borderX
. Diese neue Variable ist gleich oder regionBorder
oder mapBorderX
, abhängig vom Wert der Option zum Reduzieren. Unten habe ich die Änderungen nur für den ersten Fall gezeigt. 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; … }
Gleichzeitig bleiben die Regionen getrennt, dies ist jedoch nur erforderlich, wenn sich auf der Ost- und Westseite der Karte unterschiedliche Regionen befinden. Es gibt zwei Fälle, in denen dies nicht beachtet wird. Das erste ist, wenn wir nur eine Region haben. Die zweite ist, wenn zwei Regionen die Karte horizontal teilen. In diesen Fällen können wir einen borderX
Wert von Null zuweisen , der es den Landmassen ermöglicht, die Ost-West-Naht zu überqueren. 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; … }
Eine Region bricht zusammen.Auf den ersten Blick scheint alles richtig zu funktionieren, aber es gibt tatsächlich eine Lücke entlang der Naht. Dies wird deutlicher, wenn Sie den Erosionsprozentsatz auf Null setzen.Wenn die Erosion deaktiviert ist, macht sich eine Naht am Relief bemerkbar.Die Lücke entsteht, weil die Naht das Wachstum von Relieffragmenten verhindert. Um zu bestimmen, was zuerst hinzugefügt wird, wird der Abstand von der Zelle zur Mitte des Fragments verwendet, und die Zellen auf der anderen Seite der Karte können sehr weit entfernt sein, sodass sie sich fast nie einschalten. Das ist natürlich falsch. Wir müssen sicherstellen, dass wir HexCoordinates.DistanceTo
über die minimierte Karte Bescheid wissen.Wir berechnen den Abstand zwischen HexCoordinates
, summieren die absoluten Abstände entlang jeder der drei Achsen und halbieren das Ergebnis. Der Abstand entlang Z ist immer wahr, aber das Falten entlang kann die X- und Y-Abstände beeinflussen. Beginnen wir also mit einer separaten Berechnung von X + Y. public int DistanceTo (HexCoordinates other) {
Es ist keine leichte Aufgabe, festzustellen, ob durch das Falten ein kürzerer Abstand für beliebige Zellen entsteht. Berechnen wir also einfach X + Y für Fälle, in denen wir eine andere Koordinate nach Westen falten. Wenn der Wert kleiner als das ursprüngliche X + Y ist, verwenden Sie ihn. 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; } }
Wenn dies nicht zu einer kürzeren Entfernung führt, ist es möglich, in die andere Richtung kürzer zu drehen, also werden wir es überprüfen. 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; } } }
Jetzt erhalten wir immer die kürzeste Entfernung auf der zusammenklappbaren Karte. Geländefragmente werden nicht mehr durch eine Naht blockiert, wodurch sich Landmassen zusammenrollen können.Richtig faltbares Relief ohne Erosion und Erosion.EinheitspaketDie Welt bereisen
Nachdem wir uns mit der Kartenerstellung und Triangulation befasst haben, fahren wir nun mit der Überprüfung der Trupps, der Erkundung und der Sichtbarkeit fort.Naht testen
Das erste Hindernis, auf das wir stoßen, wenn wir einen Trupp um die Welt bewegen, ist der Rand der Karte, der nicht erkundet werden kann.Die Naht der Karte kann nicht untersucht werden.Die Zellen am Rand der Karte werden nicht erforscht, um die abrupte Fertigstellung der Karte zu verbergen. Wenn die Karte jedoch minimiert ist, sollten nur die Nord- und Südzellen markiert werden, nicht jedoch die Ost- und Westzellen. Ändern Sie dies HexGrid.CreateCell
, um dies zu berücksichtigen. if (wrapping) { cell.Explorable = z > 0 && z < cellCountZ - 1; } else { cell.Explorable = x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1; }
Sichtbarkeit von Reliefmerkmalen
Überprüfen wir nun, ob die Sichtbarkeit entlang der Naht funktioniert. Es funktioniert für Gelände, aber nicht für Geländeobjekte. Es sieht so aus, als würden kollabierende Objekte die Sichtbarkeit der letzten Zelle erhalten, die nicht reduziert wurde.Falsche Sichtbarkeit von Objekten.Dies geschieht, weil der HexCellShaderData
Klemmmodus für den verwendeten Texturfaltmodus eingestellt ist . Um das Problem zu lösen, ändern Sie einfach den Klemmmodus, um ihn zu wiederholen. Wir müssen dies jedoch nur für die Koordinaten von U tun, daher werden Initialize
wir wrapModeU
es wrapModeV
separat einstellen . 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;
Trupps und Spalten
Ein weiteres Problem ist, dass die Einheiten noch nicht zusammenbrechen. Nach dem Verschieben der Säule, in der sie sich befinden, bleiben die Einheiten an derselben Stelle.Das Gerät wird nicht übertragen und befindet sich auf der falschen Seite.Dieses Problem kann gelöst werden, indem Squads untergeordnete Elemente von Spalten erstellt werden, wie wir es bei Fragmenten getan haben. Erstens werden wir sie nicht länger zu den unmittelbaren Kindern des Gitters machen HexGrid.AddUnit
. public void AddUnit (HexUnit unit, HexCell location, float orientation) { units.Add(unit); unit.Grid = this;
Da sich die Einheiten bewegen, werden sie möglicherweise in einer anderen Spalte angezeigt. Das heißt, sie müssen ihre übergeordneten Einheiten ändern. Um dies zu ermöglichen, fügen wir der HexGrid
allgemeinen Methode hinzu MakeChildOfColumn
und übergeben als Parameter die Komponente des Transform
untergeordneten Elements und den Spaltenindex. public void MakeChildOfColumn (Transform child, int columnIndex) { child.SetParent(columns[columnIndex], false); }
Wir werden diese Methode aufrufen, wenn die Eigenschaft festgelegt ist HexUnit.Location
. public HexCell Location { … set { … Grid.MakeChildOfColumn(transform, value.ColumnIndex); } }
Dies löst das Problem der Erstellung von Einheiten. Wir müssen sie aber auch dazu bringen, sich beim Verschieben in die gewünschte Spalte zu bewegen. Dazu müssen Sie HexUnit.TravelPath
die aktuelle Spalte im Index verfolgen . Zu Beginn dieser Methode ist dies der Index der Zellenspalte am Anfang des Pfads oder der aktuelle, wenn die Verschiebung durch Neukompilierung unterbrochen wurde. IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; yield return LookAt(pathToTravel[1].Position);
Während jeder Iteration der Verschiebung prüfen wir, ob der Index der nächsten Spalte unterschiedlich ist, und wenn ja, ändern wir das übergeordnete Element der Reihenfolge. 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; } … }
Dadurch können sich Einheiten ähnlich wie Fragmente bewegen. Wenn Sie sich jedoch durch die Naht der Karte bewegen, fallen die Einheiten noch nicht zusammen. Stattdessen bewegen sie sich plötzlich in die falsche Richtung. Dies geschieht unabhängig von der Position der Naht, jedoch am deutlichsten, wenn sie über die gesamte Karte springen.Pferderennen über die Karte.Hier können wir den gleichen Ansatz verwenden, der für die Küste verwendet wurde, nur dass wir diesmal die Kurve drehen, entlang der sich die Ablösung bewegt. Wenn die nächste Spalte nach Osten gedreht wird, teleportieren wir die Kurve auch nach Osten, ähnlich für die andere Richtung. Sie müssen die Kontrollpunkte der Kurve a
und ändern b
, was sich auch auf den Kontrollpunkt auswirkt c
. for (int i = 1; i < pathToTravel.Count; i++) { currentTravelLocation = pathToTravel[i]; a = c; b = pathToTravel[i - 1].Position;
Bewegung mit Falten.Das Letzte, was Sie tun müssen, ist, die erste Runde des Trupps zu ändern, wenn es auf die erste Zelle schaut, in die es sich bewegen wird. Befindet sich diese Zelle auf der anderen Seite der Ost-West-Naht, schaut das Gerät in die falsche Richtung.Beim Minimieren einer Karte gibt es zwei Möglichkeiten, einen Punkt zu betrachten, der sich nicht genau im Norden oder Süden befindet. Sie können entweder nach Osten oder nach Westen schauen. Es ist logisch, in die Richtung zu schauen, die dem nächstgelegenen Abstand zum Punkt entspricht, da dies auch die Bewegungsrichtung ist. Verwenden wir sie also in LookAt
.Beim Minimieren überprüfen wir den relativen Abstand entlang der X-Achse. Wenn er kleiner als die negative Hälfte der Kartenbreite ist, sollten wir nach Westen schauen. Dies kann durch Drehen des Punkts nach Westen erfolgen. Andernfalls müssen wir nach Osten kollabieren, wenn die Entfernung mehr als die Hälfte der Breite der Karte beträgt. 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; } } … }
Wir haben also eine voll funktionsfähige minimierte Karte. Damit ist die Reihe der Tutorials auf Sechseckkarten abgeschlossen. Wie in den vorherigen Abschnitten erwähnt, können andere Themen berücksichtigt werden, sie sind jedoch nicht spezifisch für Sechseckkarten. Vielleicht werde ich sie in zukünftigen Tutorials berücksichtigen.Ich habe das letzte Paket heruntergeladen und bekomme im Play-Modus Turn-Fehler, Rotation . . . 5.
Ich habe das letzte Paket heruntergeladen und die Grafiken sind nicht so schön wie in den Screenshots. - .
Ich habe das letzte Paket heruntergeladen und es generiert ständig die gleiche Karteseed (1208905299), . , Use Fixed Seed .
Einheitspaket