Unity Hexagon Maps: Unebenheiten, Flüsse und Straßen

Ursprünglicher Autor: Jasper Flick
  • Übersetzung
image


Teile 1-3: Maschen, Farben und Zellhöhen;

Teile 4-7: Unebenheiten, Flüsse und Straßen;

Teile 8-11: Wasser, Landformen und Festungsmauern;

Teile 12-15: Speichern und Laden, Texturen, Entfernungen;

Teile 16-19: Wegfindung, Spielertrupps, Animationen

Teile 20-23: Nebel des Krieges, Kartenforschung, Verfahrensgenerierung

Teile 24-27: Wasserkreislauf, Erosion, Biome, zylindrische Karte

Teil 4: Rauheiten


Inhaltsverzeichnis


  • Probieren Sie die Rauschtextur.
  • Verschieben Sie die Scheitelpunkte.
  • Wir erhalten die Ebenheit der Zellen.
  • Unterteilen Sie die Ränder der Zellen.

Während unser Gitter ein strenges Muster aus Waben war. In diesem Teil werden wir Unebenheiten hinzufügen, damit die Karte natürlicher aussieht.


Keine gleichmäßigen Sechsecke mehr.

Der Lärm


Um Unebenheiten hinzuzufügen, benötigen wir Randomisierung, aber keine echte Zufälligkeit. Wir möchten, dass beim Ändern der Karte alles konsistent ist. Andernfalls springen die Objekte, wenn Sie Änderungen vornehmen. Das heißt, wir brauchen irgendeine Form von reproduzierbarem Pseudozufallsrauschen.

Ein guter Kandidat ist Perlins Lärm. Es ist überall reproduzierbar. Wenn mehrere Frequenzen kombiniert werden, entsteht auch Rauschen, das bei großen Entfernungen stark variieren kann, bei kleinen Entfernungen jedoch nahezu gleich bleibt. Dadurch können Sie eine relativ gleichmäßige Verzerrung erzielen. Punkte, die nebeneinander liegen, bleiben normalerweise in der Nähe und werden nicht in entgegengesetzte Richtungen gestreut.

Wir können Perlin-Rauschen programmgesteuert erzeugen. Im Noise TutorialIch erkläre, wie es geht. Wir können aber auch eine vorgenerierte Rauschtextur abtasten. Der Vorteil der Verwendung von Textur besteht darin, dass sie einfacher und schneller ist als die Berechnung von Perlins Mehrfrequenzrauschen. Ihr Nachteil ist, dass die Textur mehr Speicherplatz beansprucht und nur einen kleinen Rauschbereich abdeckt. Daher sollte es nahtlos verbunden und groß genug sein, damit die Wiederholung nicht auffällt.

Noise Textur


Wir werden die Textur verwenden, daher ist das Noise- Tutorial optional. Wir brauchen also eine Textur. Da ist sie:


Schließen Sie nahtlos Perlin-Geräuschbeschaffenheit an.

Die oben gezeigte Textur enthält Perlins nahtlos gekoppeltes Mehrfrequenzrauschen. Dies ist ein Graustufenbild. Sein Durchschnittswert ist 0,5 und die Extremwerte tendieren zu 0 und 1.

Aber warte eine Minute, es gibt nur einen Wert für jeden Punkt. Wenn wir eine dreidimensionale Verzerrung benötigen, benötigen wir mindestens drei Pseudozufallsstichproben! Deshalb brauchen wir zwei weitere Texturen mit unterschiedlichem Rauschen.

Wir können sie erstellen oder unterschiedliche Rauschwerte in jedem der Farbkanäle speichern. Auf diese Weise können wir bis zu vier Rauschmuster in einer Textur speichern. Hier ist diese Textur.


Vier in eins.

Wie erstelle ich eine solche Textur?
Ich habe NumberFlow verwendet . Dies ist der prozedurale Textureditor, den ich für Unity erstellt habe.

Laden Sie diese Textur herunter und importieren Sie sie in Ihr Unity-Projekt. Da wir die Textur durch Code abtasten werden, sollte sie lesbar sein. Schalten Sie den Texturtyp auf Erweitert und aktivieren Sie Lese- / Schreibzugriff . Dies speichert die Texturdaten im Speicher und kann über C # -Code aufgerufen werden. Stellen Sie Format auf Automatic Truecolor ein , sonst funktioniert nichts. Wir möchten nicht, dass die Texturkomprimierung unser Rauschmuster zerstört.

Sie können Generate Mip Maps deaktivieren , da wir sie nicht benötigen. Aktivieren Sie auch Bypass sRGB Sampling. Wir werden das nicht brauchen, aber es wird so sein. Dieser Parameter gibt an, dass die Textur keine Farbdaten im Gammaraum enthält.



Importierte Rauschstruktur.

Wann ist sRGB-Sampling wichtig?
Wenn wir eine Textur in einem Shader verwenden wollen, ist das wichtig. Wenn Sie den linearen Rendering-Modus verwenden, konvertiert das Abtasten der Textur die Farbdaten aus dem Farbraum automatisch in einen linearen Farbraum. Im Falle unserer Rauschtextur führt dies zu falschen Ergebnissen, so dass wir dies nicht benötigen.

Warum sehen meine Einstellungen für den Texturimport anders aus?
Sie wurden geändert, nachdem dieses Tutorial geschrieben wurde. Sie müssen die standardmäßigen 2D-Textureinstellungen verwenden, sRGB (Farbtextur) sollte deaktiviert sein und die Komprimierung sollte auf Keine eingestellt sein .

Noise Sampling


Fügen wir die Noise Sampling-Funktionalität hinzu HexMetrics, damit Sie sie überall verwenden können. Dies bedeutet, dass es HexMetricseinen Verweis auf die Rauschtextur enthalten sollte.

	public static Texture2D noiseSource;

Da dies keine Komponente ist, können wir ihr über den Editor keine Textur zuweisen. Deshalb setzen wir als Vermittler ein HexGrid. Da es HexGridzuerst wirkt, ist es ganz normal, wenn wir die Textur zu Beginn seiner Methode übergeben Awake.

	public Texture2D noiseSource;
	void Awake () {
		HexMetrics.noiseSource = noiseSource;
		…
	}

Dieser Ansatz übersteht jedoch die Neukompilierung im Wiedergabemodus nicht. Statische Variablen werden von der Unity-Engine nicht serialisiert. Um dieses Problem zu lösen, weisen Sie die Textur auch in der Ereignismethode neu zu OnEnable. Diese Methode wird nach der Neukompilierung aufgerufen.

	void OnEnable () {
		HexMetrics.noiseSource = noiseSource;
	}


Weisen Sie eine Rauschtextur zu.

Nachdem wir HexMetricsnun Zugriff auf die Textur haben, fügen wir ihr eine praktische Rauschabtastmethode hinzu. Diese Methode gewinnt eine Position in der Welt und erzeugt einen 4D-Vektor mit vier Rauschmustern.

	public static Vector4 SampleNoise (Vector3 position) {
	}

Samples wurden durch Abtasten der Textur mittels bilinearer Filterung erstellt, wobei die Koordinaten der Welt X und Z als UV-Koordinaten verwendet wurden. Da unsere Rauschquelle zweidimensional ist, ignorieren wir die dritte Koordinate der Welt. Wenn die Rauschquelle dreidimensional wäre, würden wir auch die Y-Koordinate verwenden,

wodurch wir eine Farbe erhalten, die in einen 4D-Vektor konvertiert werden kann. Eine solche Reduzierung kann indirekt sein, das heißt, wir können die Farbe direkt und nicht explizit zurückgeben (Vector4).

	public static Vector4 SampleNoise (Vector3 position) {
		return noiseSource.GetPixelBilinear(position.x, position.z);
	}

Wie funktioniert die bilineare Filterung?
Eine Erläuterung der UV-Koordinaten und der Texturfilterung finden Sie im Tutorial Rendering 2, Shader-Grundlagen .

Einheitspaket

Vertex-Bewegung


Wir verzerren unser glattes Wabengitter, indem wir jeden Scheitelpunkt einzeln verschieben. Fügen wir dazu die HexMeshMethode hinzu Perturb. Es nimmt einen unbeweglichen Punkt und gibt den beweglichen zurück. Zu diesem Zweck verwendet er beim Abtasten von Geräuschen einen unbeweglichen Punkt.

	Vector3 Perturb (Vector3 position) {
		Vector4 sample = HexMetrics.SampleNoise(position);
	}

Addieren Sie einfach die Rauschproben X, Y und Z direkt zu den entsprechenden Punktkoordinaten und verwenden Sie diese als Ergebnis.

	Vector3 Perturb (Vector3 position) {
		Vector4 sample = HexMetrics.SampleNoise(position);
		position.x += sample.x;
		position.y += sample.y;
		position.z += sample.z;
		return position;
	}

Wie können wir schnell ändern HexMesh, um alle Spitzen zu bewegen? Durch Ändern jedes Scheitelpunkts beim Hinzufügen von Scheitelpunkten zur Liste in den Methoden AddTriangleund AddQuad. Lass es uns tun.

	void AddTriangle (Vector3 v1, Vector3 v2, Vector3 v3) {
		int vertexIndex = vertices.Count;
		vertices.Add(Perturb(v1));
		vertices.Add(Perturb(v2));
		vertices.Add(Perturb(v3));
		…
	}
	void AddQuad (Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4) {
		int vertexIndex = vertices.Count;
		vertices.Add(Perturb(v1));
		vertices.Add(Perturb(v2));
		vertices.Add(Perturb(v3));
		vertices.Add(Perturb(v4));
		…
	}

Bleiben Vierecke flach, nachdem ihre Scheitelpunkte verschoben wurden?
Höchstwahrscheinlich nicht. Sie bestehen aus zwei Dreiecken, die nicht mehr in derselben Ebene liegen. Da diese Dreiecke jedoch zwei gemeinsame Eckpunkte haben, werden die Normalen dieser Eckpunkte geglättet. Dies bedeutet, dass wir keine scharfen Übergänge zwischen zwei Dreiecken haben. Wenn die Verzerrung nicht zu groß ist, werden wir die Vierecke immer noch als flach wahrnehmen.


Die Eckpunkte werden entweder verschoben oder nicht.

Obwohl die Änderungen nicht sehr auffällig sind, sind nur die Zellenbeschriftungen verschwunden. Dies geschah, weil wir den Punkten Rauschmuster hinzugefügt haben, die immer positiv sind. Infolgedessen stiegen alle Dreiecke über ihre Markierungen und schlossen sie. Wir müssen die Änderungen so zentrieren, dass sie in beide Richtungen auftreten. Ändern Sie das Intervall des Noise-Samples von 0–1 auf −1–1.

	Vector3 Perturb (Vector3 position) {
		Vector4 sample = HexMetrics.SampleNoise(position);
		position.x += sample.x * 2f - 1f;
		position.y += sample.y * 2f - 1f;
		position.z += sample.z * 2f - 1f;
		return position;
	}


Zentrierte Verschiebung.

Die Größe (Kraft) der Verschiebung


Jetzt ist es offensichtlich, dass wir das Gitter verzerrt haben, aber der Effekt ist kaum spürbar. Die Änderung in jeder Dimension beträgt nicht mehr als 1 Einheit. Das heißt, die theoretische maximale Verschiebung beträgt √3 ≈ 1,73 Einheiten, was, wenn überhaupt, äußerst selten vorkommt. Da der äußere Radius der Zellen 10 Einheiten beträgt, sind die Verschiebungen relativ klein.

Die Lösung besteht darin, den HexMetricsParameter zu stärken , damit Sie die Bewegungen skalieren können. Versuchen wir, Kraft 5 anzuwenden. In diesem Fall beträgt die theoretische maximale Verschiebung √ 75 ≈ 8,66 Einheiten, was viel auffälliger ist.

	public const float cellPerturbStrength = 5f;

Wenden Sie die Kraft an, indem Sie sie mit den Proben in multiplizieren HexMesh.Perturb.

	Vector3 Perturb (Vector3 position) {
		Vector4 sample = HexMetrics.SampleNoise(position);
		position.x += (sample.x * 2f - 1f) * HexMetrics.cellPerturbStrength;
		position.y += (sample.y * 2f - 1f) * HexMetrics.cellPerturbStrength;
		position.z += (sample.z * 2f - 1f) * HexMetrics.cellPerturbStrength;
		return position;
	}



Erhöhte Stärke.

Geräuschskala


Obwohl das Raster vor der Änderung gut aussieht, kann nach dem Erscheinen der Leisten alles schief gehen. Ihre Spitzen können in unvorhersehbar unterschiedliche Richtungen verzerrt werden, wodurch Chaos entsteht. Bei Verwendung von Perlin-Rauschen sollte dies nicht passieren.

Das Problem entsteht, weil wir die Koordinaten der Welt direkt verwenden, um das Rauschen abzutasten. Aus diesem Grund ist die Textur in jeder Einheit verborgen und die Zellen sind viel größer als dieser Wert. Tatsächlich wird die Textur an beliebigen Punkten abgetastet, wodurch ihre vorhandene Integrität zerstört wird.


Zeilen mit 10 x 10 Rasterfeldern überlappen die Zellen.

Wir müssen die Rauschabtastung so skalieren, dass die Textur einen viel größeren Bereich abdeckt. Fügen Sie diese Skala hinzu HexMetricsund weisen Sie ihr einen Wert von 0,003 zu. Skalieren Sie anschließend die Koordinaten der Samples um diesen Faktor.

	public const float noiseScale = 0.003f;
	public static Vector4 SampleNoise (Vector3 position) {
		return noiseSource.GetPixelBilinear(
			position.x * noiseScale,
			position.z * noiseScale
		);
	}

Plötzlich stellt sich heraus, dass unsere Textur 333 ⅓ Quadratmeter bedeckt und ihre lokale Integrität offensichtlich wird.



Skaliertes Rauschen.

Außerdem vergrößert eine neue Skala den Abstand zwischen den Geräuschfugen. Da die Zellen einen Innendurchmesser von 10 √ 3 Einheiten haben, werden sie in der X-Dimension nie genau gekachelt, aber aufgrund der lokalen Integrität des Rauschens können wir in größerem Maßstab immer noch sich wiederholende Muster erkennen, etwa alle 20 Zellen. Auch wenn die Details nicht übereinstimmen. Sie sind jedoch nur auf der Karte ohne andere charakteristische Merkmale erkennbar.

Einheitspaket

Zellzentren ausrichten


Das Verschieben aller Scheitelpunkte verleiht der Karte ein natürlicheres Aussehen, es gibt jedoch mehrere Probleme. Da die Zellen jetzt gezackt sind, schneiden sich ihre Beschriftungen mit dem Netz. Und in den Fugen der Felsvorsprünge entstehen Risse. Wir werden die Risse für später belassen, aber jetzt konzentrieren wir uns auf die Oberflächen der Zellen.


Die Karte wurde weniger streng, aber es traten mehr Probleme auf.

Der einfachste Weg, das Kreuzungsproblem zu lösen, besteht darin, die Zentren der Zellen flach zu machen. Ändern wir einfach nicht die Y-Koordinate HexMesh.Perturb.

	Vector3 Perturb (Vector3 position) {
		Vector4 sample = HexMetrics.SampleNoise(position);
		position.x += (sample.x * 2f - 1f) * HexMetrics.cellPerturbStrength;
//		position.y += (sample.y * 2f - 1f) * HexMetrics.cellPerturbStrength;
		position.z += (sample.z * 2f - 1f) * HexMetrics.cellPerturbStrength;
		return position;
	}


Ausgerichtete Zellen.

Mit dieser Änderung bleiben alle vertikalen Positionen unverändert, sowohl in der Mitte der Zellen als auch an den Stufen der Leisten. Es ist zu beachten, dass dies die maximale Verschiebung nur in der XZ-Ebene auf √50 ≈ 7,07 verringert.

Dies ist eine gute Änderung, da es die Identifizierung einzelner Zellen vereinfacht und verhindert, dass die Leisten zu chaotisch werden. Aber es wäre trotzdem schön, eine kleine vertikale Bewegung hinzuzufügen.

Zellenhöhe verschieben


Anstatt auf jeden Scheitelpunkt eine vertikale Bewegung anzuwenden, können wir sie auf eine Zelle anwenden. In diesem Fall bleibt jede Zelle flach, die Variabilität zwischen den Zellen bleibt jedoch bestehen. Es wäre logisch, auch eine andere Skala zu verwenden, um die Höhe zu verschieben, also fügen Sie sie hinzu HexMetrics. Eine Kraft von 1,5 Einheiten erzeugt eine leichte Abweichung, die ungefähr der Höhe einer Stufe der Leiste entspricht.

	public const float elevationPerturbStrength = 1.5f;

Ändern Sie die Eigenschaft HexCell.Elevationso, dass diese Bewegung auf die vertikale Position der Zelle angewendet wird.

	public int Elevation {
		get {
			return elevation;
		}
		set {
			elevation = value;
			Vector3 position = transform.localPosition;
			position.y = value * HexMetrics.elevationStep;
			position.y +=
				(HexMetrics.SampleNoise(position).y * 2f - 1f) *
				HexMetrics.elevationPerturbStrength;
			transform.localPosition = position;
			Vector3 uiPosition = uiRect.localPosition;
			uiPosition.z = -position.y;
			uiRect.localPosition = uiPosition;
		}
	}

Damit die Bewegung sofort angewendet wird, müssen wir die Höhe jeder Zelle explizit einstellen HexGrid.CreateCell. Andernfalls ist das Gitter zunächst flach. Machen wir es am Ende, nachdem wir die Benutzeroberfläche erstellt haben.

	void CreateCell (int x, int z, int i) {
		…
		cell.Elevation = 0;
	}



Versetzte Höhen mit Rissen.

Mit den gleichen Höhen


Es sind viele Risse im Netz aufgetreten, da beim Triangulieren des Netzes nicht die gleichen Zellhöhen verwendet werden. Fügen Sie der HexCellEigenschaft eine Position hinzu, damit Sie sie überall verwenden können.

	public Vector3 Position {
		get {
			return transform.localPosition;
		}
	}

Jetzt können wir diese Eigenschaft verwenden HexMesh.Triangulate, um den Mittelpunkt der Zelle zu bestimmen.

	void Triangulate (HexDirection direction, HexCell cell) {
		Vector3 center = cell.Position;
		…
	}

Und wir können damit TriangulateConnectiondie vertikalen Positionen benachbarter Zellen bestimmen.

	void TriangulateConnection (
		HexDirection direction, HexCell cell, Vector3 v1, Vector3 v2
	) {
		…
		Vector3 bridge = HexMetrics.GetBridge(direction);
		Vector3 v3 = v1 + bridge;
		Vector3 v4 = v2 + bridge;
		v3.y = v4.y = neighbor.Position.y;
		…
		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (direction <= HexDirection.E && nextNeighbor != null) {
			Vector3 v5 = v2 + HexMetrics.GetBridge(direction.Next());
			v5.y = nextNeighbor.Position.y;
			…
		}
	}


Konsequente Nutzung der Zellenhöhe.

Einheitspaket

Cell Edge Unit


Obwohl die Zellen eine schöne Variation aufweisen, sehen sie immer noch wie offensichtliche Sechsecke aus. Dies ist an sich kein Problem, aber wir können ihr Aussehen verbessern.


Gut sichtbare sechseckige Zellen.

Wenn wir mehr Eckpunkte hätten, wäre die lokale Variabilität größer. Brechen wir also jede Kante der Zelle in zwei Teile, indem wir die obere Kante in der Mitte zwischen den beiden Ecken einfügen. Dies bedeutet, dass HexMesh.Triangulatenicht ein, sondern zwei Dreiecke hinzugefügt werden müssen.

	void Triangulate (HexDirection direction, HexCell cell) {
		Vector3 center = cell.Position;
		Vector3 v1 = center + HexMetrics.GetFirstSolidCorner(direction);
		Vector3 v2 = center + HexMetrics.GetSecondSolidCorner(direction);
		Vector3 e1 = Vector3.Lerp(v1, v2, 0.5f);
		AddTriangle(center, v1, e1);
		AddTriangleColor(cell.color);
		AddTriangle(center, e1, v2);
		AddTriangleColor(cell.color);
		if (direction <= HexDirection.SE) {
			TriangulateConnection(direction, cell, v1, v2);
		}
	}


Zwölf Seiten statt sechs.

Das Verdoppeln von Eckpunkten und Dreiecken erhöht die Variabilität der Zellenränder. Machen wir sie noch ungleichmäßiger, indem wir die Anzahl der Eckpunkte verdreifachen.

		Vector3 e1 = Vector3.Lerp(v1, v2, 1f / 3f);
		Vector3 e2 = Vector3.Lerp(v1, v2, 2f / 3f);
		AddTriangle(center, v1, e1);
		AddTriangleColor(cell.color);
		AddTriangle(center, e1, e2);
		AddTriangleColor(cell.color);
		AddTriangle(center, e2, v2);
		AddTriangleColor(cell.color);


18 Seiten.

Rib Joint Division


Natürlich müssen wir auch die Randfugen unterteilen. Daher übergeben wir die neuen Eckpunkte der Kanten an TriangulateConnection.

		if (direction <= HexDirection.SE) {
			TriangulateConnection(direction, cell, v1, e1, e2, v2);
		}

Fügen Sie die entsprechenden Parameter hinzu, TriangulateConnectiondamit es mit zusätzlichen Scheitelpunkten arbeiten kann.

	void TriangulateConnection (
		HexDirection direction, HexCell cell,
		Vector3 v1, Vector3 e1, Vector3 e2, Vector3 v2
	) {
	…
}

Wir müssen auch zusätzliche Kantenscheitelpunkte für benachbarte Zellen berechnen. Wir können sie berechnen, nachdem wir die Brücke mit der anderen Seite verbunden haben.

		Vector3 bridge = HexMetrics.GetBridge(direction);
		Vector3 v3 = v1 + bridge;
		Vector3 v4 = v2 + bridge;
		v3.y = v4.y = neighbor.Position.y;
		Vector3 e3 = Vector3.Lerp(v3, v4, 1f / 3f);
		Vector3 e4 = Vector3.Lerp(v3, v4, 2f / 3f);

Als nächstes müssen wir die Kantentriangulation ändern. Bis wir die Hänge mit den Vorsprüngen ignorieren, addieren Sie einfach drei statt ein Quad.

		if (cell.GetEdgeType(direction) == HexEdgeType.Slope) {
			TriangulateEdgeTerraces(v1, v2, cell, v3, v4, neighbor);
		}
		else {
			AddQuad(v1, e1, v3, e3);
			AddQuadColor(cell.color, neighbor.color);
			AddQuad(e1, e2, e3, e4);
			AddQuadColor(cell.color, neighbor.color);
			AddQuad(e2, v2, e4, v4);
			AddQuadColor(cell.color, neighbor.color);
		}


Unterteilte Verbindungen.

Eckenränder verbinden


Da wir zur Beschreibung der Kanten nun vier Eckpunkte benötigen, wäre es logisch, sie zu einer Menge zusammenzufassen. Dies ist bequemer als das Arbeiten mit vier unabhängigen Scheitelpunkten. Erstellen wir hierfür eine einfache Struktur EdgeVertices. Es sollte vier Eckpunkte enthalten, die im Uhrzeigersinn entlang der Zellkante verlaufen.

using UnityEngine;
public struct EdgeVertices {
	public Vector3 v1, v2, v3, v4;
}

Sollten sie nicht serialisierbar sein?
Wir werden diese Struktur nur zur Triangulation verwenden. In dieser Phase müssen die Eckpunkte der Kanten nicht gespeichert werden, sodass sie nicht serialisierbar sein müssen.

Fügen Sie eine bequeme Konstruktormethode hinzu, die sich mit der Berechnung von Zwischenpunkten der Kante befasst.

	public EdgeVertices (Vector3 corner1, Vector3 corner2) {
		v1 = corner1;
		v2 = Vector3.Lerp(corner1, corner2, 1f / 3f);
		v3 = Vector3.Lerp(corner1, corner2, 2f / 3f);
		v4 = corner2;
	}

Jetzt können wir eine HexMeshseparate Triangulationsmethode hinzufügen, um einen Fächer aus Dreiecken zwischen der Mitte der Zelle und einer ihrer Kanten zu erstellen.

	void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) {
		AddTriangle(center, edge.v1, edge.v2);
		AddTriangleColor(color);
		AddTriangle(center, edge.v2, edge.v3);
		AddTriangleColor(color);
		AddTriangle(center, edge.v3, edge.v4);
		AddTriangleColor(color);
	}

Und ein Verfahren zum Triangulieren eines Viereckstreifens zwischen zwei Kanten.

	void TriangulateEdgeStrip (
		EdgeVertices e1, Color c1,
		EdgeVertices e2, Color c2
	) {
		AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
		AddQuadColor(c1, c2);
		AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
		AddQuadColor(c1, c2);
		AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
		AddQuadColor(c1, c2);
	}

Dadurch können wir die Methode vereinfachen Triangulate.

	void Triangulate (HexDirection direction, HexCell cell) {
		Vector3 center = cell.Position;
		EdgeVertices e = new EdgeVertices(
			center + HexMetrics.GetFirstSolidCorner(direction),
			center + HexMetrics.GetSecondSolidCorner(direction)
		);
		TriangulateEdgeFan(center, e, cell.color);
		if (direction <= HexDirection.SE) {
			TriangulateConnection(direction, cell, e);
		}
	}

Gehen wir weiter zu TriangulateConnection. Jetzt können wir verwenden TriangulateEdgeStrip, aber andere Ersetzungen müssen vorgenommen werden. Wo wir es früher v1getan haben, müssen wir es tun e1.v1. Ebenso wird v2es e1.v4, v3es wird e2.v1und v4es wird e2.v4.

	void TriangulateConnection (
		HexDirection direction, HexCell cell, EdgeVertices e1
	) {
		HexCell neighbor = cell.GetNeighbor(direction);
		if (neighbor == null) {
			return;
		}
		Vector3 bridge = HexMetrics.GetBridge(direction);
		bridge.y = neighbor.Position.y - cell.Position.y;
		EdgeVertices e2 = new EdgeVertices(
			e1.v1 + bridge,
			e1.v4 + bridge
		);
		if (cell.GetEdgeType(direction) == HexEdgeType.Slope) {
			TriangulateEdgeTerraces(e1.v1, e1.v4, cell, e2.v1, e2.v4, neighbor);
		}
		else {
			TriangulateEdgeStrip(e1, cell.color, e2, neighbor.color);
		}
		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (direction <= HexDirection.E && nextNeighbor != null) {
			Vector3 v5 = e1.v4 + HexMetrics.GetBridge(direction.Next());
			v5.y = nextNeighbor.Position.y;
			if (cell.Elevation <= neighbor.Elevation) {
				if (cell.Elevation <= nextNeighbor.Elevation) {
					TriangulateCorner(
						e1.v4, cell, e2.v4, neighbor, v5, nextNeighbor
					);
				}
				else {
					TriangulateCorner(
						v5, nextNeighbor, e1.v4, cell, e2.v4, neighbor
					);
				}
			}
			else if (neighbor.Elevation <= nextNeighbor.Elevation) {
				TriangulateCorner(
					e2.v4, neighbor, v5, nextNeighbor, e1.v4, cell
				);
			}
			else {
				TriangulateCorner(
					v5, nextNeighbor, e1.v4, cell, e2.v4, neighbor
				);
			}
		}

Leistenabteilung


Wir müssen teilen und Leisten. Deshalb passieren wir die Rippen TriangulateEdgeTerraces.

		if (cell.GetEdgeType(direction) == HexEdgeType.Slope) {
			TriangulateEdgeTerraces(e1, cell, e2, neighbor);
		}

Jetzt müssen wir es TriangulateEdgeTerracesso ändern , dass es zwischen Kanten und nicht zwischen Eckpunkten interpoliert. Nehmen wir an, dass Sie EdgeVerticeseine bequeme statische Methode dafür haben. Dies wird es uns ermöglichen, es zu vereinfachen, TriangulateEdgeTerracesanstatt es zu komplizieren.

	void TriangulateEdgeTerraces (
		EdgeVertices begin, HexCell beginCell,
		EdgeVertices end, HexCell endCell
	) {
		EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1);
		Color c2 = HexMetrics.TerraceLerp(beginCell.color, endCell.color, 1);
		TriangulateEdgeStrip(begin, beginCell.color, e2, c2);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			EdgeVertices e1 = e2;
			Color c1 = c2;
			e2 = EdgeVertices.TerraceLerp(begin, end, i);
			c2 = HexMetrics.TerraceLerp(beginCell.color, endCell.color, i);
			TriangulateEdgeStrip(e1, c1, e2, c2);
		}
		TriangulateEdgeStrip(e2, c2, end, endCell.color);
	}

Das Verfahren EdgeVertices.TerraceLerpinterpoliert einfach die Leisten zwischen allen vier Scheitelpunktpaaren zweier Kanten.

	public static EdgeVertices TerraceLerp (
		EdgeVertices a, EdgeVertices b, int step)
	{
		EdgeVertices result;
		result.v1 = HexMetrics.TerraceLerp(a.v1, b.v1, step);
		result.v2 = HexMetrics.TerraceLerp(a.v2, b.v2, step);
		result.v3 = HexMetrics.TerraceLerp(a.v3, b.v3, step);
		result.v4 = HexMetrics.TerraceLerp(a.v4, b.v4, step);
		return result;
	}


Unterteilte Leisten.

Einheitspaket

Schließen Sie Klippen und Felsvorsprünge wieder an


Bisher haben wir Risse in der Kreuzung von Klippen und Felsvorsprüngen ignoriert. Es ist Zeit, dieses Problem zu lösen. Betrachten wir zunächst die Fälle Cliff-Slope-Slope (OSS) und Slope-Cliff-Slope (SOS).


Löcher ineinander greifen.

Das Problem tritt auf, weil sich die Spitzen der Grenzen verschoben haben. Das bedeutet, dass sie jetzt nicht genau auf der Seite der Klippe liegen, was zu einem Riss führt. Manchmal sind diese Löcher unsichtbar und manchmal auffällig.

Die Lösung besteht darin, den oberen Rand nicht zu verschieben. Dies bedeutet, dass wir steuern müssen, ob der Punkt verschoben wird. Am einfachsten ist es, eine Alternative zu erstellen AddTriangle, bei der die Scheitelpunkte überhaupt nicht verschoben werden.

	void AddTriangleUnperturbed (Vector3 v1, Vector3 v2, Vector3 v3) {
		int vertexIndex = vertices.Count;
		vertices.Add(v1);
		vertices.Add(v2);
		vertices.Add(v3);
		triangles.Add(vertexIndex);
		triangles.Add(vertexIndex + 1);
		triangles.Add(vertexIndex + 2);
	}

Ändern Sie, TriangulateBoundaryTriangledamit er diese Methode verwendet. Dies bedeutet, dass er alle Scheitelpunkte außer den Randscheitelpunkten explizit verschieben muss.

	void TriangulateBoundaryTriangle (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 boundary, Color boundaryColor
	) {
		Vector3 v2 = HexMetrics.TerraceLerp(begin, left, 1);
		Color c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, 1);
		AddTriangleUnperturbed(Perturb(begin), Perturb(v2), boundary);
		AddTriangleColor(beginCell.color, c2, boundaryColor);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v2;
			Color c1 = c2;
			v2 = HexMetrics.TerraceLerp(begin, left, i);
			c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, i);
			AddTriangleUnperturbed(Perturb(v1), Perturb(v2), boundary);
			AddTriangleColor(c1, c2, boundaryColor);
		}
		AddTriangleUnperturbed(Perturb(v2), Perturb(left), boundary);
		AddTriangleColor(c2, leftCell.color, boundaryColor);
	}

Beachten Sie Folgendes: Da wir keinen v2anderen Punkt verwenden, können Sie ihn sofort verschieben. Dies ist eine einfache Optimierung und reduziert die Menge an Code.

	void TriangulateBoundaryTriangle (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 boundary, Color boundaryColor
	) {
		Vector3 v2 = Perturb(HexMetrics.TerraceLerp(begin, left, 1));
		Color c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, 1);
		AddTriangleUnperturbed(Perturb(begin), v2, boundary);
		AddTriangleColor(beginCell.color, c2, boundaryColor);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v2;
			Color c1 = c2;
			v2 = Perturb(HexMetrics.TerraceLerp(begin, left, i));
			c2 = HexMetrics.TerraceLerp(beginCell.color, leftCell.color, i);
			AddTriangleUnperturbed(v1, v2, boundary);
			AddTriangleColor(c1, c2, boundaryColor);
		}
		AddTriangleUnperturbed(v2, Perturb(left), boundary);
		AddTriangleColor(c2, leftCell.color, boundaryColor);
	}


Unbewegte Grenzen.

Es sieht besser aus, aber wir sind noch nicht fertig. Innerhalb der Methode wird der TriangulateCornerTerracesCliffGrenzpunkt zwischen dem linken und dem rechten Punkt interpoliert. Diese Punkte wurden jedoch noch nicht verschoben. Damit der Grenzpunkt der resultierenden Klippe entspricht, müssen wir zwischen den bewegten Punkten interpolieren.

		Vector3 boundary = Vector3.Lerp(Perturb(begin), Perturb(right), b);

Gleiches gilt für die Methode TriangulateCornerCliffTerraces.

		Vector3 boundary = Vector3.Lerp(Perturb(begin), Perturb(left), b);


Die Löcher sind weg.

Doppelklippen und Hang


In allen verbleibenden Problemfällen sind zwei Klippen und ein Hang vorhanden.


Großes Loch aufgrund eines einzelnen Dreiecks.

Dieses Problem wird durch manuelles Verschieben eines einzelnen Dreiecks im Block elseam Ende behoben TriangulateCornerTerracesCliff.

		else {
			AddTriangleUnperturbed(Perturb(left), Perturb(right), boundary);
			AddTriangleColor(leftCell.color, rightCell.color, boundaryColor);
		}

Gleiches gilt für TriangulateCornerCliffTerraces.

		else {
			AddTriangleUnperturbed(Perturb(left), Perturb(right), boundary);
			AddTriangleColor(leftCell.color, rightCell.color, boundaryColor);
		}


Beseitigen Sie die neuesten Risse.

Einheitspaket

Fertigstellung


Jetzt haben wir ein völlig korrektes verzerrtes Netz. Sein Aussehen hängt vom spezifischen Geräusch, seinem Ausmaß und den Verzerrungskräften ab. In unserem Fall scheint die Verzerrung zu stark zu sein. Obwohl diese Unebenheit wunderschön aussieht, möchten wir nicht, dass die Zellen zu stark vom geraden Raster abweichen. Letztendlich verwenden wir es immer noch, um die Zelle zu definieren, deren Größe geändert werden soll. Und wenn die Größe der Zellen zu stark variiert, wird es für uns schwieriger, den Inhalt darin abzulegen.



Unverzerrte und verzerrte Maschen

Es scheint, dass die Kraft 5 zum Verzerren der Zellen zu groß ist.


Die Verzerrung der Zellen liegt zwischen 0 und 5.

Reduzieren wir sie auf 4, um die Bequemlichkeit des Rasters zu erhöhen, ohne es zu korrigieren. Dies stellt sicher, dass der maximale XZ-Versatz √32 ≈ 5.66 Einheiten beträgt.

	public const float cellPerturbStrength = 4f;


Zellverzerrungsstärke 4.
Ein weiterer Wert, der geändert werden kann, ist der Integritätskoeffizient. Wenn wir es erhöhen, werden die flachen Zentren der Zellen größer, das heißt, es wird mehr Platz für zukünftige Inhalte geben. Dabei werden sie natürlich sechseckiger.


Integritätskoeffizient von 0,75 bis 0,95.

Eine kleine Erhöhung des Integritätskoeffizienten auf 0,8 wird unser zukünftiges Leben etwas vereinfachen.

	public const float solidFactor = 0.8f;


Integritätskoeffizient 0.8.

Schließlich können Sie sehen, dass die Unterschiede zwischen den Höhenstufen zu stark sind. Dies ist praktisch, wenn Sie sicherstellen müssen, dass das Netz korrekt generiert wurde. Damit sind wir jedoch bereits fertig. Reduzieren wir es auf 1 Einheit pro Stufe der Kante, das heißt auf 3.

	public const float elevationStep = 3f;


Die Tonhöhe wird auf 3 reduziert.

Wir können auch die Stärke der Tonhöhenverzerrung ändern. Aber jetzt hat es einen Wert von 1,5, was einer halben Höhenstufe entspricht, was für uns passt.

Kleine Höhenschritte ermöglichen eine logischere Verwendung aller sieben Höhenstufen. Dies erhöht die Variabilität der Karte.


Wir verwenden sieben Höhenstufen.

Einheitspaket

Teil 5: größere Karten


  • Wir teilen das Gitter in Fragmente.
  • Wir steuern die Kamera.
  • Färben Sie die Farben und Höhen separat ein.
  • Verwenden Sie den vergrößerten Pinsel der Zellen.

Bisher haben wir mit einer sehr kleinen Karte gearbeitet. Es ist Zeit, es zu erhöhen.


Es ist Zeit zu zoomen.

Mesh-Fragmente


Wir können das Gitter nicht zu groß machen, weil wir an die Grenzen dessen stoßen, was in ein Gitter passen kann. Wie kann man dieses Problem lösen? Verwenden Sie mehrere Maschen. Dazu müssen wir unser Gitter in mehrere Fragmente aufteilen. Wir verwenden rechteckige Fragmente konstanter Größe.


Teilen Sie das Gitter in 3 mal 3 Segmente

und verwenden Sie 5 mal 5 Blöcke, dh 25 Zellen pro Fragment. Definieren Sie sie in HexMetrics.

	public const int chunkSizeX = 5, chunkSizeZ = 5;

Welche Fragmentgröße kann als geeignet angesehen werden?
Es ist schwer zu sagen. Bei der Verwendung von großen Netzfragmenten wird weniger benötigt, die Größe ist jedoch größer. Dies reduziert die Anzahl der Draw Calls. Aber je kleiner die Fragmente sind, desto besser werden sie von der Pyramide der Sichtbarkeit (Kegelstumpf) abgeschnitten, was dazu führt, dass weniger Dreiecke gezeichnet werden. Es wäre logischer, die Größe einfach zu wählen und gegebenenfalls in Zukunft anzupassen.

Jetzt können wir keine Größe für das Raster verwenden, es muss ein Vielfaches der Fragmentgröße sein. Ändern wir es HexGriddaher so, dass seine Größe nicht in separaten Zellen, sondern in Fragmenten festgelegt wird. Legen Sie die Standardgröße auf 4 x 3 Fragmente fest, dh nur 12 Fragmente oder 300 Zellen. So bekommen wir eine bequeme Testkarte.

	public int chunkCountX = 4, chunkCountZ = 3;

Wir benutzen immer noch widthund height, aber jetzt sollten sie privat werden. Und benenne sie in cellCountXund um cellCountZ. Verwenden Sie den Editor, um alle Vorkommen dieser Variablen auf einmal umzubenennen. Nun wird klar, wann es sich um die Anzahl der Fragmente oder Zellen handelt.

//	public int width = 6;
//	public int height = 6;
	int cellCountX, cellCountZ;



Geben Sie die Größe in Fragmenten an.

Ändern Sie dies Awakeso, dass bei Bedarf die Anzahl der Zellen aus der Anzahl der Fragmente berechnet wird. Wir heben die Erzeugung von Zellen in einer separaten Methode hervor, um nicht zu verstopfen Awake.

	void Awake () {
		HexMetrics.noiseSource = noiseSource;
		gridCanvas = GetComponentInChildren();
		hexMesh = GetComponentInChildren();
		cellCountX = chunkCountX * HexMetrics.chunkSizeX;
		cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ;
		CreateCells();
	}
	void CreateCells () {
		cells = new HexCell[cellCountZ * cellCountX];
		for (int z = 0, i = 0; z < cellCountZ; z++) {
			for (int x = 0; x < cellCountX; x++) {
				CreateCell(x, z, i++);
			}
		}
	}

Fragment vorgefertigt


Um die Netzfragmente zu beschreiben, benötigen wir einen neuen Komponententyp.

using UnityEngine;
using UnityEngine.UI;
public class HexGridChunk : MonoBehaviour {
}

Als nächstes erstellen wir ein vorgefertigtes Fragment. Dazu duplizieren wir das Hex Grid- Objekt und benennen es in Hex Grid Chunk um . Entfernen Sie die Komponente HexGridund fügen Sie stattdessen eine Komponente hinzu HexGridChunk. Verwandeln Sie es dann in ein Fertighaus und entfernen Sie das Objekt aus der Szene.



Ein vorgefertigtes Fragment mit eigener Leinwand und Masche.

Da dadurch Instanzen dieser Fragmente erstellt werden HexGrid, wird eine Verknüpfung zum vorgefertigten Fragment erstellt.

	public HexGridChunk chunkPrefab;


Jetzt mit Fragmenten.

Das Erstellen von Instanzen von Fragmenten ähnelt dem Erstellen von Instanzen von Zellen. Wir werden sie mit Hilfe eines Arrays verfolgen, und um es zu verwenden, werden wir eine Doppelschleife verwenden.

	HexGridChunk[] chunks;
	void Awake () {
		…
		CreateChunks();
		CreateCells();
	}
	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(transform);
			}
		}
	}

Das Initialisieren eines Fragments ähnelt dem Initialisieren eines Gitters aus Sechsecken. Sie setzt alles ein Awakeund führt eine Triangulation durch Start. Es erfordert einen Verweis auf die Zeichenfläche und das Netz sowie ein Array für die Zellen. Das Fragment erstellt diese Zellen jedoch nicht. Das Gitter wird dies weiterhin tun.

public class HexGridChunk : MonoBehaviour {
	HexCell[] cells;
	HexMesh hexMesh;
	Canvas gridCanvas;
	void Awake () {
		gridCanvas = GetComponentInChildren();
		hexMesh = GetComponentInChildren();
		cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ];
	}
	void Start () {
		hexMesh.Triangulate(cells);
	}
}

Zuweisen von Zellen zu Fragmenten


HexGridschafft immer noch alle Zellen. Dies ist normal, aber jetzt müssen wir jede Zelle einem geeigneten Fragment hinzufügen und dürfen sie nicht mit unserem eigenen Netz und Canvas festlegen.

	void CreateCell (int x, int z, int i) {
		…
		HexCell cell = cells[i] = Instantiate(cellPrefab);
//		cell.transform.SetParent(transform, false);
		cell.transform.localPosition = position;
		cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
		cell.color = defaultColor;
		…
		Text label = Instantiate(cellLabelPrefab);
//		label.rectTransform.SetParent(gridCanvas.transform, false);
		label.rectTransform.anchoredPosition =
			new Vector2(position.x, position.z);
		label.text = cell.coordinates.ToStringOnSeparateLines();
		cell.uiRect = label.rectTransform;
		cell.Elevation = 0;
		AddCellToChunk(x, z, cell);
	}
	void AddCellToChunk (int x, int z, HexCell cell) {
	}

Wir können das richtige Fragment durch ganzzahlige Division xund zdurch Fragmentgröße finden.

	void AddCellToChunk (int x, int z, HexCell cell) {
		int chunkX = x / HexMetrics.chunkSizeX;
		int chunkZ = z / HexMetrics.chunkSizeZ;
		HexGridChunk chunk = chunks[chunkX + chunkZ * chunkCountX];
	}

Mit Hilfe von Zwischenergebnissen können wir auch den lokalen Zellindex in diesem Fragment bestimmen. Danach können Sie dem Fragment eine Zelle hinzufügen.

	void AddCellToChunk (int x, int z, HexCell cell) {
		int chunkX = x / HexMetrics.chunkSizeX;
		int chunkZ = z / HexMetrics.chunkSizeZ;
		HexGridChunk chunk = chunks[chunkX + chunkZ * chunkCountX];
		int localX = x - chunkX * HexMetrics.chunkSizeX;
		int localZ = z - chunkZ * HexMetrics.chunkSizeZ;
		chunk.AddCell(localX + localZ * HexMetrics.chunkSizeX, cell);
	}

Dann platziert es HexGridChunk.AddCelldie Zelle in einem eigenen Array und legt dann die übergeordneten Elemente für die Zelle und ihre Benutzeroberfläche fest.

	public void AddCell (int index, HexCell cell) {
		cells[index] = cell;
		cell.transform.SetParent(transform, false);
		cell.uiRect.SetParent(gridCanvas.transform, false);
	}

Sweep


Zu diesem Zeitpunkt kann er HexGriddie Leinwand und das Sechsecknetz seiner Kinder sowie den Code loswerden.

//	Canvas gridCanvas;
//	HexMesh hexMesh;
	void Awake () {
		HexMetrics.noiseSource = noiseSource;
//		gridCanvas = GetComponentInChildren();
//		hexMesh = GetComponentInChildren();
		…
	}
//	void Start () {
//		hexMesh.Triangulate(cells);
//	}
//	public void Refresh () {
//		hexMesh.Triangulate(cells);
//	}

Da wir losgeworden sind Refresh, sollten wir es HexMapEditornicht mehr benutzen.

	void EditCell (HexCell cell) {
		cell.color = activeColor;
		cell.Elevation = activeElevation;
//		hexGrid.Refresh();
	}


Das gereinigte Gitter aus Sechsecken.

Nach dem Start des Play-Modus sieht die Karte immer noch gleich aus. Die Hierarchie der Objekte ist jedoch unterschiedlich. Hex Grid erstellt nun untergeordnete Fragmentobjekte, die Zellen sowie deren Mesh und Canvas enthalten.


Untergeordnete Fragmente im Wiedergabemodus.

Möglicherweise haben wir einige Probleme mit Zellenbeschriftungen. Zunächst haben wir die Breite des Etiketts 5 festgelegt. Dies reichte aus, um die beiden Zeichen, die für uns ausreichten, auf einer kleinen Karte anzuzeigen. Aber jetzt können wir Koordinaten wie -10 haben, in denen es drei Zeichen gibt. Sie passen nicht und werden gekürzt. Um dies zu beheben, erhöhen Sie die Breite der Zellenbeschriftung auf 10 oder noch mehr.



Erweiterte Zellenbeschriftungen.

Jetzt können wir viel größere Karten erstellen! Da das gesamte Raster beim Start generiert wird, kann das Erstellen großer Karten viel Zeit in Anspruch nehmen. Aber nach der Fertigstellung haben wir viel Platz zum Experimentieren.

Korrigiert die Zellbearbeitung


Die Bearbeitung scheint derzeit nicht zu funktionieren, da das Raster nicht mehr aktualisiert wird. Wir Fragmente fügen Sie so ein Verfahren aktualisiert werden müssen , Refreshum HexGridChunk.

	public void Refresh () {
		hexMesh.Triangulate(cells);
	}

Wann sollten wir diese Methode aufrufen? Wir haben das gesamte Gitter jedes Mal aktualisiert, weil wir nur ein Gitter hatten. Aber jetzt haben wir viele Fragmente. Anstatt jedes Mal alle zu aktualisieren, ist es effizienter, die geänderten Fragmente zu aktualisieren. Andernfalls wird das Wechseln großer Karten sehr langsam.

Aber woher weißt du, welches Fragment wir aktualisieren sollen? Am einfachsten ist es, jeder Zelle mitzuteilen, zu welchem ​​Fragment sie gehört. Dann kann die Zelle ihr Fragment aktualisieren, wenn sich diese Zelle ändert. Geben wir also einen HexCellLink zu seinem Fragment an.

	public HexGridChunk chunk;

HexGridChunk kann sich beim Hinzufügen selbst zur Zelle hinzufügen.

	public void AddCell (int index, HexCell cell) {
		cells[index] = cell;
		cell.chunk = this;
		cell.transform.SetParent(transform, false);
		cell.uiRect.SetParent(gridCanvas.transform, false);
	}

Indem wir sie verbinden, ergänzen wir HexCellund die Methode Refresh. Jedes Mal, wenn eine Zelle aktualisiert wird, wird das Fragment einfach aktualisiert.

	void Refresh () {
		chunk.Refresh();
	}

Wir müssen es nicht HexCell.Refreshallgemein machen, da die Zelle selbst besser weiß, wann es geändert wurde. Zum Beispiel, nachdem seine Höhe geändert wurde.

	public int Elevation {
		get {
			return elevation;
		}
		set {
			…
			Refresh();
		}
	}

Tatsächlich müssen wir es nur aktualisieren, wenn sich seine Höhe auf einen anderen Wert geändert hat. Sie muss nicht einmal etwas neu berechnen, wenn wir ihr die gleiche Größe wie zuvor zuweisen. Daher können wir den Anfang des Setters verlassen.

	public int Elevation {
		get {
			return elevation;
		}
		set {
			if (elevation == value) {
				return;
			}
			…
		}
	}

Wenn die Höhe auf 0 gesetzt ist, werden jedoch erstmals Berechnungen übersprungen, da dies der Standardwert für die Maschenhöhe ist. Um dies zu vermeiden, werden wir den Anfangswert so festlegen, wie wir ihn niemals verwenden.

	int elevation = int.MinValue;

Was ist int.MinValue?
Dies ist der kleinste Wert, den eine Ganzzahl haben kann. Da es sich bei C # -Integer um
eine 32-Bit-Zahl handelt, gibt es 2 32 mögliche Ganzzahlen, die in positive Werte, negative Werte und Null unterteilt sind. Um die Negativität einer Zahl anzuzeigen, wird ein Bit verwendet.

Das Minimum ist −2 31 = −2 147 483 648. Wir werden diese Höhe niemals verwenden!

Das Maximum ist 2 31 - 1 = 2 147 483 647. Dies ist eins weniger als 2 31 aufgrund von Null.

Um die Farbänderung der Zelle zu erkennen, müssen wir sie auch in eine Eigenschaft umwandeln. Benennen Sie es in ColorGroßbuchstaben um, und verwandeln Sie es dann in eine Eigenschaft mit einer privaten Variablen color. Der Standardfarbwert ist transparentes Schwarz, was zu uns passt.

	public Color Color {
		get {
			return color;
		}
		set {
			if (color == value) {
				return;
			}
			color = value;
			Refresh();
		}
	}
	Color color;

Wenn wir jetzt den Play-Modus starten, erhalten wir Ausnahmen mit Nullreferenz. Dies geschieht, weil wir die Farbe und Höhe auf ihre Standardwerte setzen, bevor wir eine Zelle ihrem Fragment zuweisen. Es ist normal, dass wir die Fragmente zu diesem Zeitpunkt nicht aktualisieren, da wir sie nach Abschluss der Initialisierung triangulieren. Mit anderen Worten, wir aktualisieren ein Fragment nur, wenn es zugewiesen ist.

	void Refresh () {
		if (chunk) {
			chunk.Refresh();
		}
	}

Endlich können wir die Zellen wieder wechseln! Es tritt jedoch ein Problem auf. Beim Zeichnen entlang der Fragmentränder erscheinen Nähte.


Fehler an den Grenzen von Fragmenten.

Das ist logisch, denn wenn sich eine einzelne Zelle ändert, ändern sich auch alle Verbindungen mit ihren Nachbarn. Und diese Nachbarn können in anderen Fragmenten sein. Die einfachste Lösung besteht darin, alle benachbarten Zellen zu aktualisieren, wenn sie unterschiedlich sind.

	void Refresh () {
		if (chunk) {
			chunk.Refresh();
			for (int i = 0; i < neighbors.Length; i++) {
				HexCell neighbor = neighbors[i];
				if (neighbor != null && neighbor.chunk != chunk) {
					neighbor.chunk.Refresh();
				}
			}
		}
	}

Obwohl dies funktioniert, kann sich herausstellen, dass wir ein Fragment mehrmals aktualisieren. Und wenn wir beginnen, mehrere Zellen gleichzeitig einzufärben, wird alles noch schlimmer.

Es ist jedoch nicht erforderlich, sofort nach dem Aktualisieren des Fragments zu triangulieren. Stattdessen schreiben wir einfach, dass ein Update erforderlich ist, und triangulieren, nachdem die Änderung abgeschlossen ist.

Da es HexGridChunksonst nichts tut, können wir den aktivierten Zustand verwenden, um die Notwendigkeit von Aktualisierungen anzuzeigen. Bei der Aktualisierung beziehen wir die Komponente mit ein. Das mehrmalige Einschalten ändert nichts. Die Komponente wird später aktualisiert. Wir werden an dieser Stelle triangulieren und die Komponente wieder deaktivieren.

Wir verwenden LateUpdatestattdessenUpdateum sicherzustellen, dass die Triangulation erfolgt, nachdem die Änderung für den aktuellen Frame abgeschlossen ist.

	public void Refresh () {
//		hexMesh.Triangulate(cells);
		enabled = true;
	}
	void LateUpdate () {
		hexMesh.Triangulate(cells);
		enabled = false;
	}

Was ist der Unterschied zwischen Update und LateUpdate?
In jedem Frame werden die Methoden der Updateenthaltenen Komponenten zu einem bestimmten Zeitpunkt in einer beliebigen Reihenfolge aufgerufen. Sobald dies abgeschlossen ist, geschieht dasselbe mit den Methoden LateUpdate. Das heißt, es gibt zwei Phasen der Aktualisierung, früh und spät.

Da unsere Komponente standardmäßig aktiviert ist, müssen wir nicht mehr explizit triangulieren Start. Daher kann diese Methode entfernt werden.

//	void Start () {
//		hexMesh.Triangulate(cells);
//	}


Fragmente von 20 mal 20 mit 10.000 Zellen.

Generalisierte Listen


Obwohl wir die Art und Weise, wie das Gitter trianguliert wird, erheblich geändert haben, HexMeshbleibt es gleich. Alles, was er arbeiten muss, ist eine Reihe von Zellen. Es ist ihm egal, ob es ein oder mehrere Sechsecke gibt. Wir haben jedoch noch nicht in Betracht gezogen, mehrere Maschen zu verwenden. Vielleicht kann hier etwas verbessert werden?

Die verwendeten HexMeshListen sind im Wesentlichen temporäre Puffer. Sie werden nur zur Triangulation verwendet. Und die Fragmente werden einzeln trianguliert. Daher benötigen wir in der Tat nur einen Satz von Listen und nicht einen Satz für jedes Hexagon-Mesh-Objekt. Dies kann erreicht werden, indem die Listen statisch gemacht werden.

	static List vertices = new List();
	static List colors = new List();
	static List triangles = new List();
	void Awake () {
		GetComponent().mesh = hexMesh = new Mesh();
		meshCollider = gameObject.AddComponent();
		hexMesh.name = "Hex Mesh";
//		vertices = new List();
//		colors = new List();
//		triangles = new List();
	}

Sind statische Listen wirklich so wichtig?
Это изменение внести очень просто и оно отражает способ использования списков. Поэтому стоит это сделать, несмотря на то, что пока нас не очень беспокоит производительность.

Это немного увеличивает эффективность кода, потому что при использовании обобщённых списков необходимо намного меньше выделяемой памяти. В случае карты 20 на 20 фрагментов это экономит больше 100МБ.

unitypackage

Управление камерой


Die große Kamera ist wunderbar, aber es nützt nichts, wenn wir sie nicht sehen können. Um die gesamte Karte zu inspizieren, müssen wir die Kamera bewegen. Das Zoomen ist auch nützlich. Erstellen wir daher eine Kamera, um diese Aktionen auszuführen.

Erstellen Sie ein Dummy-Objekt und nennen Sie es Hex Map Camera . Bewegen Sie die Transformationskomponente zum Ursprung, ohne die Drehung und Skalierung zu ändern. Fügen Sie ein untergeordnetes Element mit dem Namen Swivel hinzu , und fügen Sie dem Stick ein untergeordnetes Element hinzu . Machen Sie die Hauptkamera zum Kind des Sticks und setzen Sie die Transformationskomponente zurück.


Die Hierarchie der Kamera.

Ziel des Kamerascharniers (Swivel) ist es, den Blickwinkel der Kamera auf die Karte zu steuern. Lasst es uns drehen (45, 0, 0). Der Griff (Stick) steuert die Entfernung, in der sich die Kameras befinden. Setzen wir ihr eine Position (0, 0, -45).

Jetzt brauchen wir eine Komponente, um dieses System zu steuern. Weisen Sie diese Komponente dem Stammverzeichnis der Kamerahierarchie zu. Geben Sie ihm einen Link zum Scharnier und Griff, und holen Sie sie hinein Awake.

using UnityEngine;
public class HexMapCamera : MonoBehaviour {
	Transform swivel, stick;
	void Awake () {
		swivel = transform.GetChild(0);
		stick = swivel.GetChild(0);
	}
}


Kamera Sechsecke Karte.

Zoom


Die erste Funktion, die wir erstellen, ist das Zoomen (zoom). Wir können die aktuelle Zoomstufe mit der Variablen float steuern. Ein Wert von 0 bedeutet, dass wir vollständig entfernt sind, und ein Wert von 1 bedeutet, dass wir vollständig in der Nähe sind. Beginnen wir mit maximalem Zoom.

	float zoom = 1f;

Das Zoomen erfolgt normalerweise mit dem Mausrad oder einer analogen Steuerung. Wir können es mit der standardmäßigen Mouse ScrollWheel- Eingabeachse implementieren . Fügen Sie eine Methode hinzu Update, die das Vorhandensein eines Eingabe-Deltas überprüft. Wenn es eine gibt, wird die Zoomänderungsmethode aufgerufen.

	void Update () {
		float zoomDelta = Input.GetAxis("Mouse ScrollWheel");
		if (zoomDelta != 0f) {
			AdjustZoom(zoomDelta);
		}
	}
	void AdjustZoom (float delta) {
	}

Um die Zoomstufe zu ändern, fügen wir einfach ein Delta hinzu und begrenzen dann den Wert (Klammer) so, dass er im Bereich von 0 bis 1 bleibt.

	void AdjustZoom (float delta) {
		zoom = Mathf.Clamp01(zoom + delta);
	}

Beim Vergrößern und Verkleinern sollte sich der Abstand zur Kamera entsprechend ändern. Dies kann durch Ändern der Position des Griffs in Z erfolgen. Fügen Sie zwei allgemeine Float-Variablen hinzu, um die Position des Griffs bei minimalem und maximalem Zoom anzupassen. Da wir eine relativ kleine Karte entwickeln, setzen Sie die Werte auf -250 und -45.

	public float stickMinZoom, stickMaxZoom;

Nach dem Ändern des Zooms führen wir basierend auf dem neuen Zoomwert eine lineare Interpolation zwischen diesen beiden Werten durch. Aktualisieren Sie dann die Position des Griffs.

	void AdjustZoom (float delta) {
		zoom = Mathf.Clamp01(zoom + delta);
		float distance = Mathf.Lerp(stickMinZoom, stickMaxZoom, zoom);
		stick.localPosition = new Vector3(0f, 0f, distance);
	}



Minimale und maximale Stickwerte.

Jetzt funktioniert der Zoom, aber bisher ist es nicht sehr nützlich. Wenn der Zoom weiter entfernt ist, zeigt die Kamera normalerweise eine Draufsicht. Wir können dies tun, indem wir das Scharnier drehen. Daher addieren wir die Variablen min und max für das Scharnier. Setzen wir sie auf die Werte 90 und 45.

	public float swivelMinZoom, swivelMaxZoom;

Wie bei der Griffposition interpolieren wir, um einen geeigneten Zoomwinkel zu finden. Dann stellen wir die Drehung des Scharniers ein.

	void AdjustZoom (float delta) {
		zoom = Mathf.Clamp01(zoom + delta);
		float distance = Mathf.Lerp(stickMinZoom, stickMaxZoom, zoom);
		stick.localPosition = new Vector3(0f, 0f, distance);
		float angle = Mathf.Lerp(swivelMinZoom, swivelMaxZoom, zoom);
		swivel.localRotation = Quaternion.Euler(angle, 0f, 0f);
	}



Der minimale und maximale Wert von Swivel.

Die Änderungsrate des Zooms kann durch Ändern der Empfindlichkeit der Eingabeparameter des Mausrads angepasst werden. Sie finden sie unter Bearbeiten / Projekteinstellungen / Eingabe . Wenn Sie sie beispielsweise von 0,1 auf 0,025 ändern, ändert sich der Zoom langsamer und sanfter.


Eingabemöglichkeiten mit dem Mausrad.

Umzug


Fahren wir nun mit dem Bewegen der Kamera fort. Die Bewegung in X- und Z-Richtung müssen wir Updatewie beim Zoom ausführen. Hierfür können horizontale und vertikale Eingabeachsen verwendet werden . Dadurch können wir die Kamera mit den Pfeiltasten und den WASD-Tasten bewegen.

	void Update () {
		float zoomDelta = Input.GetAxis("Mouse ScrollWheel");
		if (zoomDelta != 0f) {
			AdjustZoom(zoomDelta);
		}
		float xDelta = Input.GetAxis("Horizontal");
		float zDelta = Input.GetAxis("Vertical");
		if (xDelta != 0f || zDelta != 0f) {
			AdjustPosition(xDelta, zDelta);
		}
	}
	void AdjustPosition (float xDelta, float zDelta) {
	}

Am einfachsten ist es, die aktuelle Position des Kamerasystems zu ermitteln, Deltas X und Z hinzuzufügen und das Ergebnis der Position des Systems zuzuweisen.

	void AdjustPosition (float xDelta, float zDelta) {
		Vector3 position = transform.localPosition;
		position += new Vector3(xDelta, 0f, zDelta);
		transform.localPosition = position;
	}

Dank dieser Funktion bewegt sich die Kamera mit gedrückten Pfeilen oder WASD, jedoch nicht mit konstanter Geschwindigkeit. Dies hängt von der Bildrate ab. Um die Strecke zu bestimmen, die Sie zurücklegen müssen, verwenden wir das Zeitdelta sowie die erforderliche Geschwindigkeit. Aus diesem Grund fügen wir eine gemeinsame Variable hinzu, moveSpeedsetzen sie auf 100 und multiplizieren sie dann mit dem Zeitdelta, um das Positionsdelta zu erhalten.

	public float moveSpeed;
	void AdjustPosition (float xDelta, float zDelta) {
		float distance = moveSpeed * Time.deltaTime;
		Vector3 position = transform.localPosition;
		position += new Vector3(xDelta, 0f, zDelta) * distance;
		transform.localPosition = position;
	}


Bewegungsgeschwindigkeit.

Jetzt können wir uns mit konstanter Geschwindigkeit entlang der X- oder Z-Achse bewegen, aber wenn wir uns gleichzeitig (diagonal) entlang beider Achsen bewegen, ist die Bewegung schneller. Um dies zu beheben, müssen wir den Delta-Vektor normalisieren. Auf diese Weise können Sie es als Ziel verwenden.

	void AdjustPosition (float xDelta, float zDelta) {
		Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized;
		float distance = moveSpeed * Time.deltaTime;
		Vector3 position = transform.localPosition;
		position += direction * distance;
		transform.localPosition = position;
	}

Die diagonale Bewegung ist nun korrekt ausgeführt, aber plötzlich stellt sich heraus, dass sich die Kamera auch nach dem Loslassen aller Tasten noch ziemlich lange bewegt. Dies liegt daran, dass die Eingangsachsen nicht sofort nach dem Drücken der Tasten auf die Grenzwerte springen. Dafür brauchen sie etwas Zeit. Gleiches gilt für die Freigabe von Schlüsseln. Es dauert eine Weile, bis die Werte der Achse Null sind. Da wir jedoch die Eingabewerte normalisiert haben, wird die maximale Geschwindigkeit konstant beibehalten.

Wir können die Eingabeparameter anpassen, um die Verzögerungen zu beseitigen, aber sie geben ein Gefühl der Glätte, das es sich zu sparen lohnt. Wir können den extremsten Wert der Achsen als Dämpfungsbeiwert verwenden.

		Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized;
		float damping = Mathf.Max(Mathf.Abs(xDelta), Mathf.Abs(zDelta));
		float distance = moveSpeed * damping * Time.deltaTime;


Bewegung mit Dämpfung.

Jetzt funktioniert die Bewegung gut, zumindest mit einer Vergrößerung des Zooms. In einiger Entfernung stellt sich heraus, dass es zu langsam ist. Mit reduziertem Zoom müssen wir beschleunigen. Dies kann durch Ersetzen einer Variablen moveSpeeddurch zwei für den minimalen und maximalen Zoom und anschließendes Interpolieren erfolgen. Weisen Sie ihnen Werte von 400 und 100 zu.

//	public float moveSpeed;
	public float moveSpeedMinZoom, moveSpeedMaxZoom;
	void AdjustPosition (float xDelta, float zDelta) {
		Vector3 direction = new Vector3(xDelta, 0f, zDelta).normalized;
		float damping = Mathf.Max(Mathf.Abs(xDelta), Mathf.Abs(zDelta));
		float distance =
			Mathf.Lerp(moveSpeedMinZoom, moveSpeedMaxZoom, zoom) *
			damping * Time.deltaTime;
		Vector3 position = transform.localPosition;
		position += direction * distance;
		transform.localPosition = position;
	}



Die Bewegungsgeschwindigkeit variiert je nach Zoomstufe.

Jetzt können wir uns schnell auf der Karte bewegen! Tatsächlich können wir uns weit über die Karte hinausbewegen, was jedoch unerwünscht ist. Die Kamera sollte in der Karte bleiben. Um dies zu gewährleisten, müssen wir die Grenzen der Karte kennen, daher ist eine Verknüpfung mit dem Raster erforderlich. Hinzufügen und verbinden.

	public HexGrid grid;


Müssen Sie Rastergröße anfordern.

Nachdem Sie eine neue Position angefahren haben, begrenzen Sie diese mit der neuen Methode.

	void AdjustPosition (float xDelta, float zDelta) {
		…
		transform.localPosition = ClampPosition(position);
	}
	Vector3 ClampPosition (Vector3 position) {
		return position;
	}

Position X hat einen Minimalwert von 0 und der Maximalwert wird durch die Größe der Karte bestimmt.

	Vector3 ClampPosition (Vector3 position) {
		float xMax =
			grid.chunkCountX * HexMetrics.chunkSizeX *
			(2f * HexMetrics.innerRadius);
		position.x = Mathf.Clamp(position.x, 0f, xMax);
		return position;
	}

Gleiches gilt für Position Z.

	Vector3 ClampPosition (Vector3 position) {
		float xMax =
			grid.chunkCountX * HexMetrics.chunkSizeX *
			(2f * HexMetrics.innerRadius);
		position.x = Mathf.Clamp(position.x, 0f, xMax);
		float zMax =
			grid.chunkCountZ * HexMetrics.chunkSizeZ *
			(1.5f * HexMetrics.outerRadius);
		position.z = Mathf.Clamp(position.z, 0f, zMax);
		return position;
	}

In der Tat ist dies ein wenig ungenau. Der Startpunkt liegt in der Mitte der Zelle, nicht links. Daher soll die Kamera in der Mitte der Zellen ganz rechts anhalten. Dazu subtrahieren Sie die Hälfte der Zelle vom Maximum von X.

		float xMax =
			(grid.chunkCountX * HexMetrics.chunkSizeX - 0.5f) *
			(2f * HexMetrics.innerRadius);
		position.x = Mathf.Clamp(position.x, 0f, xMax);

Aus dem gleichen Grund müssen wir das Maximum Z reduzieren. Da sich die Metriken geringfügig unterscheiden, müssen wir die gesamte Zelle subtrahieren.

		float zMax =
			(grid.chunkCountZ * HexMetrics.chunkSizeZ - 1) *
			(1.5f * HexMetrics.outerRadius);
		position.z = Mathf.Clamp(position.z, 0f, zMax);

Mit der Bewegung sind wir fertig, nur ein kleines Detail bleibt übrig. Manchmal reagiert die Benutzeroberfläche auf die Pfeiltasten, und dies führt dazu, dass sich der Schieberegler bewegt, wenn Sie die Kamera bewegen. Dies geschieht, wenn die Benutzeroberfläche sich selbst als aktiv ansieht, nachdem Sie darauf geklickt haben und sich der Cursor weiterhin darüber befindet.

Sie können verhindern, dass die Benutzeroberfläche Tastatureingaben empfängt. Dies kann erreicht werden, indem das EventSystem- Objekt angewiesen wird, keine Navigationsereignisse senden auszuführen .


Keine Navigationsereignisse mehr.

Wende dich


Möchten Sie sehen, was sich hinter der Klippe befindet? Es wäre praktisch, die Kamera drehen zu können! Lassen Sie uns diese Funktion hinzufügen.

Die Zoomstufe ist für die Drehung nicht wichtig, nur die Geschwindigkeit ist ausreichend. Fügen Sie eine allgemeine Variable hinzu rotationSpeedund setzen Sie sie auf 180 Grad. Überprüfen Sie das Rotations-Delta-In, Updateindem Sie die Rotationsachse abtasten und gegebenenfalls die Rotation ändern.

	public float rotationSpeed;
	void Update () {
		float zoomDelta = Input.GetAxis("Mouse ScrollWheel");
		if (zoomDelta != 0f) {
			AdjustZoom(zoomDelta);
		}
		float rotationDelta = Input.GetAxis("Rotation");
		if (rotationDelta != 0f) {
			AdjustRotation(rotationDelta);
		}
		float xDelta = Input.GetAxis("Horizontal");
		float zDelta = Input.GetAxis("Vertical");
		if (xDelta != 0f || zDelta != 0f) {
			AdjustPosition(xDelta, zDelta);
		}
	}
	void AdjustRotation (float delta) {
	}



Drehgeschwindigkeit.

Tatsächlich ist die Rotationsachse nicht standardmäßig eingestellt. Wir müssen es selbst schaffen. Gehen Sie zu den Eingabeparametern und duplizieren Sie den obersten Eintrag Vertikal . Ändern Sie den Namen des Duplikats in Rotation, und ändern Sie die Schlüssel in QE und ein Komma (,) mit einem Punkt (.).


Die Eingabe der Drehachse.

Ich habe unitypackage heruntergeladen. Warum habe ich diesen Input nicht?
Eingabemöglichkeiten gelten für das Projekt. Daher sind sie nicht in Unity-Paketen enthalten. Glücklicherweise können Sie sie einfach selbst hinzufügen. Ist dies nicht der Fall, erhalten Sie eine Ausnahme, die eine fehlende Eingabeachse meldet.

Den Drehwinkel werden wir verfolgen und ändern AdjustRotation. Danach drehen wir das gesamte Kamerasystem.

	float rotationAngle;
	void AdjustRotation (float delta) {
		rotationAngle += delta * rotationSpeed * Time.deltaTime;
		transform.localRotation = Quaternion.Euler(0f, rotationAngle, 0f);
	}

Da der Vollkreis 360 Grad beträgt, drehen wir den Drehwinkel so, dass er im Bereich von 0 bis 360 liegt.

	void AdjustRotation (float delta) {
		rotationAngle += delta * rotationSpeed * Time.deltaTime;
		if (rotationAngle < 0f) {
			rotationAngle += 360f;
		}
		else if (rotationAngle >= 360f) {
			rotationAngle -= 360f;
		}
		transform.localRotation = Quaternion.Euler(0f, rotationAngle, 0f);
	}


Schalten Sie in Aktion.

Jetzt funktioniert die Rotation. Wenn Sie es überprüfen, können Sie sehen, dass die Bewegung absolut ist. Daher ist die Bewegung nach einer Drehung um 180 Grad das Gegenteil von dem, was erwartet wurde. Für den Benutzer wäre es viel bequemer, wenn die Bewegung relativ zum Betrachtungswinkel der Kamera ausgeführt wird. Wir können dies tun, indem wir die aktuelle Rotation mit der Bewegungsrichtung multiplizieren.

	void AdjustPosition (float xDelta, float zDelta) {
		Vector3 direction =
			transform.localRotation *
			new Vector3(xDelta, 0f, zDelta).normalized;
		…
	}


Relative Verschiebung.

Einheitspaket

Erweiterte Bearbeitung


Jetzt, da wir eine größere Karte haben, können Sie die Kartenbearbeitungswerkzeuge verbessern. Das Ändern einer Zelle zu einem Zeitpunkt ist zu lang, daher wäre es schön, einen größeren Pinsel zu erstellen. Es ist auch praktisch, wenn Sie malen oder die Höhe ändern und alles andere unverändert lassen möchten.

Optionale Farbe und Höhe


Wir können Farben optional machen, indem wir der Umschaltgruppe eine leere Auswahl hinzufügen. Duplizieren Sie einen der Farbschalter und ersetzen Sie dessen Beschriftung durch --- oder ähnliches, um anzuzeigen, dass es sich nicht um eine Farbe handelt. Ändern Sie dann das Argument des Ereignisses On Value Changed in −1.


Ungültiger Farbindex.

Dieser Index gilt natürlich nicht für ein Array von Farben. Wir können damit bestimmen, ob Zellen mit Farbe versehen werden sollen.

	bool applyColor;
	public void SelectColor (int index) {
		applyColor = index >= 0;
		if (applyColor) {
			activeColor = colors[index];
		}
	}
	void EditCell (HexCell cell) {
		if (applyColor) {
			cell.Color = activeColor;
		}
		cell.Elevation = activeElevation;
	}

Die Höhe wird über einen Schieberegler gesteuert, daher können wir ihm keinen Schalter hinzufügen. Stattdessen können wir einen separaten Schalter verwenden, um die Höhenbearbeitung zu aktivieren oder zu deaktivieren. Standardmäßig ist es aktiviert.

	bool applyElevation = true;
	void EditCell (HexCell cell) {
		if (applyColor) {
			cell.Color = activeColor;
		}
		if (applyElevation) {
			cell.Elevation = activeElevation;
		}
	}

Fügen Sie der Benutzeroberfläche einen neuen Höhenschalter hinzu. Außerdem lege ich alles in ein neues Bedienfeld und stelle den Höhenregler horizontal ein, damit die Benutzeroberfläche schöner wird.


Optionale Farbe und Höhe.

Um die Höhe zu aktivieren, benötigen wir eine neue Methode, die wir mit der Benutzeroberfläche verbinden.

	public void SetApplyElevation (bool toggle) {
		applyElevation = toggle;
	}

Stellen Sie durch Anschließen an den Höhenschalter sicher, dass die dynamische Bool-Methode oben in der Methodenliste verwendet wird. In den richtigen Versionen wird im Inspektor kein Häkchen angezeigt.


Wir übermitteln den Status des Höhenschalters.

Jetzt können wir nur die Färbung mit Blumen oder nur die Höhe wählen. Oder wie gewohnt beide Optionen. Wir können uns sogar dafür entscheiden, weder den einen noch den anderen zu ändern, aber bisher ist es für uns nicht besonders nützlich.


Wechseln Sie zwischen Farbe und Höhe.

Warum schaltet sich die Höhe ab, wenn Sie eine Farbe auswählen?
Dies ist der Fall, wenn alle Schalter zu derselben Toggle-Gruppe gehören. Sie haben wahrscheinlich einen der Farbauswahlschalter dupliziert und geändert, aber die Umschaltgruppe nicht entfernt.

Pinselgröße


Fügen Sie eine Ganzzahlvariable brushSizeund eine Methode zum Festlegen der Pinselgröße über die Benutzeroberfläche hinzu, um die Größe des Pinsels zu ändern . Wir werden den Schieberegler verwenden, also müssen wir den Wert erneut von float in int konvertieren.

	int brushSize;
	public void SetBrushSize (float size) {
		brushSize = (int)size;
	}


Schieberegler für die Pinselgröße.

Sie können einen neuen Schieberegler erstellen, indem Sie den Höhenschieber duplizieren. Ändern Sie den Maximalwert in 4 und hängen Sie ihn an die entsprechende Methode an. Ich habe ihm auch einen Tag hinzugefügt.


Einstellungen des Schiebereglers für die Pinselgröße.

Da wir nun mehrere Zellen gleichzeitig bearbeiten können, müssen wir die Methode verwenden EditCells. Diese Methode ruft EditCellalle beteiligten Zellen auf. Die anfangs ausgewählte Zelle wird als Mittelpunkt des Pinsels betrachtet.

	void HandleInput () {
		Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition);
		RaycastHit hit;
		if (Physics.Raycast(inputRay, out hit)) {
			EditCells(hexGrid.GetCell(hit.point));
		}
	}
	void EditCells (HexCell center) {
	}
	void EditCell (HexCell cell) {
		…
	}

Die Größe des Pinsels bestimmt den Radius der Bearbeitung. Bei einem Radius von 0 ist dies nur eine zentrale Zelle. Mit einem Radius von 1 ist dies das Zentrum und seine Nachbarn. Im Umkreis von 2 sind die Nachbarn des Centers und ihre unmittelbaren Nachbarn eingeschaltet. Usw.


Bis zu Radius 3.

Um Zellen zu bearbeiten, müssen Sie sie in einer Schleife umgehen. Zuerst brauchen wir die X- und Z-Koordinaten des Zentrums.

	void EditCells (HexCell center) {
		int centerX = center.coordinates.X;
		int centerZ = center.coordinates.Z;
	}

Wir finden die minimale Z-Koordinate, indem wir den Radius subtrahieren. Also definieren wir die Nulllinie. Ab dieser Linie durchlaufen wir eine Schleife, bis wir die Linie in der Mitte abdecken.

	void EditCells (HexCell center) {
		int centerX = center.coordinates.X;
		int centerZ = center.coordinates.Z;
		for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) {
		}
	}

Die erste Zelle in der unteren Reihe hat die gleiche X-Koordinate wie die mittlere Zelle. Diese Koordinate nimmt mit zunehmender Zeilennummer ab.

Die letzte Zelle hat immer eine X-Koordinate, die der Mittelkoordinate plus Radius entspricht.

Jetzt können wir jede Zeile durchlaufen und die Zellen anhand ihrer Koordinaten ermitteln.

		for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) {
			for (int x = centerX - r; x <= centerX + brushSize; x++) {
				EditCell(hexGrid.GetCell(new HexCoordinates(x, z)));
			}
		}

Wir haben noch keine Methode HexGrid.GetCellmit einem Koordinatenparameter, also erstelle sie. Wandle in die Koordinaten der Verschiebungen um und erhalte die Zelle.

	public HexCell GetCell (HexCoordinates coordinates) {
		int z = coordinates.Z;
		int x = coordinates.X + z / 2;
		return cells[x + z * cellCountX];
	}


Der untere Teil des Pinsels, Größe 2.

Wir bedecken den Rest des Pinsels und führen einen Zyklus von oben nach unten bis zur Mitte durch. In diesem Fall wird die Logik gespiegelt und die zentrale Zeile muss ausgeschlossen werden.

	void EditCells (HexCell center) {
		int centerX = center.coordinates.X;
		int centerZ = center.coordinates.Z;
		for (int r = 0, z = centerZ - brushSize; z <= centerZ; z++, r++) {
			for (int x = centerX - r; x <= centerX + brushSize; x++) {
				EditCell(hexGrid.GetCell(new HexCoordinates(x, z)));
			}
		}
		for (int r = 0, z = centerZ + brushSize; z > centerZ; z--, r++) {
			for (int x = centerX - brushSize; x <= centerX + r; x++) {
				EditCell(hexGrid.GetCell(new HexCoordinates(x, z)));
			}
		}
	}


Der gesamte Pinsel, Größe 2.

Dies funktioniert, es sei denn, unser Pinsel geht über die Ränder des Rasters hinaus. In diesem Fall wird eine Ausnahme für einen Index außerhalb des gültigen Bereichs angezeigt. Um dies zu vermeiden, überprüfen Sie die Grenzen in HexGrid.GetCellund kehren Sie zurück, nullwenn eine nicht vorhandene Zelle angefordert wird.

	public HexCell GetCell (HexCoordinates coordinates) {
		int z = coordinates.Z;
		if (z < 0 || z >= cellCountZ) {
			return null;
		}
		int x = coordinates.X + z / 2;
		if (x < 0 || x >= cellCountX) {
			return null;
		}
		return cells[x + z * cellCountX];
	}

Um eine Null-Referenz-Ausnahme zu vermeiden, HexMapEditormuss vor dem Bearbeiten geprüft werden, ob die Zelle tatsächlich vorhanden ist.

	void EditCell (HexCell cell) {
		if (cell) {
			if (applyColor) {
				cell.Color = activeColor;
			}
			if (applyElevation) {
				cell.Elevation = activeElevation;
			}
		}
	}


Verwendung mehrerer Pinselgrößen.

Sichtbarkeit der Zellenbeschriftung wechseln


In den meisten Fällen müssen wir keine Zelletiketten sehen. Also machen wir sie optional. Da jedes Fragment seine eigene Zeichenfläche steuert, fügen Sie eine Methode ShowUIzu hinzu HexGridChunk. Wenn die Benutzeroberfläche sichtbar sein soll, aktivieren wir die Zeichenfläche. Andernfalls deaktivieren Sie es.

	public void ShowUI (bool visible) {
		gridCanvas.gameObject.SetActive(visible);
	}

Lassen Sie uns die Benutzeroberfläche standardmäßig ausblenden.

	void Awake () {
		gridCanvas = GetComponentInChildren();
		hexMesh = GetComponentInChildren();
		cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ];
		ShowUI(false);
	}

Da das Auftreten von UI - Schalter für die gesamte Karte, fügen Sie eine Methode ShowUIin HexGrid. Es übergibt die Anfrage nur an seine Fragmente.

	public void ShowUI (bool visible) {
		for (int i = 0; i < chunks.Length; i++) {
			chunks[i].ShowUI(visible);
		}
	}

HexMapEditor Ruft dieselbe Methode ab und leitet die Anforderung an das Grid weiter.

	public void ShowUI (bool visible) {
		hexGrid.ShowUI(visible);
	}

Schließlich können wir der Benutzeroberfläche einen Schalter hinzufügen und ihn verbinden.


Tag-Sichtbarkeitsschalter.

Einheitspaket

Teil 6: Flüsse


  • Flüsse zu Zellen hinzufügen.
  • Drag & Drop-Unterstützung für das Zeichnen von Flüssen.
  • Flussbetten schaffen.
  • Verwenden mehrerer Maschen pro Fragment.
  • Erstellen Sie einen gemeinsamen Listenpool.
  • Triangulation und Animation von fließendem Wasser.

Im vorherigen Teil haben wir über die Unterstützung großer Karten gesprochen. Jetzt können wir zu größeren Reliefelementen übergehen. Dieses Mal werden wir über Flüsse sprechen.


Flüsse fließen aus den Bergen.

Flusszellen


Es gibt drei Möglichkeiten, Flüsse zu einem Gitter von Sechsecken hinzuzufügen. Der erste Weg ist, sie von Zelle zu Zelle fließen zu lassen. So wird es in Endless Legend implementiert. Die zweite Möglichkeit besteht darin, sie zwischen den Zellen fließen zu lassen, von Kante zu Kante. So wird es in Civilization 5 implementiert. Der dritte Weg ist, überhaupt keine speziellen Flussstrukturen zu schaffen, sondern Wasserzellen zu verwenden, um sie vorzuschlagen. So werden die Flüsse in Age of Wonders 3 umgesetzt.

In unserem Fall sind die Ränder der Zellen bereits von Hängen und Klippen besetzt. Das lässt wenig Platz für Flüsse. Deshalb werden wir sie von Zelle zu Zelle fließen lassen. Dies bedeutet, dass in jeder Zelle entweder kein Fluss vorhanden ist oder dass ein Fluss entlang fließt oder dass sich ein Anfang oder ein Ende des Flusses darin befindet. In den Zellen, entlang derer der Fluss fließt, kann er geradeaus fließen und einen oder zwei Schritte abbiegen.


Fünf mögliche Flusskonfigurationen.

Wir werden keine Abzweigungen oder Zusammenführungen von Flüssen unterstützen. Dies wird die Dinge noch komplizierter machen, insbesondere den Wasserfluss. Auch große Wassermengen werden uns nicht verwirren. Wir werden sie in einem anderen Tutorial betrachten.

Flussverfolgung


Die Zelle, entlang derer der Fluss fließt, kann gleichzeitig als ein ankommender und abgehender Fluss betrachtet werden. Wenn es den Anfang eines Flusses enthält, dann hat es nur einen ausgehenden Fluss. Und wenn es das Ende des Flusses enthält, dann hat es nur einen ankommenden Fluss. Wir können diese Informationen HexCellmit zwei Booleschen Werten speichern .

	bool hasIncomingRiver, hasOutgoingRiver;

Das reicht aber nicht. Wir müssen auch die Richtung dieser Flüsse kennen. Im Fall eines abfließenden Flusses wird angezeigt, wo er fließt. Im Falle eines ankommenden Flusses wird angezeigt, woher er kam.

	bool hasIncomingRiver, hasOutgoingRiver;
	HexDirection incomingRiver, outgoingRiver;

Diese Informationen werden beim Triangulieren von Zellen benötigt. Fügen Sie also Eigenschaften hinzu, um Zugriff darauf zu erhalten. Wir werden die direkte Zuweisung nicht unterstützen. Dazu werden wir eine separate Methode hinzufügen.

	public bool HasIncomingRiver {
		get {
			return hasIncomingRiver;
		}
	}
	public bool HasOutgoingRiver {
		get {
			return hasOutgoingRiver;
		}
	}
	public HexDirection IncomingRiver {
		get {
			return incomingRiver;
		}
	}
	public HexDirection OutgoingRiver {
		get {
			return outgoingRiver;
		}
	}

Eine wichtige Frage ist, ob sich unabhängig von den Details ein Fluss in der Zelle befindet. Fügen wir daher auch hierfür eine Eigenschaft hinzu.

	public bool HasRiver {
		get {
			return hasIncomingRiver || hasOutgoingRiver;
		}
	}

Eine andere logische Frage: Ist der Anfang oder das Ende des Flusses in der Zelle. Wenn der Zustand des ein- und ausgehenden Flusses unterschiedlich ist, ist dies genau der Fall. Deshalb werden wir dies zu einer weiteren Eigenschaft machen.

	public bool HasRiverBeginOrEnd {
		get {
			return hasIncomingRiver != hasOutgoingRiver;
		}
	}

Und schließlich ist es nützlich zu wissen, ob der Fluss durch einen bestimmten Kamm fließt, ob er ankommt oder abfließt.

	public bool HasRiverThroughEdge (HexDirection direction) {
		return
			hasIncomingRiver && incomingRiver == direction ||
			hasOutgoingRiver && outgoingRiver == direction;
	}

Flussentfernung


Bevor wir einer Zelle einen Fluss hinzufügen, implementieren wir zunächst die Unterstützung für das Entfernen von Flüssen. Zunächst schreiben wir eine Methode, um nur den ausgehenden Teil des Flusses zu entfernen.

Befindet sich kein abgehender Fluss in der Zelle, muss nichts unternommen werden. Andernfalls schalten Sie es aus und führen Sie das Update durch.

	public void RemoveOutgoingRiver () {
		if (!hasOutgoingRiver) {
			return;
		}
		hasOutgoingRiver = false;
		Refresh();
	}

Das ist aber noch nicht alles. Der abgehende Fluss muss irgendwo weiterlaufen. Daher muss es einen Nachbarn mit dem ankommenden Fluss geben. Wir müssen sie auch loswerden.

	public void RemoveOutgoingRiver () {
		if (!hasOutgoingRiver) {
			return;
		}
		hasOutgoingRiver = false;
		Refresh();
		HexCell neighbor = GetNeighbor(outgoingRiver);
		neighbor.hasIncomingRiver = false;
		neighbor.Refresh();
	}

Kann ein Fluss nicht aus einer Karte fließen?
Obwohl es möglich ist, Unterstützung für solche Flüsse zu implementieren, werden wir dies nicht tun. Daher brauchen wir nicht zu prüfen, ob ein Nachbar existiert.

Durch das Entfernen eines Flusses aus einer Zelle wird nur das Erscheinungsbild dieser Zelle geändert. Im Gegensatz zur Bearbeitung von Höhe oder Farbe hat dies keine Auswirkungen auf die Nachbarn. Daher müssen wir nur die Zelle selbst aktualisieren, nicht aber ihre Nachbarn.

	public void RemoveOutgoingRiver () {
		if (!hasOutgoingRiver) {
			return;
		}
		hasOutgoingRiver = false;
		RefreshSelfOnly();
		HexCell neighbor = GetNeighbor(outgoingRiver);
		neighbor.hasIncomingRiver = false;
		neighbor.RefreshSelfOnly();
	}

Diese Methode RefreshSelfOnlyaktualisiert einfach das Fragment, zu dem die Zelle gehört. Da wir den Fluss während der Netzinitialisierung nicht ändern, müssen wir uns keine Sorgen machen, wenn bereits ein Fragment zugewiesen wurde.

	void RefreshSelfOnly () {
		chunk.Refresh();
	}

Das Entfernen ankommender Flüsse funktioniert auf die gleiche Weise.

	public void RemoveIncomingRiver () {
		if (!hasIncomingRiver) {
			return;
		}
		hasIncomingRiver = false;
		RefreshSelfOnly();
		HexCell neighbor = GetNeighbor(incomingRiver);
		neighbor.hasOutgoingRiver = false;
		neighbor.RefreshSelfOnly();
	}

Und das Entfernen des gesamten Flusses bedeutet einfach das Entfernen sowohl des ankommenden als auch des abgehenden Teils des Flusses.

	public void RemoveRiver () {
		RemoveOutgoingRiver();
		RemoveIncomingRiver();
	}

Flüsse hinzufügen


Um die Entstehung von Flüssen zu unterstützen, benötigen wir eine Methode zur Angabe des abfließenden Flusses der Zelle. Er muss alle bisherigen abgehenden Flüsse neu definieren und den entsprechenden ankommenden Fluss setzen.

Für den Anfang brauchen wir nichts zu tun, wenn der Fluss bereits existiert.

	public void SetOutgoingRiver (HexDirection direction) {
		if (hasOutgoingRiver && outgoingRiver == direction) {
			return;
		}
	}

Als nächstes müssen wir sicherstellen, dass es einen Nachbarn in der richtigen Richtung gibt. Darüber hinaus können Flüsse nicht fließen. Daher müssen wir die Operation abschließen, wenn der Nachbar höher ist.

		HexCell neighbor = GetNeighbor(direction);
		if (!neighbor || elevation < neighbor.elevation) {
			return;
		}

Als nächstes müssen wir den vorherigen ausgehenden Fluss räumen. Außerdem müssen wir den ankommenden Fluss entfernen, wenn er einen neuen abgehenden Fluss überlappt.

		RemoveOutgoingRiver();
		if (hasIncomingRiver && incomingRiver == direction) {
			RemoveIncomingRiver();
		}

Jetzt können wir den abfließenden Fluss einrichten.

		hasOutgoingRiver = true;
		outgoingRiver = direction;
		RefreshSelfOnly();

Vergessen Sie nicht, den eingehenden Fluss für eine andere Zelle festzulegen, nachdem Sie den aktuellen eingehenden Fluss entfernt haben, falls vorhanden.

		neighbor.RemoveIncomingRiver();
		neighbor.hasIncomingRiver = true;
		neighbor.incomingRiver = direction.Opposite();
		neighbor.RefreshSelfOnly();

Flüsse fließen auf


Jetzt, da wir es möglich gemacht haben, nur die richtigen Flüsse hinzuzufügen, können andere Aktionen immer noch die falschen erzeugen. Wenn wir die Höhe der Zelle ändern, müssen wir den Fluss erneut zwingen, nur nach unten zu fließen. Alle unregelmäßigen Flüsse müssen entfernt werden.

	public int Elevation {
		get {
			return elevation;
		}
		set {
			…
			if (
				hasOutgoingRiver &&
				elevation < GetNeighbor(outgoingRiver).elevation
			) {
				RemoveOutgoingRiver();
			}
			if (
				hasIncomingRiver &&
				elevation > GetNeighbor(incomingRiver).elevation
			) {
				RemoveIncomingRiver();
			}
			Refresh();
		}
	}

Einheitspaket

Flüsse wechseln


Um das Bearbeiten von Flüssen zu unterstützen, müssen Sie der Benutzeroberfläche einen Flussschalter hinzufügen. Tatsächlich. Wir brauchen Unterstützung für drei Bearbeitungsmodi. Wir müssen die Flüsse entweder ignorieren, hinzufügen oder löschen. Wir können eine einfache Helfer-Aufzählung von Schaltern verwenden, um den Status zu verfolgen. Da wir es nur innerhalb des Editors verwenden, können wir es HexMapEditorzusammen mit dem Feld für den Flussmodus innerhalb der Klasse definieren .

	enum OptionalToggle {
		Ignore, Yes, No
	}
	OptionalToggle riverMode;

Und wir brauchen eine Methode, um das Flussregime über die Benutzeroberfläche zu ändern.

	public void SetRiverMode (int mode) {
		riverMode = (OptionalToggle)mode;
	}

Um das Flussregime zu steuern, fügen Sie der Benutzeroberfläche drei Schalter hinzu und verbinden Sie sie mit der neuen Toggle-Gruppe, wie wir es mit den Farben gemacht haben. Ich habe die Schalter so konfiguriert, dass sich ihre Beschriftungen unter den Kontrollkästchen befinden. Aus diesem Grund bleiben sie dünn genug, um alle drei Optionen in einer Zeile unterzubringen.


UI Flüsse

Warum nicht eine Dropdown-Liste verwenden?
Wenn Sie möchten, können Sie die Dropdown-Liste verwenden. Leider kann die Dropdown-Liste in Unity die Neukompilierung im Wiedergabemodus nicht verarbeiten. Das Listenelement geht verloren und ist nach der Neukompilierung unbrauchbar.

Drag & Drop-Erkennung


Um einen Fluss zu erschaffen, brauchen wir sowohl eine Zelle als auch eine Richtung. Im Moment HexMapEditorstellt uns diese Information nicht zur Verfügung. Daher müssen wir die Drag & Drop-Unterstützung von einer Zelle zur nächsten hinzufügen.

Wir müssen wissen, ob dieser Widerstand korrekt ist, und auch seine Richtung bestimmen. Und um Drag & Drop zu erkennen, müssen wir uns an die vorherige Zelle erinnern.

	bool isDrag;
	HexDirection dragDirection;
	HexCell previousCell;

Wenn das Ziehen nicht ausgeführt wird, ist die vorherige Zelle zunächst nicht vorhanden. Das heißt, wenn es keine Eingabe gibt oder wir nicht mit der Karte interagieren, müssen Sie ihr einen Wert zuweisen null.

	void Update () {
		if (
			Input.GetMouseButton(0) &&
			!EventSystem.current.IsPointerOverGameObject()
		) {
			HandleInput();
		}
		else {
			previousCell = null;
		}
	}
	void HandleInput () {
		Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition);
		RaycastHit hit;
		if (Physics.Raycast(inputRay, out hit)) {
			EditCells(hexGrid.GetCell(hit.point));
		}
		else {
			previousCell = null;
		}
	}

Die aktuelle Zelle haben wir gefunden, indem wir den Strahl mit dem Netz gekreuzt haben. Nach dem Bearbeiten der Zellen wird diese aktualisiert und wird zur vorherigen Zelle für eine neue Aktualisierung.

	void HandleInput () {
		Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition);
		RaycastHit hit;
		if (Physics.Raycast(inputRay, out hit)) {
			HexCell currentCell = hexGrid.GetCell(hit.point);
			EditCells(currentCell);
			previousCell = currentCell;
		}
		else {
			previousCell = null;
		}
	}

Nachdem wir die aktuelle Zelle ermittelt haben, können wir sie mit der vorherigen Zelle vergleichen, falls vorhanden. Wenn wir zwei verschiedene Zellen erhalten, haben wir möglicherweise das richtige Drag & Drop und müssen dies überprüfen. Ansonsten ist es definitiv kein Widerstand.

		if (Physics.Raycast(inputRay, out hit)) {
			HexCell currentCell = hexGrid.GetCell(hit.point);
			if (previousCell && previousCell != currentCell) {
				ValidateDrag(currentCell);
			}
			else {
				isDrag = false;
			}
			EditCells(currentCell);
			previousCell = currentCell;
			isDrag = true;
		}

Wie überprüfen wir Drag & Drop? Überprüfen, ob die aktuelle Zelle ein Nachbar der vorherigen Zelle ist. Wir überprüfen dies, indem wir seine Nachbarn in einem Zyklus umgehen. Wenn wir eine Übereinstimmung finden, erkennen wir auch sofort die Richtung des Widerstands.

	void ValidateDrag (HexCell currentCell) {
		for (
			dragDirection = HexDirection.NE;
			dragDirection <= HexDirection.NW;
			dragDirection++
		) {
			if (previousCell.GetNeighbor(dragDirection) == currentCell) {
				isDrag = true;
				return;
			}
		}
		isDrag = false;
	}

Werden wir ruckartige Schleppen erzeugen?
Wenn wir den Cursor an den Rändern einer Zelle entlang bewegen, kann er dadurch schnell zwischen diesen beiden Zellen oszillieren. Dies kann tatsächlich zu zitternden Zügen führen, aber nicht alles ist so schlimm.

Wir können dies vermeiden, indem wir uns an das vorherige Ziehen und Ablegen erinnern. Aus diesem Grund wird das nächste Ziehen nicht sofort in die entgegengesetzte Richtung erfolgen.

Zellen wechseln


Jetzt, da wir Drag & Drop erkennen können, können wir abgehende Flüsse definieren. Wir können auch Flüsse entfernen, da diese Unterstützung für Drag & Drop nicht erforderlich ist.

	void EditCell (HexCell cell) {
		if (cell) {
			if (applyColor) {
				cell.Color = activeColor;
			}
			if (applyElevation) {
				cell.Elevation = activeElevation;
			}
			if (riverMode == OptionalToggle.No) {
				cell.RemoveRiver();
			}
			else if (isDrag && riverMode == OptionalToggle.Yes) {
				previousCell.SetOutgoingRiver(dragDirection);
			}
		}
	}

Dieser Code zeichnet den Fluss von der vorherigen Zelle zur aktuellen. Aber er ignoriert die Größe des Pinsels. Das ist ganz logisch, aber lassen Sie uns die Flüsse für alle von der Bürste geschlossenen Zellen zeichnen. Dies kann durch Ausführen von Vorgängen an der bearbeiteten Zelle erfolgen. In unserem Fall müssen wir sicherstellen, dass wirklich eine andere Zelle existiert.

			else if (isDrag && riverMode == OptionalToggle.Yes) {
				HexCell otherCell = cell.GetNeighbor(dragDirection.Opposite());
				if (otherCell) {
					otherCell.SetOutgoingRiver(dragDirection);
				}
			}

Jetzt können wir die Flüsse bearbeiten, aber wir sehen sie noch nicht. Wir können überprüfen, ob dies funktioniert, indem wir die geänderten Zellen im Debug-Inspektor untersuchen.


Eine Zelle mit einem Fluss im Debug-Inspektor.

Was ist ein Debug-Inspektor?
Wir können über das Tab-Menü zwischen dem normalen und dem Debug-Modus des Inspektors wechseln. Es wird mit einem Symbol in der oberen rechten Ecke der Registerkarte geöffnet. Im Debug-Modus zeigt der Inspector Rohdaten an.

Einheitspaket

Flussbetten zwischen Zellen


Bei der Triangulation eines Flusses müssen zwei Teile berücksichtigt werden: die Lage des Flussbetts und das durchfließende Wasser. Zuerst werden wir einen Kanal erstellen und das Wasser für später verlassen.

Der einfachste Teil des Flusses fließt in Übergängen zwischen Zellen. Derzeit triangulieren wir diesen Bereich mit einem Streifen aus drei Quadraten. Wir können ein Flussbett hinzufügen, indem wir das mittlere Quad absenken und zwei Kanalwände hinzufügen.


Hinzufügen eines Flusses zu einem Rippenstreifen.

Hierfür werden im Falle des Flusses zwei zusätzliche Quads benötigt und ein Kanal mit zwei senkrechten Wänden geschaffen. Ein alternativer Ansatz ist die Verwendung von vier Quadraten. Dann senken wir den mittleren Gipfel ab, um ein Bett mit schrägen Wänden zu schaffen.


Immer vier Quad.

Die konstante Verwendung der gleichen Anzahl von Vierecken ist praktisch. Wählen Sie diese Option.

Randoberteile hinzufügen


Der Übergang von drei zu vier pro Kante erfordert die Erstellung eines zusätzlichen Scheitelpunkts der Kante. Rewrite EdgeVertices, erste Umbenennung v4in v5, und dann umbenannt v3zu v4. Aktionen in dieser Reihenfolge stellen sicher, dass der gesamte Code weiterhin auf die richtigen Scheitelpunkte verweist. Verwenden Sie dazu die Option zum Umbenennen oder Umgestalten Ihres Editors, damit die Änderungen überall angewendet werden. Andernfalls müssen Sie den gesamten Code manuell überprüfen und Änderungen vornehmen.

	public Vector3 v1, v2, v4, v5;

Fügen Sie nach dem Umbenennen einen neuen hinzu v3.

	public Vector3 v1, v2, v3, v4, v5;

Fügen Sie dem Konstruktor einen neuen Scheitelpunkt hinzu. Es befindet sich in der Mitte zwischen den Eckgipfeln. Außerdem sollten andere Eckpunkte jetzt in ½ und ¾ und nicht in ⅓ und ⅔ sein.

	public EdgeVertices (Vector3 corner1, Vector3 corner2) {
		v1 = corner1;
		v2 = Vector3.Lerp(corner1, corner2, 0.25f);
		v3 = Vector3.Lerp(corner1, corner2, 0.5f);
		v4 = Vector3.Lerp(corner1, corner2, 0.75f);
		v5 = corner2;
	}

Hinzufügen v3und in TerraceLerp.

	public static EdgeVertices TerraceLerp (
		EdgeVertices a, EdgeVertices b, int step)
	{
		EdgeVertices result;
		result.v1 = HexMetrics.TerraceLerp(a.v1, b.v1, step);
		result.v2 = HexMetrics.TerraceLerp(a.v2, b.v2, step);
		result.v3 = HexMetrics.TerraceLerp(a.v3, b.v3, step);
		result.v4 = HexMetrics.TerraceLerp(a.v4, b.v4, step);
		result.v5 = HexMetrics.TerraceLerp(a.v5, b.v5, step);
		return result;
	}

Jetzt HexMeshmuss ich einen zusätzlichen Eckpunkt in die Fächer der Rippendreiecke einfügen.

	void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) {
		AddTriangle(center, edge.v1, edge.v2);
		AddTriangleColor(color);
		AddTriangle(center, edge.v2, edge.v3);
		AddTriangleColor(color);
		AddTriangle(center, edge.v3, edge.v4);
		AddTriangleColor(color);
		AddTriangle(center, edge.v4, edge.v5);
		AddTriangleColor(color);
	}

Und auch in seinen Streifen von Vierecken.

	void TriangulateEdgeStrip (
		EdgeVertices e1, Color c1,
		EdgeVertices e2, Color c2
	) {
		AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
		AddQuadColor(c1, c2);
		AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
		AddQuadColor(c1, c2);
		AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
		AddQuadColor(c1, c2);
		AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);
		AddQuadColor(c1, c2);
	}



Vergleich von vier und fünf Eckpunkten pro Kante.

Die Höhe des Flussbetts


Wir haben den Kanal erstellt, indem wir die untere Oberseite der Rippe abgesenkt haben. Sie bestimmt die vertikale Position des Flussbetts. Obwohl die genaue vertikale Position jeder Zelle verzerrt ist, müssen wir die gleiche Höhe des Flussbetts in Zellen mit der gleichen Höhe beibehalten. Dank dieses Wassers muss es nicht stromaufwärts fließen. Darüber hinaus sollte das Bett niedrig genug sein, um auch bei stark abweichenden vertikalen Zellen unter dem Bett zu bleiben und gleichzeitig genügend Platz für Wasser zu lassen.

Setzen wir diesen Versatz auf HexMetricsund drücken ihn als Höhe aus. Offsets von einer Ebene werden ausreichen.

	public const float streamBedElevationOffset = -1f;

Mit dieser Metrik können Eigenschaften hinzugefügt werden HexCell, um die vertikale Position des Zellflussbettes zu ermitteln.

	public float StreamBedY {
		get {
			return
				(elevation + HexMetrics.streamBedElevationOffset) *
				HexMetrics.elevationStep;
		}
	}

Kanal erstellen


Wenn HexMesheiner der sechs dreieckigen Teile einer Zelle trianguliert ist, können wir bestimmen, ob ein Fluss entlang seiner Kante fließt. Wenn ja, können wir die mittlere Spitze der Rippe auf die Höhe des Flussbetts absenken.

	void Triangulate (HexDirection direction, HexCell cell) {
		Vector3 center = cell.Position;
		EdgeVertices e = new EdgeVertices(
			center + HexMetrics.GetFirstSolidCorner(direction),
			center + HexMetrics.GetSecondSolidCorner(direction)
		);
		if (cell.HasRiverThroughEdge(direction)) {
			e.v3.y = cell.StreamBedY;
		}
		TriangulateEdgeFan(center, e, cell.Color);
		if (direction <= HexDirection.SE) {
			TriangulateConnection(direction, cell, e);
		}
	}


Ändern Sie den mittleren Scheitelpunkt der Rippe.

Wir können sehen, wie die ersten Anzeichen des Flusses erscheinen, aber Löcher im Relief entstehen. Um sie zu schließen, müssen wir eine andere Kante ändern und dann die Verbindung triangulieren.

	void TriangulateConnection (
		HexDirection direction, HexCell cell, EdgeVertices e1
	) {
		HexCell neighbor = cell.GetNeighbor(direction);
		if (neighbor == null) {
			return;
		}
		Vector3 bridge = HexMetrics.GetBridge(direction);
		bridge.y = neighbor.Position.y - cell.Position.y;
		EdgeVertices e2 = new EdgeVertices(
			e1.v1 + bridge,
			e1.v5 + bridge
		);
		if (cell.HasRiverThroughEdge(direction)) {
			e2.v3.y = neighbor.StreamBedY;
		}
		…
	}


Fertige Kanäle von Rippenverbindungen.

Einheitspaket

Flussbetten, die durch die Zelle fließen


Jetzt haben wir die richtigen Flussbetten zwischen den Zellen. Aber wenn der Fluss durch die Zelle fließt, enden die Kanäle immer in ihrer Mitte. Um dieses Problem zu lösen, muss es funktionieren. Beginnen wir mit dem Fall, dass ein Fluss direkt von einer Kante zur anderen durch eine Zelle fließt.

Wenn es keinen Fluss gibt, kann jeder Teil der Zelle ein einfacher Fan von Dreiecken sein. Wenn der Fluss jedoch direkt fließt, muss ein Kanal eingefügt werden. Tatsächlich müssen wir den zentralen Scheitelpunkt in eine Linie strecken und so die beiden mittleren Dreiecke in Vierecke verwandeln. Dann verwandelt sich der Fächer der Dreiecke in ein Trapez.


Wir fügen den Kanal in das Dreieck ein.

Solche Kanäle werden viel länger sein als diejenigen, die durch die Verbindung von Zellen verlaufen. Dies wird deutlich, wenn die Scheitelpunktpositionen verzerrt sind. Teilen wir daher das Trapez in zwei Segmente, indem wir einen weiteren Satz von Scheitelpunktkanten in der Mitte zwischen der Mitte und der Kante einfügen.


Kanal-Triangulation.

Da sich die Triangulation mit einem Fluss stark von der Triangulation ohne Fluss unterscheidet, erstellen wir eine separate Methode. Wenn wir einen Fluss haben, verwenden wir diese Methode, andernfalls hinterlassen wir einen Fächer aus Dreiecken.

	void Triangulate (HexDirection direction, HexCell cell) {
		Vector3 center = cell.Position;
		EdgeVertices e = new EdgeVertices(
			center + HexMetrics.GetFirstSolidCorner(direction),
			center + HexMetrics.GetSecondSolidCorner(direction)
		);
		if (cell.HasRiver) {
			if (cell.HasRiverThroughEdge(direction)) {
				e.v3.y = cell.StreamBedY;
				TriangulateWithRiver(direction, cell, center, e);
			}
		}
		else {
			TriangulateEdgeFan(center, e, cell.Color);
		}
		if (direction <= HexDirection.SE) {
			TriangulateConnection(direction, cell, e);
		}
	}
	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
	}


Löcher, in denen es Flüsse geben sollte.

Deaktivieren Sie vorübergehend die Zellenverzerrung, um besser zu sehen, was passiert.

	public const float cellPerturbStrength = 0f; // 4f;


Unverzerrte Spitzen

Triangulation direkt durch die Zelle


Um einen Kanal direkt durch einen Teil der Zelle zu erzeugen, müssen wir das Zentrum in eine Linie strecken. Diese Linie sollte die gleiche Breite wie der Kanal haben. Wir können den linken Scheitelpunkt finden, indem wir ¼ des Abstands von der Mitte zur ersten Ecke des vorherigen Teils bewegen.

	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		Vector3 centerL = center +
			HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f;
	}

Ähnliches gilt für den rechten Eckpunkt. In diesem Fall brauchen wir die zweite Ecke des nächsten Teils.

		Vector3 centerL = center +
			HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f;
		Vector3 centerR = center +
			HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f;

Die Mittellinie kann gefunden werden, indem Scheitelpunktkanten zwischen der Mitte und der Kante erstellt werden.

		EdgeVertices m = new EdgeVertices(
			Vector3.Lerp(centerL, e.v1, 0.5f),
			Vector3.Lerp(centerR, e.v5, 0.5f)
		);

Ändern Sie als Nächstes den mittleren Scheitelpunkt der Mittelrippe sowie die Mitte, da diese die unteren Punkte des Kanals werden.

		m.v3.y = center.y = e.v3.y;

Jetzt können wir TriangulateEdgeStripden Raum zwischen der Mittellinie und der Kantenlinie ausfüllen.

		TriangulateEdgeStrip(m, cell.Color, e, cell.Color);


Komprimierte Kanäle.

Leider sehen die Kanäle komprimiert aus. Dies geschieht, weil die mittleren Scheitelpunkte der Rippe zu nahe beieinander liegen. Warum ist das passiert?

Wenn wir annehmen, dass die Länge der Außenkante 1 ist, beträgt die Länge der Mittellinie 1/2. Da sich die Mittelkante in der Mitte dazwischen befindet, sollte ihre Länge gleich ¾ sein.

Die Kanalbreite beträgt ½ und sollte konstant bleiben. Da die Länge der Mittelrippe gleich ¾ ist, verbleibt nur ¼ entlang ⅛ auf beiden Seiten des Kanals.


Relative Längen.

Da die Länge der Mittelkante ¾ ist, wird ⅛ in Bezug auf die Länge der Mittelkante относительно. Dies bedeutet, dass die zweiten und vierten Eckpunkte mit Sechsteln und nicht mit Vierteln interpoliert werden sollten.

Wir können eine solche alternative Interpolation unterstützen, indem wir sie einem EdgeVerticesanderen Konstruktor hinzufügen . Anstelle von festen Interpolationen für v2und verwenden v4wir einen Parameter.

	public EdgeVertices (Vector3 corner1, Vector3 corner2, float outerStep) {
		v1 = corner1;
		v2 = Vector3.Lerp(corner1, corner2, outerStep);
		v3 = Vector3.Lerp(corner1, corner2, 0.5f);
		v4 = Vector3.Lerp(corner1, corner2, 1f - outerStep);
		v5 = corner2;
	}

Jetzt können wir es zusammen mit ⅙ in verwenden HexMesh.TriangulateWithRiver.

		EdgeVertices m = new EdgeVertices(
			Vector3.Lerp(centerL, e.v1, 0.5f),
			Vector3.Lerp(centerR, e.v5, 0.5f),
			1f / 6f
		);


Direkte Kanäle.

Nachdem wir den Kanal gerade gemacht haben, können wir zum zweiten Teil des Trapezes gehen. In diesem Fall können wir den Rippenstreifen nicht verwenden, daher müssen wir ihn manuell ausführen. Lassen Sie uns zuerst Dreiecke an den Seiten erstellen.

		AddTriangle(centerL, m.v1, m.v2);
		AddTriangleColor(cell.Color);
		AddTriangle(centerR, m.v4, m.v5);
		AddTriangleColor(cell.Color);


Seitliche Dreiecke.

Es sieht gut aus, also füllen wir den verbleibenden Raum mit zwei Vierecken, um den letzten Teil des Kanals zu erstellen.

		AddTriangle(centerL, m.v1, m.v2);
		AddTriangleColor(cell.Color);
		AddQuad(centerL, center, m.v2, m.v3);
		AddQuadColor(cell.Color);
		AddQuad(center, centerR, m.v3, m.v4);
		AddQuadColor(cell.Color);
		AddTriangle(centerR, m.v4, m.v5);
		AddTriangleColor(cell.Color);

Tatsächlich gibt es keine Alternative, für AddQuadColordie nur ein Parameter erforderlich ist. Wir haben es zwar nicht gebraucht. Also lasst es uns schaffen.

	void AddQuadColor (Color color) {
		colors.Add(color);
		colors.Add(color);
		colors.Add(color);
		colors.Add(color);
	}


Gerade Kanäle abgeschlossen.

Triangulation starten und beenden


Die Triangulation eines Teils, der nur den Anfang oder das Ende eines Flusses hat, ist ganz anders und erfordert daher eine eigene Methode. Aus diesem Grund werden wir dies einchecken Triangulateund die entsprechende Methode aufrufen.

		if (cell.HasRiver) {
			if (cell.HasRiverThroughEdge(direction)) {
				e.v3.y = cell.StreamBedY;
				if (cell.HasRiverBeginOrEnd) {
					TriangulateWithRiverBeginOrEnd(direction, cell, center, e);
				}
				else {
					TriangulateWithRiver(direction, cell, center, e);
				}
			}
		}

In diesem Fall möchten wir den Kanal in der Mitte vervollständigen, verwenden jedoch weiterhin zwei Stufen. Daher erstellen wir erneut die mittlere Kante zwischen der Mitte oder der Kante. Da wir den Kanal vervollständigen wollen, sind wir sehr froh, dass er komprimiert wird.

	void TriangulateWithRiverBeginOrEnd (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		EdgeVertices m = new EdgeVertices(
			Vector3.Lerp(center, e.v1, 0.5f),
			Vector3.Lerp(center, e.v5, 0.5f)
		);
	}

Damit der Kanal nicht zu schnell flach wird, ordnen wir die Höhe des Flussbetts dem Mittelgipfel zu. Das Zentrum muss aber nicht gewechselt werden.

		m.v3.y = e.v3.y;

Wir können mit einem Rippenstreifen und einem Fächer triangulieren.

		TriangulateEdgeStrip(m, cell.Color, e, cell.Color);
		TriangulateEdgeFan(center, m, cell.Color);


Start- und Endpunkte.

One-Step-Turns


Betrachten Sie als Nächstes die scharfen Kurven, die zwischen benachbarten Zellen im Zickzack verlaufen. Wir werden auch damit umgehen TriangulateWithRiver. Daher müssen wir bestimmen, mit welchem ​​Flusstyp wir arbeiten.


Zick-Zack-Fluss.

Wenn die Zelle einen Fluss hat, der sowohl in die entgegengesetzte Richtung als auch in die Richtung fließt, mit der wir arbeiten, sollte dies ein gerader Fluss sein. In diesem Fall können wir die bereits berechnete Mittellinie speichern. Andernfalls kehrt es zu einem Punkt zurück und faltet die Mittellinie.

		Vector3 centerL, centerR;
		if (cell.HasRiverThroughEdge(direction.Opposite())) {
			centerL = center +
				HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f;
			centerR = center +
				HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f;
		}
		else {
			centerL = centerR = center;
		}


Gekräuselte Zickzacke.

Wir können scharfe Kurven erkennen, indem wir prüfen, ob in der Zelle ein Fluss durch den nächsten oder vorherigen Teil der Zelle fließt. Wenn ja, müssen wir die Mittellinie an der Kante zwischen diesem und dem benachbarten Teil ausrichten. Wir können dies tun, indem wir die entsprechende Seite der Linie in der Mitte zwischen dem Mittelpunkt und dem gemeinsamen Winkel platzieren. Die andere Seite der Linie wird dann zur Mitte.

		if (cell.HasRiverThroughEdge(direction.Opposite())) {
			centerL = center +
				HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f;
			centerR = center +
				HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f;
		}
		else if (cell.HasRiverThroughEdge(direction.Next())) {
			centerL = center;
			centerR = Vector3.Lerp(center, e.v5, 0.5f);
		}
		else if (cell.HasRiverThroughEdge(direction.Previous())) {
			centerL = Vector3.Lerp(center, e.v1, 0.5f);
			centerR = center;
		}
		else {
			centerL = centerR = center;
		}

Nachdem wir entschieden haben, wo sich der linke und der rechte Punkt befinden, können wir den resultierenden Mittelpunkt bestimmen, indem wir sie mitteln.

		if (cell.HasRiverThroughEdge(direction.Opposite())) {
			…
		}		
		center = Vector3.Lerp(centerL, centerR, 0.5f);


Versetzte Mittelrippe.

Obwohl der Kanal auf beiden Seiten die gleiche Breite hat, sieht er ziemlich komprimiert aus. Dies wird durch Drehen der Mittellinie um 60 ° verursacht. Sie können diesen Effekt glätten, indem Sie die Breite der Mittellinie etwas vergrößern. Anstatt mit ½ zu interpolieren, verwenden wir ⅔.

		else if (cell.HasRiverThroughEdge(direction.Next())) {
			centerL = center;
			centerR = Vector3.Lerp(center, e.v5, 2f / 3f);
		}
		else if (cell.HasRiverThroughEdge(direction.Previous())) {
			centerL = Vector3.Lerp(center, e.v1, 2f / 3f);
			centerR = center;
		}


Zickzack ohne Kompression.

Zweistufige Kurven


Die restlichen Fälle liegen zwischen Zickzack und geraden Flüssen. Dies sind zweistufige Kurven, die sanft geschwungene Flüsse erzeugen.


Ein sich schlängelnder Fluss.

Um zwischen zwei möglichen Ausrichtungen zu unterscheiden, müssen wir verwenden direction.Next().Next(). Aber lassen Sie uns machen es bequemer durch Hinzufügen von HexDirectionErweiterungsmethoden Next2und Previous2.

	public static HexDirection Previous2 (this HexDirection direction) {
		direction -= 2;
		return direction >= HexDirection.NE ? direction : (direction + 6);
	}
	public static HexDirection Next2 (this HexDirection direction) {
		direction += 2;
		return direction <= HexDirection.NW ? direction : (direction - 6);
	}

Zurück zu HexMesh.TriangulateWithRiver. Jetzt können wir die Richtung unseres mäandrierenden Flusses mit erkennen direction.Next2().

		if (cell.HasRiverThroughEdge(direction.Opposite())) {
			centerL = center +
				HexMetrics.GetFirstSolidCorner(direction.Previous()) * 0.25f;
			centerR = center +
				HexMetrics.GetSecondSolidCorner(direction.Next()) * 0.25f;
		}
		else if (cell.HasRiverThroughEdge(direction.Next())) {
			centerL = center;
			centerR = Vector3.Lerp(center, e.v5, 2f / 3f);
		}
		else if (cell.HasRiverThroughEdge(direction.Previous())) {
			centerL = Vector3.Lerp(center, e.v1, 2f / 3f);
			centerR = center;
		}
		else if (cell.HasRiverThroughEdge(direction.Next2())) {
			centerL = centerR = center;
		}
		else {
			centerL = centerR = center;
		}

In den letzten beiden Fällen müssen wir die Mittellinie auf den Teil der Zelle verschieben, der sich auf der Innenseite der Kurve befindet. Wenn wir einen Vektor in der Mitte einer durchgezogenen Kante hätten, könnten wir ihn verwenden, um den Endpunkt zu positionieren. Stellen wir uns vor, wir haben eine Methode dafür.

		else if (cell.HasRiverThroughEdge(direction.Next2())) {
			centerL = center;
			centerR = center +
				HexMetrics.GetSolidEdgeMiddle(direction.Next()) * 0.5f;
		}
		else {
			centerL = center +
				HexMetrics.GetSolidEdgeMiddle(direction.Previous()) * 0.5f;
			centerR = center;
		}

Jetzt natürlich müssen wir diese Methode hinzufügen HexMetrics. Er muss nur zwei Vektoren benachbarter Winkel mitteln und den Integritätskoeffizienten anwenden.

	public static Vector3 GetSolidEdgeMiddle (HexDirection direction) {
		return
			(corners[(int)direction] + corners[(int)direction + 1]) *
			(0.5f * solidFactor);
	}


Leicht komprimierte Kurven.

Unsere Mittellinien sind jetzt korrekt um 30 ° gedreht. Sie sind aber nicht lang genug, weshalb die Kanäle etwas komprimiert sind. Dies geschieht, weil der Mittelpunkt der Rippe näher am Zentrum liegt als der Winkel der Rippe. Sein Abstand entspricht dem inneren Radius, nicht dem äußeren. Das heißt, wir arbeiten im falschen Maßstab.

Wir konvertieren bereits vom externen zum internen Radius um HexMetrics. Wir müssen die umgekehrte Operation durchführen. Stellen wir daher beide Umrechnungsfaktoren durch zur Verfügung HexMetrics.

	public const float outerToInner = 0.866025404f;
	public const float innerToOuter = 1f / outerToInner;
	public const float outerRadius = 10f;
	public const float innerRadius = outerRadius * outerToInner;

Jetzt können wir zur richtigen Skala übergehen HexMesh.TriangulateWithRiver. Die Kanäle bleiben aufgrund ihrer Wendung noch etwas eingeengt, dies ist jedoch viel weniger ausgeprägt als bei Zick-Zack-Kanälen. Daher müssen wir dies nicht kompensieren.

		else if (cell.HasRiverThroughEdge(direction.Next2())) {
			centerL = center;
			centerR = center +
				HexMetrics.GetSolidEdgeMiddle(direction.Next()) *
				(0.5f * HexMetrics.innerToOuter);
		}
		else {
			centerL = center +
				HexMetrics.GetSolidEdgeMiddle(direction.Previous()) *
				(0.5f * HexMetrics.innerToOuter);
			centerR = center;
		}


Glatte Kurven.

Einheitspaket

Triangulation in der Nähe von Flüssen


Unsere Flüsse sind fertig. Aber wir haben noch keine anderen Teile der Zellen trianguliert, die die Flüsse enthalten. Jetzt werden wir diese Löcher schließen.


Löcher in der Nähe der Kanäle.

Wenn die Zelle einen Fluss hat, aber nicht in die aktuelle Richtung fließt, Triangulaterufen wir eine neue Methode in auf.

		if (cell.HasRiver) {
			if (cell.HasRiverThroughEdge(direction)) {
				e.v3.y = cell.StreamBedY;
				if (cell.HasRiverBeginOrEnd) {
					TriangulateWithRiverBeginOrEnd(direction, cell, center, e);
				}
				else {
					TriangulateWithRiver(direction, cell, center, e);
				}
			}
			else {
				TriangulateAdjacentToRiver(direction, cell, center, e);
			}
		}
		else {
			TriangulateEdgeFan(center, e, cell.Color);
		}

Bei dieser Methode füllen wir das Zelldreieck mit einem Streifen und einem Fächer. Nur ein Ventilator wird uns nicht ausreichen, denn die Spitzen sollten der Mittelkante der Teile entsprechen, in denen sich der Fluss befindet.

	void TriangulateAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		EdgeVertices m = new EdgeVertices(
			Vector3.Lerp(center, e.v1, 0.5f),
			Vector3.Lerp(center, e.v5, 0.5f)
		);
		TriangulateEdgeStrip(m, cell.Color, e, cell.Color);
		TriangulateEdgeFan(center, m, cell.Color);
	}


Überlagerung in Kurven und geraden Flüssen.

Passen Sie den Kanal an


Natürlich müssen wir das Zentrum, das wir verwenden, an den zentralen Teil anpassen, der von den Flussabschnitten verwendet wird. Mit Zickzack ist alles in Ordnung und Kurven und gerade Flüsse erfordern Aufmerksamkeit. Daher müssen wir sowohl die Art des Flusses als auch seine relative Ausrichtung bestimmen.

Beginnen wir mit der Überprüfung, ob wir uns innerhalb der Kurve befinden. In diesem Fall enthalten sowohl die vorherige als auch die nächste Richtung den Fluss. Wenn ja, müssen wir die Mitte an den Rand verschieben.

		if (cell.HasRiverThroughEdge(direction.Next())) {
			if (cell.HasRiverThroughEdge(direction.Previous())) {
				center += HexMetrics.GetSolidEdgeMiddle(direction) *
					(HexMetrics.innerToOuter * 0.5f);
			}
		}
		EdgeVertices m = new EdgeVertices(
			Vector3.Lerp(center, e.v1, 0.5f),
			Vector3.Lerp(center, e.v5, 0.5f)
		);


Der Fall, dass der Fluss von beiden Seiten fließt, wurde behoben.

Wenn wir einen Fluss in einer anderen Richtung haben, aber nicht in der vorherigen, dann prüfen wir, ob er gerade ist. Wenn ja, verschieben Sie die Mitte in die erste Ecke.

		if (cell.HasRiverThroughEdge(direction.Next())) {
			if (cell.HasRiverThroughEdge(direction.Previous())) {
				center += HexMetrics.GetSolidEdgeMiddle(direction) *
					(HexMetrics.innerToOuter * 0.5f);
			}
			else if (
				cell.HasRiverThroughEdge(direction.Previous2())
			) {
				center += HexMetrics.GetFirstSolidCorner(direction) * 0.25f;
			}
		}


Feste halbe Überlagerung mit einem geraden Fluss.

Also haben wir das Problem mit der Hälfte der Teile an den geraden Flüssen gelöst. Der letzte Fall - wir haben einen Fluss in der vorherigen Richtung und er ist gerade. In diesem Fall müssen Sie die Mitte zur nächsten Ecke verschieben.

		if (cell.HasRiverThroughEdge(direction.Next())) {
			if (cell.HasRiverThroughEdge(direction.Previous())) {
				center += HexMetrics.GetSolidEdgeMiddle(direction) *
					(HexMetrics.innerToOuter * 0.5f);
			}
			else if (
				cell.HasRiverThroughEdge(direction.Previous2())
			) {
				center += HexMetrics.GetFirstSolidCorner(direction) * 0.25f;
			}
		}
		else if (
			cell.HasRiverThroughEdge(direction.Previous()) &&
			cell.HasRiverThroughEdge(direction.Next2())
		) {
			center += HexMetrics.GetSecondSolidCorner(direction) * 0.25f;
		}


Keine Überlagerungen mehr.

Einheitspaket

Verallgemeinerung von HexMesh


Wir haben die Triangulation der Kanäle abgeschlossen. Jetzt können wir sie mit Wasser füllen. Da Wasser sich von Land unterscheidet, müssen wir ein anderes Netz mit unterschiedlichen Scheitelpunktdaten und unterschiedlichem Material verwenden. Es wäre sehr praktisch, wenn wir HexMeshsowohl Sushi als auch Wasser verwenden könnten . Lassen Sie uns verallgemeinern, HexMeshindem wir daraus eine Klasse machen, die sich mit diesen Netzen befasst, unabhängig davon, wofür sie verwendet werden. Wir werden die Aufgabe der Triangulation seiner Zellen übergeben HexGridChunk.

Verschieben der Störungsmethode


Da das Verfahren ist Perturbziemlich allgemein und an verschiedenen Orten verwendet wird, lassen Sie uns bewegen sie HexMetrics. Benennen Sie es zunächst in um HexMetrics.Perturb. Dies ist ein falscher Methodenname, der jedoch den gesamten Code für die ordnungsgemäße Verwendung refaktorisiert. Wenn Ihr Code-Editor über spezielle Funktionen zum Verschieben von Methoden verfügt, verwenden Sie diese.

Wenn Sie die Methode nach innen verschieben HexMetrics, machen Sie sie allgemein und statisch und korrigieren Sie dann ihren Namen.

	public static Vector3 Perturb (Vector3 position) {
		Vector4 sample = SampleNoise(position);
		position.x += (sample.x * 2f - 1f) * cellPerturbStrength;
		position.z += (sample.z * 2f - 1f) * cellPerturbStrength;
		return position;
	}

Triangulationsmethoden verschieben


Bei HexGridChunkder Änderung von Variablen hexMeshin den gemeinsamen Variablen terrain.

	public HexMesh terrain;
//	HexMesh hexMesh;
	void Awake () {
		gridCanvas = GetComponentInChildren();
//		hexMesh = GetComponentInChildren();
		cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ];
		ShowUI(false);
	}

Als nächstes überarbeiten wir alle Methoden Add…aus HexMeshc terrain.Add…. Verschieben Sie dann alle Methoden Triangulate…nach HexGridChunk. Danach können Sie die Namen der Methoden Add…in korrigieren HexMeshund verallgemeinern. Als Ergebnis werden alle komplexen Triangulationsmethoden gefunden HexGridChunk, und einfache Methoden zum Hinzufügen von Daten zum Netz bleiben erhalten HexMesh.

Wir sind noch nicht fertig. Jetzt HexGridChunk.LateUpdatesollte es seine eigene Methode aufrufen Triangulate. Außerdem sollte es keine Zellen mehr als Argument übergeben. Daher kann es Triangulateseinen Parameter verlieren. Und er muss die Reinigung und Anwendung der Maschendaten delegieren HexMesh.

	void LateUpdate () {
		Triangulate();
//		hexMesh.Triangulate(cells);
		enabled = false;
	}
	public void Triangulate () {
		terrain.Clear();
//		hexMesh.Clear();
//		vertices.Clear();
//		colors.Clear();
//		triangles.Clear();
		for (int i = 0; i < cells.Length; i++) {
			Triangulate(cells[i]);
		}
		terrain.Apply();
//		hexMesh.vertices = vertices.ToArray();
//		hexMesh.colors = colors.ToArray();
//		hexMesh.triangles = triangles.ToArray();
//		hexMesh.RecalculateNormals();
//		meshCollider.sharedMesh = hexMesh;
	}

Fügen Sie die erforderlichen Methoden Clearund Applyin HexMesh.

	public void Clear () {
		hexMesh.Clear();
		vertices.Clear();
		colors.Clear();
		triangles.Clear();
	}
	public void Apply () {
		hexMesh.SetVertices(vertices);
		hexMesh.SetColors(colors);
		hexMesh.SetTriangles(triangles, 0);
		hexMesh.RecalculateNormals();
		meshCollider.sharedMesh = hexMesh;
	}

Was ist mit SetVertices, SetColors und SetTriangles?
Diese Methoden wurden der Klasse in jüngster Zeit hinzugefügt Mesh. Mit ihnen können Sie Netzdaten direkt über Listen angeben. Dies bedeutet, dass wir beim Aktualisieren von Maschen keine temporären Arrays mehr erstellen müssen.

Die Methode SetTriangleshat einen zweiten Integer-Parameter, den Mix-Index. Da wir keine Mischungen verwenden, ist es immer Null.

Zuletzt befestigen Sie das untergeordnete Element des Netzes manuell an der vorgefertigten Fragmentfabrik. Dies kann nicht mehr automatisch erfolgen, da dem Netz bald ein zweites untergeordnetes Element hinzugefügt wird. Benennen Sie es in Terrain um, um den Zweck anzugeben.


Weisen Sie ein Relief zu.

Das Umbenennen eines vorgefertigten Kindes funktioniert nicht?
Режим проекта не обновляется при изменении имени дочернего элемента префаба. Можно заставить его обновиться, создав экземпляр префаба. Измените экземпляр, а затем нажмите кнопку Apply, чтобы передать эти изменения и в префаб. Пока это наилучший способ изменения иерархий объектов префабов.

Создание пулов списков


Obwohl wir einiges an Code verschoben haben, sollte unsere Karte immer noch so funktionieren wie zuvor. Das Hinzufügen eines weiteren Netzes zum Fragment ändert dies nicht. Aber wenn wir dies mit der Gegenwart tun HexMesh, können Fehler auftreten.

Das Problem ist, dass wir davon ausgegangen sind, dass wir jeweils nur mit einem Netz arbeiten würden. Auf diese Weise konnten wir statische Listen zum Speichern temporärer Netzdaten verwenden. Nach dem Hinzufügen von Wasser arbeiten wir jedoch gleichzeitig mit zwei Maschen, sodass wir keine statischen Listen mehr verwenden können.

Wir werden jedoch nicht für jede Instanz zu den Listensätzen zurückkehren HexMesh. Stattdessen verwenden wir einen statischen Listenpool. Standardmäßig ist dieses Pooling nicht vorhanden. Beginnen wir also damit, eine gemeinsame Listenpoolklasse selbst zu erstellen.

public static class ListPool {
}

So funktioniert ListPool?
Wir haben bereits verallgemeinerte Listen verwendet, zum Beispiel für eine Liste von ganzen Zahlen. Nach der Klassendeklaration stellen wir fest, dass es sich um eine verallgemeinerte Klasse handelt. Zur Verallgemeinerung können Sie einen beliebigen Namen verwenden, in der Regel jedoch (aus der Wortvorlage).ListListPoolT

Um eine Sammlung von Listen in einem Pool zu speichern, können wir den Stack verwenden. Normalerweise verwende ich keine Listen, weil Unity sie nicht serialisiert, aber in diesem Fall spielt es keine Rolle.

using System.Collections.Generic;
public static class ListPool {
	static Stack> stack = new Stack>();
}

Was bedeutet Stapeln?>?
Dies ist ein Beispiel für integrierte generische Typen. Dies bedeutet, dass wir einen Listenstapel benötigen. Und der Inhalt der Listen hängt vom Pool ab.

Fügen Sie eine allgemeine statische Methode hinzu, um die Liste aus dem Pool abzurufen. Wenn der Stack nicht leer ist, extrahieren wir die Top-Liste und geben diese zurück. Andernfalls erstellen wir eine neue Liste.

	public static List Get () {
		if (stack.Count > 0) {
			return stack.Pop();
		}
		return new List();
	}

Um Listen wiederzuverwenden, müssen Sie sie dem Pool hinzufügen, nachdem Sie die Arbeit mit ihnen beendet haben. ListPoollöscht die Liste und legt sie auf den Stapel.

	public static void Add (List list) {
		list.Clear();
		stack.Push(list);
	}

Jetzt können wir die Pools benutzen HexMesh. Ersetzen Sie statische Listen durch nicht statische private Links. Markieren Sie sie, NonSerializeddamit Unity sie beim erneuten Kompilieren nicht beibehält. Oder schreiben System.NonSerializedoder using System;am Anfang des Skripts hinzufügen .

	[NonSerialized] List vertices;
	[NonSerialized] List colors;
	[NonSerialized] List triangles;
//	static List vertices = new List();
//	static List colors = new List();
//	static List triangles = new List();

Da das Netz unmittelbar vor dem Hinzufügen neuer Daten bereinigt wird, müssen Sie hier Listen aus Pools abrufen.

	public void Clear () {
		hexMesh.Clear();
		vertices = ListPool.Get();
		colors = ListPool.Get();
		triangles = ListPool.Get();
	}

Nachdem wir diese Netze angewendet haben, brauchen wir sie nicht mehr. Hier können wir sie den Pools hinzufügen.

	public void Apply () {
		hexMesh.SetVertices(vertices);
		ListPool.Add(vertices);
		hexMesh.SetColors(colors);
		ListPool.Add(colors);
		hexMesh.SetTriangles(triangles, 0);
		ListPool.Add(triangles);
		hexMesh.RecalculateNormals();
		meshCollider.sharedMesh = hexMesh;
	}

Daher haben wir Listen mehrfach verwendet, unabhängig davon, wie viele Maschen wir gleichzeitig füllen.

Optionaler Collider


Obwohl unser Gelände einen Collider braucht, wird er für Flüsse nicht wirklich benötigt. Die Strahlen passieren einfach das Wasser und kreuzen sich mit dem Kanal darunter. Machen wir es so, dass wir das Vorhandensein eines Colliders für konfigurieren können HexMesh. Wir realisieren dies, indem wir ein gemeinsames Feld hinzufügen bool useCollider. Für Gelände schalten wir es ein.

	public bool useCollider;


Mesh-Collider verwenden.

Wir müssen den Collider erst erstellen und zuweisen, wenn er eingeschaltet ist.

	void Awake () {
		GetComponent().mesh = hexMesh = new Mesh();
		if (useCollider) {
			meshCollider = gameObject.AddComponent();
		}
		hexMesh.name = "Hex Mesh";
	}
	public void Apply () {
		…
		if (useCollider) {
			meshCollider.sharedMesh = hexMesh;
		}
		…
	}

Optionale Farben


Scheitelpunktfarben können auch optional sein. Wir brauchen sie, um verschiedene Arten von Relief zu demonstrieren, aber Wasser ändert seine Farbe nicht. Wir können sie optional machen, genauso wie wir den Collider optional gemacht haben.

	public bool useCollider, useColors;
	public void Clear () {
		hexMesh.Clear();
		vertices = ListPool.Get();
		if (useColors) {
			colors = ListPool.Get();
		}
		triangles = ListPool.Get();
	}
	public void Apply () {
		hexMesh.SetVertices(vertices);
		ListPool.Add(vertices);
		if (useColors) {
			hexMesh.SetColors(colors);
			ListPool.Add(colors);
		}
		…
	}

Natürlich sollte das Terrain die Farben der Eckpunkte verwenden, also schalten Sie sie ein.


Verwendung von Farben.

Optionales UV


Währenddessen können wir auch Unterstützung für optionale UV-Koordinaten hinzufügen. Obwohl das Relief sie nicht benutzt, werden wir sie für Wasser brauchen.

	public bool useCollider, useColors, useUVCoordinates;
	[NonSerialized] List uvs;
	public void Clear () {
		hexMesh.Clear();
		vertices = ListPool.Get();
		if (useColors) {
			colors = ListPool.Get();
		}
		if (useUVCoordinates) {
			uvs = ListPool.Get();
		}
		triangles = ListPool.Get();
	}
	public void Apply () {
		hexMesh.SetVertices(vertices);
		ListPool.Add(vertices);
		if (useColors) {
			hexMesh.SetColors(colors);
			ListPool.Add(colors);
		}
		if (useUVCoordinates) {
			hexMesh.SetUVs(0, uvs);
			ListPool.Add(uvs);
		}
		…
	}


Wir verwenden keine UV-Koordinaten.

Um diese Funktion zu verwenden, erstellen Sie Methoden zum Hinzufügen von UV-Koordinaten zu Dreiecken und Vierecken.

	public void AddTriangleUV (Vector2 uv1, Vector2 uv2, Vector3 uv3) {
		uvs.Add(uv1);
		uvs.Add(uv2);
		uvs.Add(uv3);
	}
	public void AddQuadUV (Vector2 uv1, Vector2 uv2, Vector3 uv3, Vector3 uv4) {
		uvs.Add(uv1);
		uvs.Add(uv2);
		uvs.Add(uv3);
		uvs.Add(uv4);
	}

Fügen wir eine zusätzliche Methode hinzu, AddQuadUVum bequem einen rechteckigen UV-Bereich hinzuzufügen. Dies ist der Standardfall, wenn das Quad und seine Textur identisch sind. Wir werden es für das Wasser des Flusses verwenden.

	public void AddQuadUV (float uMin, float uMax, float vMin, float vMax) {
		uvs.Add(new Vector2(uMin, vMin));
		uvs.Add(new Vector2(uMax, vMin));
		uvs.Add(new Vector2(uMin, vMax));
		uvs.Add(new Vector2(uMax, vMax));
	}

Einheitspaket

Aktuelle Flüsse


Endlich ist es Zeit, Wasser zu schaffen! Wir werden dies mit einem Quad machen, das die Wasseroberfläche anzeigt. Und da wir mit Flüssen arbeiten, muss Wasser fließen. Dazu verwenden wir UV-Koordinaten, die die Ausrichtung des Flusses angeben. Um dies zu visualisieren, benötigen wir einen neuen Shader. Erstellen Sie daher einen neuen Standard-Shader und nennen Sie ihn River . Ändern Sie es so, dass die UV-Koordinaten in den grünen und roten Albedokanälen aufgezeichnet werden.

Shader "Custom/River" {
	…
		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c = tex2D(_MainTex, IN.uv_MainTex) * _Color;
			o.Albedo = c.rgb * IN.color;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
			o.Albedo.rg = IN.uv_MainTex;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

Zum HexGridChunkallgemeinen Feld hinzufügen HexMesh rivers. Wir säubern es und wenden es wie im Erleichterungsfall an.

	public HexMesh terrain, rivers;
	public void Triangulate () {
		terrain.Clear();
		rivers.Clear();
		for (int i = 0; i < cells.Length; i++) {
			Triangulate(cells[i]);
		}
		terrain.Apply();
		rivers.Apply();
	}

Werden wir zusätzliche Draw Calls haben, auch wenn wir keine Flüsse haben?
Die Unity-Engine ist intelligent genug, um keine Zeit mit dem Zeichnen eines leeren Netzes zu verschwenden. Daher wird das Netz des Flusses gezeichnet, wenn etwas zu sehen ist.

Ändern Sie das Fertighaus (durch die Instanz), indem Sie sein Geländeobjekt duplizieren, es in Rivers umbenennen und verbinden.



Fertigfragment mit Flüssen.

Erstellen Sie das River- Material mit unserem neuen Shader und lassen Sie das Rivers- Objekt es verwenden . Wir haben auch die Hexagon-Mesh-Komponente des Objekts so eingerichtet, dass sie UV-Koordinaten verwendet, aber keine Scheitelpunktfarben oder den Collider verwendet.


Unterobjekt Flüsse.

Triangulieren Sie Wasser


Bevor wir Wasser triangulieren können, müssen wir den Pegel seiner Oberfläche bestimmen. Machen HexMetricswir eine Höhenverschiebung , wie wir es mit dem Flussbett gemacht haben. Da die vertikale Verzerrung der Zelle der Hälfte der Höhenverschiebung entspricht, verschieben wir damit die Oberfläche des Flusses. So garantieren wir, dass das Wasser nie über der Topographie der Zelle liegt.

	public const float riverSurfaceElevationOffset = -0.5f;

Warum nicht etwas tiefer legen?
Die zufällige Verzerrung erreicht tatsächlich nie ihr Maximum, was normal ist. Wenn Sie möchten, können Sie die Wasseroberfläche natürlich leicht absenken.

Fügen Sie eine HexCellEigenschaft hinzu, um die vertikale Position der Oberfläche des Flusses zu ermitteln.

	public float RiverSurfaceY {
		get {
			return
				(elevation + HexMetrics.riverSurfaceElevationOffset) *
				HexMetrics.elevationStep;
		}
	}

Jetzt können wir uns an die Arbeit machen HexGridChunk! Da wir viele Vierecke von Flüssen erstellen werden, fügen wir hierfür eine separate Methode hinzu. Geben wir vier Eckpunkte und eine Höhe als Parameter an. Auf diese Weise können Sie bequem die vertikale Position aller vier Scheitelpunkte gleichzeitig festlegen, bevor Sie Quad hinzufügen.

	void TriangulateRiverQuad (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y
	) {
		v1.y = v2.y = v3.y = v4.y = y;
		rivers.AddQuad(v1, v2, v3, v4);
	}

Wir werden hier die UV-Koordinaten des Vierecks addieren. Gehen Sie einfach von links nach rechts und von unten nach oben.

		rivers.AddQuad(v1, v2, v3, v4);
		rivers.AddQuadUV(0f, 1f, 0f, 1f);

TriangulateWithRiver- Dies ist die erste Methode, zu der wir die Vierecke der Flüsse hinzufügen werden. Das erste Quad liegt zwischen der Mitte und der Mitte. Die zweite liegt zwischen der Mitte und der Rippe. Wir verwenden nur die Eckpunkte, die wir bereits haben. Da diese Spitzen unterschätzt werden, befindet sich das Wasser teilweise unter den geneigten Wänden des Kanals. Daher brauchen wir uns keine Gedanken über die genaue Position des Wasserrands zu machen.

	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		TriangulateRiverQuad(centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY);
		TriangulateRiverQuad(m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY);
	}


Die ersten Anzeichen von Wasser.

Warum ändert sich die Breite des Wassers?
Dies geschieht, weil die Höhe der Zelle verzerrt ist, das Bett und die Oberfläche des Flusses jedoch nicht. Eine größere Zellenhöhe führt zu steileren und höheren Kanalwänden. Dies verengt den Fluss an die Wasseroberfläche.

Im Fluss bewegen


Derzeit stimmen die UV-Koordinaten nicht mit der Flussrichtung überein. Wir müssen hier für Konsistenz sorgen. Angenommen, die U-Koordinate ist 0 auf der linken Seite des Flusses und 1 auf der rechten Seite, wenn Sie stromabwärts schauen. Die V-Koordinate sollte in Flussrichtung von 0 bis 1 variieren.

Unter Verwendung dieser Spezifikation sind die UV-Werte korrekt, wenn der ausgehende Fluss trianguliert wird. Sie stellen sich jedoch als falsch heraus und müssen umgedreht werden, wenn der eingehende Fluss trianguliert wird. Ergänzen Sie den TriangulateRiverQuadParameter, um die Arbeit zu vereinfachen bool reversed. Verwenden Sie es, um die UV-Strahlung bei Bedarf umzudrehen.

	void TriangulateRiverQuad (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y, bool reversed
	) {
		v1.y = v2.y = v3.y = v4.y = y;
		rivers.AddQuad(v1, v2, v3, v4);
		if (reversed) {
			rivers.AddQuadUV(1f, 0f, 1f, 0f);
		}
		else {
			rivers.AddQuadUV(0f, 1f, 0f, 1f);
		}
	}

In TriangulateWithRiverwissen wir, dass wir die Richtung umkehren müssen, wenn wir mit einem ankommenden Fluss umgehen.

		bool reversed = cell.IncomingRiver == direction;
		TriangulateRiverQuad(
			centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY, reversed
		);
		TriangulateRiverQuad(
			m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, reversed
		);


Die vereinbarte Richtung der Flüsse.

Anfang und Ende des Flusses


Im Inneren müssen TriangulateWithRiverBeginOrEndwir nur überprüfen, ob ein Fluss eintritt, um die Richtung des Flusses zu bestimmen. Dann können wir einen weiteren Quad River zwischen der Mitte und der Rippe einfügen.

	void TriangulateWithRiverBeginOrEnd (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		bool reversed = cell.HasIncomingRiver;
		TriangulateRiverQuad(
			m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, reversed
		);
	}

Der Teil zwischen der Mitte und der Mitte ist ein Dreieck, daher können wir es nicht verwenden TriangulateRiverQuad. Der einzige signifikante Unterschied besteht darin, dass sich der zentrale Gipfel in der Mitte des Flusses befindet. Daher ist seine Koordinate U immer gleich 1/2.

		center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY;
		rivers.AddTriangle(center, m.v2, m.v4);
		if (reversed) {
			rivers.AddTriangleUV(
				new Vector2(0.5f, 1f), new Vector2(1f, 0f), new Vector2(0f, 0f)
			);
		}
		else {
			rivers.AddTriangleUV(
				new Vector2(0.5f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f)
			);
		}


Wasser am Anfang und Ende.

Fehlen an den Enden Portionen Wasser?
Da Vierecke aus zwei Dreiecken bestehen, hängt ihre Form von der Ausrichtung ab, wenn das Viereck nicht flach ist. Daher ist die Triangulation der Wände des Kanals zu beiden Seiten des Flusses nicht symmetrisch. Dies zeigt sich am Schnittpunkt der Wasseroberfläche mit den Wänden des Kanals.

Wir können diesen Unterschied beseitigen, indem wir die Vierecke spiegeln. Dies macht sich jedoch nur bemerkbar, weil wir die Peaks noch nicht verzerrt haben. Nach dem Verzerren ist die Symmetrie trotzdem gebrochen.

Fluss zwischen Zellen


Beim Hinzufügen von Wasser zwischen den Zellen müssen wir auf den Höhenunterschied achten. Damit das Wasser an Hängen und Klippen herunterfließen kann, TriangulateRiverQuadmüssen zwei Höhenparameter unterstützt werden. Fügen wir also eine zweite hinzu.

	void TriangulateRiverQuad (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y1, float y2, bool reversed
	) {
		v1.y = v2.y = y1;
		v3.y = v4.y = y2;
		rivers.AddQuad(v1, v2, v3, v4);
		if (reversed) {
			rivers.AddQuadUV(1f, 0f, 1f, 0f);
		}
		else {
			rivers.AddQuadUV(0f, 1f, 0f, 1f);
		}
	}

Fügen Sie der Einfachheit halber eine Option hinzu, die eine Höhe erhält. Es wird nur eine andere Methode aufgerufen.

	void TriangulateRiverQuad (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y, bool reversed
	) {
		TriangulateRiverQuad(v1, v2, v3, v4, y, y, reversed);
	}

Jetzt können wir Quad River und In hinzufügen TriangulateConnection. Da wir uns zwischen den Zellen befinden, können wir nicht sofort herausfinden, mit welcher Art von Fluss wir es zu tun haben. Um festzustellen, ob eine Wende erforderlich ist, müssen wir prüfen, ob ein Fluss ankommt und ob er sich in unsere Richtung bewegt.

		if (cell.HasRiverThroughEdge(direction)) {
			e2.v3.y = neighbor.StreamBedY;
			TriangulateRiverQuad(
				e1.v2, e1.v4, e2.v2, e2.v4,
				cell.RiverSurfaceY, neighbor.RiverSurfaceY,
				cell.HasIncomingRiver && cell.IncomingRiver == direction
			);
		}


Der vollendete Fluss.

V Strecken


Bisher haben wir in jedem Segment des Flusses V-Koordinaten, die von 0 bis 1 gehen. Das heißt, es gibt nur vier von ihnen in der Zelle. Fünf, wenn wir auch Verbindungen zwischen Zellen hinzufügen. Was auch immer wir zum Texturieren des Flusses verwenden, es muss genauso oft wiederholt werden.

Wir können die Anzahl der Wiederholungen reduzieren, indem wir die V-Koordinaten so strecken, dass sie in der gesamten Zelle plus einer Verbindung von 0 auf 1 gehen. Dies kann durch Erhöhen der V-Koordinate in jedem Segment um 0,2 erfolgen. Wenn wir 0,4 in die Mitte setzen, wird es in der Mitte 0,6 und am Rand 0,8. In der Zellenverknüpfung ist der Wert dann 1.

Wenn der Fluss in die entgegengesetzte Richtung fließt, können wir immer noch 0,4 in die Mitte stellen, aber in die Mitte wird 0,2 und am Rand 0. Wenn wir so weitermachen, bis sich die Zelle anschließt, erhalten wir -0,2. Dies ist normal, da es für eine Textur mit Wiederholungsfilterungsmodus ähnlich wie 0,8 ist, genau wie 0 gleich 1 ist.


Änderung der Koordinaten V.

Um dies zu unterstützen, müssen wir TriangulateRiverQuadeinen weiteren Parameter hinzufügen .

	void TriangulateRiverQuad (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y, float v, bool reversed
	) {
		TriangulateRiverQuad(v1, v2, v3, v4, y, y, v, reversed);
	}
	void TriangulateRiverQuad (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y1, float y2, float v, bool reversed
	) {
		…
	}

Wenn die Richtung nicht umgekehrt wird, verwenden wir einfach die übertragene Koordinate am unteren Rand des Vierecks und addieren 0,2 am oberen Rand.

		else {
			rivers.AddQuadUV(0f, 1f, v, v + 0.2f);
		}

Wir können mit einer umgekehrten Richtung arbeiten, indem wir die Koordinate von 0,8 und 0,6 subtrahieren.

		if (reversed) {
			rivers.AddQuadUV(1f, 0f, 0.8f - v, 0.6f - v);
		}

Jetzt müssen wir die korrekten Koordinaten übertragen, als hätten wir es mit einem abgehenden Fluss zu tun. Beginnen wir mit TriangulateWithRiver.

		TriangulateRiverQuad(
			centerL, centerR, m.v2, m.v4, cell.RiverSurfaceY, 0.4f, reversed
		);
		TriangulateRiverQuad(
			m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed
		);

Dann TriangulateConnectionändern wir uns wie folgt.

			TriangulateRiverQuad(
				e1.v2, e1.v4, e2.v2, e2.v4,
				cell.RiverSurfaceY, neighbor.RiverSurfaceY, 0.8f,
				cell.HasIncomingRiver && cell.IncomingRiver == direction
			);

Und zum Schluss TriangulateWithRiverBeginOrEnd.

		TriangulateRiverQuad(
			m.v2, m.v4, e.v2, e.v4, cell.RiverSurfaceY, 0.6f, reversed
		);
		center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY;
		rivers.AddTriangle(center, m.v2, m.v4);
		if (reversed) {
			rivers.AddTriangleUV(
				new Vector2(0.5f, 0.4f),
				new Vector2(1f, 0.2f), new Vector2(0f, 0.2f)
			);
		}
		else {
			rivers.AddTriangleUV(
				new Vector2(0.5f, 0.4f),
				new Vector2(0f, 0.6f), new Vector2(1f, 0.6f)
			);
		}


Gestreckte V-Koordinaten:

Um die Faltung der V-Koordinaten korrekt anzuzeigen, stellen Sie sicher, dass sie im Fluss-Shader positiv bleiben.

			if (IN.uv_MainTex.y < 0) {
				IN.uv_MainTex.y += 1;
			}
			o.Albedo.rg = IN.uv_MainTex;


Eingeklappte Koordinaten V.

Einheitspaket

Flussanimation


Nachdem wir mit den UV-Koordinaten fertig sind, können wir die Flüsse animieren. Der Fluss-Shader wird dies tun, damit wir das Netz nicht ständig aktualisieren müssen.

Wir werden in diesem Tutorial keinen komplexen Fluss-Shader erstellen, aber später. Im Moment erstellen wir einen einfachen Effekt, der einen Einblick in die Funktionsweise von Animationen gibt.

Die Animation wird durch Verschieben der V-Koordinaten basierend auf der Spielzeit erstellt. Mit Unity können Sie den Wert mithilfe einer Variablen ermitteln _Time. Ihre Komponente Y enthält die unveränderte Zeit, die wir verwenden. Andere Komponenten enthalten andere Zeitskalen.

Wir werden die Faltung entlang V los, weil wir sie nicht mehr brauchen. Stattdessen subtrahieren wir die aktuelle Zeit von der V-Koordinate, wodurch die Koordinate nach unten verschoben wird und die Illusion entsteht, dass der Strom stromabwärts des Flusses fließt.

//			if (IN.uv_MainTex.y < 0) {
//				IN.uv_MainTex.y += 1;
//			}
			IN.uv_MainTex.y -= _Time.y;
			o.Albedo.rg = IN.uv_MainTex;

In einer Sekunde wird die V-Koordinate an allen Punkten kleiner als Null, sodass wir den Unterschied nicht mehr sehen. Dies ist wieder normal, wenn im Texturwiederholungsmodus gefiltert wird. Aber um zu sehen, was passiert, können wir den Bruchteil der V-Koordinate nehmen.

			IN.uv_MainTex.y -= _Time.y;
			IN.uv_MainTex.y = frac(IN.uv_MainTex.y);
			o.Albedo.rg = IN.uv_MainTex;


Animierte V-Koordinaten.

Lärmgebrauch


Jetzt ist unser Fluss animiert, aber in Richtung und Geschwindigkeit gibt es scharfe Übergänge. Unser UV-Muster macht sie ziemlich offensichtlich, aber es wird schwerer zu erkennen sein, wenn Sie ein wasserähnlicheres Muster verwenden. Lassen Sie uns also die Textur abtasten, anstatt rohes UV anzuzeigen. Wir können unsere vorhandene Rauschstruktur verwenden. Wir probieren es aus und multiplizieren die Farbe des Materials mit dem ersten Rauschkanal.

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float2 uv = IN.uv_MainTex;
			uv.y -= _Time.y;
			float4 noise = tex2D(_MainTex, uv);
			fixed4 c = _Color * noise.r;
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}

Ordnen Sie die Rauschstruktur dem Flussmaterial zu und stellen Sie sicher, dass es weiß ist.



Verwenden von Rauschtexturen.

Da die V-Koordinaten sehr gestreckt sind, erstreckt sich die Rauschtextur auch entlang des Flusses. Leider ist der Platz nicht sehr schön. Versuchen wir, es auf eine andere Weise zu dehnen - indem wir die Skalierung der Koordinaten von U stark reduzieren. Ein Sechzehntel wird ausreichen. Dies bedeutet, dass wir nur ein schmales Rauschband abtasten.

			float2 uv = IN.uv_MainTex;
			uv.x *= 0.0625;
			uv.y -= _Time.y;


Dehnen der U-Koordinate:

Verlangsamen wir den Vorgang auf ein Viertel pro Sekunde, sodass der Texturzyklus vier Sekunden dauert.

			uv.y -= _Time.y * 0.25;


Das aktuelle Rauschen.

Geräuschmischung


Alles sieht schon viel besser aus, aber das Muster bleibt immer dasselbe. Wasser verhält sich nicht so.

Da wir nur ein kleines Rauschband verwenden, können wir das Muster variieren, indem wir dieses Band entlang der Textur verschieben. Dazu wird der U-Koordinate Zeit hinzugefügt. Wir müssen es langsam machen, sonst scheint der Fluss seitwärts zu fließen. Versuchen wir den Koeffizienten von 0,005. Dies bedeutet, dass es 200 Sekunden dauert, bis der Zyklus des Musters abgeschlossen ist.

			uv.x = uv.x * 0.0625 + _Time.y * 0.005;


Bewegungsgeräusch.

Das sieht leider nicht sehr schön aus. Wasser scheint immer noch statisch und die Verschiebung ist deutlich spürbar, obwohl es sehr langsam ist. Wir können die Verschiebung verbergen, indem wir zwei Rauschproben kombinieren und in entgegengesetzte Richtungen verschieben. Und wenn wir beim Verschieben des zweiten Samples etwas andere Werte verwenden, erstellen wir eine leichte Animation der Änderung.

Damit wir nie dasselbe Rauschmuster überlappen, verwenden wir für das zweite Sample einen anderen Kanal.

			float2 uv = IN.uv_MainTex;
			uv.x = uv.x * 0.0625 + _Time.y * 0.005;
			uv.y -= _Time.y * 0.25;
			float4 noise = tex2D(_MainTex, uv);
			float2 uv2 = IN.uv_MainTex;
			uv2.x = uv2.x * 0.0625 - _Time.y * 0.0052;
			uv2.y -= _Time.y * 0.23;
			float4 noise2 = tex2D(_MainTex, uv2);
			fixed4 c = _Color * (noise.r * noise2.a);


Eine Kombination aus zwei sich ändernden Geräuschmustern.

Durchscheinendes Wasser


Unser Muster sieht sehr dynamisch aus. Der nächste Schritt besteht darin, es durchscheinend zu machen.

Stellen Sie zunächst sicher, dass das Wasser keine Schatten wirft. Sie können sie über die Renderer-Komponente des Rivers- Objekts im Fertighaus deaktivieren.


Schattenwurf ist deaktiviert.

Schalten Sie nun den Shader in den transparenten Modus. Shader-Tags sollten verwendet werden, um dies anzuzeigen. Fügen Sie dann das #pragma surfaceSchlüsselwort in die Zeile ein alpha. Während wir hier sind, können Sie das Schlüsselwort entfernen fullforwardshadows, da wir immer noch keine Schatten werfen.

		Tags { "RenderType"="Transparent" "Queue"="Transparent" }
		LOD 200
		CGPROGRAM
		#pragma surface surf Standard alpha // fullforwardshadows
		#pragma target 3.0

Jetzt werden wir die Art und Weise ändern, wie wir die Farbe des Flusses einstellen. Anstatt das Rauschen mit der Farbe zu multiplizieren, fügen wir es dem Rauschen hinzu. Dann verwenden wir die Funktion saturate, um das Ergebnis so einzuschränken, dass es 1 nicht überschreitet.

			fixed4 c = saturate(_Color + noise.r * noise2.a);

Dadurch können wir die Farbe des Materials als Grundfarbe verwenden. Rauschen erhöht die Helligkeit und Opazität. Lassen Sie uns versuchen, eine blaue Farbe mit einer relativ geringen Deckkraft zu verwenden. Als Ergebnis erhalten wir blaues durchscheinendes Wasser mit weißen Spritzer.



Farbiges durchscheinendes Wasser.

Einheitspaket

Fertigstellung


Jetzt, wo alles zu funktionieren scheint, ist es Zeit, die Spitzen wieder zu verzerren. Dies führt nicht nur zu einer Verformung der Zellränder, sondern auch zu einer Unebenheit unserer Flüsse.

	public const float cellPerturbStrength = 4f;



Verzerrte und verzerrte Spitzen.

Untersuchen wir das Gelände auf Probleme, die aufgrund von Verzerrungen aufgetreten sind. Sieht aus wie sie sind! Schauen wir uns die hohen Wasserfälle an.


Wasser von Klippen abgeschnitten.

Wasser, das von einem hohen Wasserfall fällt, verschwindet hinter einer Klippe. Wenn dies passiert, ist es sehr auffällig, also müssen wir etwas dagegen tun.

Viel weniger offensichtlich ist, dass Wasserfälle eher abfallend als gerade abfallend sein können. Obwohl Wasser in der Realität nicht so fließt, ist es nicht besonders auffällig. Unser Gehirn wird es so interpretieren, dass es uns normal erscheint. Also ignoriere es einfach.

Der einfachste Weg, um Wasserverluste zu vermeiden, ist die Vertiefung der Flussbetten. So schaffen wir mehr Raum zwischen der Wasseroberfläche und dem Flussbett. Dadurch werden auch die Wände des Kanals vertikaler, gehen Sie also nicht zu tief. Fragen wir malHexMetrics.streamBedElevationOffsetWert -1,75. Dies löst den Großteil der Probleme und das Bett wird nicht zu tief. Ein Teil des Wassers wird noch abgeschnitten, aber nicht die ganzen Wasserfälle.

	public const float streamBedElevationOffset = -1.75f;


Detaillierte Kanäle.

Einheitspaket

Teil 7: Straßen


  • Straßenunterstützung hinzufügen.
  • Triangulieren Sie die Straße.
  • Wir verbinden Straßen und Flüsse.
  • Das Erscheinungsbild von Straßen verbessern.


Die ersten Zeichen der Zivilisation.

Zellen mit Straßen


Wie Flüsse verlaufen Straßen von Zelle zu Zelle durch die Mitte der Zellenränder. Der große Unterschied besteht darin, dass auf den Straßen kein Wasser fließt und sie daher bidirektional sind. Außerdem sind Kreuzungen für ein funktionierendes Straßennetz erforderlich, sodass mehr als zwei Straßen pro Zelle unterstützt werden müssen.

Wenn Sie zulassen, dass die Straßen in alle sechs Richtungen verlaufen, kann die Zelle null bis sechs Straßen enthalten. Das sind insgesamt vierzehn mögliche Straßenkonfigurationen. Dies sind weit mehr als fünf mögliche Flusskonfigurationen. Um dies zu handhaben, müssen wir einen allgemeineren Ansatz verwenden, der alle Konfigurationen handhaben kann.


14 mögliche Straßenkonfigurationen.

Straßenverfolgung


Die einfachste Möglichkeit, Straßen in einer Zelle zu verfolgen, besteht darin, ein Array von Booleschen Werten zu verwenden. Fügen Sie das private Feld des Arrays hinzu HexCellund machen Sie es serialisierbar, damit Sie es im Inspektor sehen können. Wir legen die Größe des Arrays über die Zellen-Vorfertigung fest, sodass sechs Straßen unterstützt werden.

	[SerializeField]
	bool[] roads;


Fertige Zellen mit sechs Straßen.

Fügen Sie eine Methode hinzu, um zu überprüfen, ob die Zelle einen Pfad in eine bestimmte Richtung hat.

	public bool HasRoadThroughEdge (HexDirection direction) {
		return roads[(int)direction];
	}

Es ist auch praktisch zu wissen, ob sich mindestens eine Straße in der Zelle befindet. Fügen Sie daher eine Eigenschaft für diese hinzu. Gehen Sie einfach in der Schleife um das Array herum und kehren Sie zurück true, sobald wir den Weg gefunden haben. Wenn es keine Straßen gibt, kehren Sie zurück false.

	public bool HasRoads {
		get {
			for (int i = 0; i < roads.Length; i++) {
				if (roads[i]) {
					return true;
				}
			}
			return false;
		}
	}

Straßenräumung


Wie bei Flüssen werden wir eine Methode hinzufügen, um alle Straßen aus der Zelle zu entfernen. Dies kann mit einer Schleife geschehen, die jede zuvor aktivierte Straße trennt.

	public void RemoveRoads () {
		for (int i = 0; i < neighbors.Length; i++) {
			if (roads[i]) {
				roads[i] = false;
			}
		}
	}

Natürlich müssen wir auch die entsprechenden teuren Zellen in den Nachbarn deaktivieren.

			if (roads[i]) {
				roads[i] = false;
				neighbors[i].roads[(int)((HexDirection)i).Opposite()] = false;
			}

Danach müssen wir jede der Zellen aktualisieren. Da die Straßen lokal zu den Zellen sind, müssen wir nur die Zellen selbst ohne ihre Nachbarn aktualisieren.

			if (roads[i]) {
				roads[i] = false;
				neighbors[i].roads[(int)((HexDirection)i).Opposite()] = false;
				neighbors[i].RefreshSelfOnly();
				RefreshSelfOnly();
			}

Straßen hinzufügen


Das Hinzufügen von Straßen ähnelt dem Entfernen von Straßen. Der einzige Unterschied besteht darin, dass wir der booleschen Variablen einen Wert zuweisen trueund nicht false. Wir können eine private Methode erstellen, die beide Operationen ausführen kann. Dann ist es möglich, die Straße hinzuzufügen und zu entfernen.

	public void AddRoad (HexDirection direction) {
		if (!roads[(int)direction]) {
			SetRoad((int)direction, true);
		}
	}
	public void RemoveRoads () {
		for (int i = 0; i < neighbors.Length; i++) {
			if (roads[i]) {
				SetRoad(i, false);
			}
		}
	}
	void SetRoad (int index, bool state) {
		roads[index] = state;
		neighbors[index].roads[(int)((HexDirection)index).Opposite()] = state;
		neighbors[index].RefreshSelfOnly();
		RefreshSelfOnly();
	}

Wir können nicht gleichzeitig einen Fluss und eine Straße in die gleiche Richtung haben. Daher werden wir vor dem Hinzufügen der Straße prüfen, ob es einen Platz dafür gibt.

	public void AddRoad (HexDirection direction) {
		if (!roads[(int)direction] && !HasRiverThroughEdge(direction)) {
			SetRoad((int)direction, true);
		}
	}

Außerdem können Straßen nicht mit Klippen kombiniert werden, weil sie zu scharf sind. Oder lohnt es sich vielleicht, den Weg durch eine niedrige Klippe zu ebnen, aber nicht durch eine hohe? Um dies festzustellen, müssen wir eine Methode erstellen, die den Höhenunterschied in eine bestimmte Richtung angibt.

	public int GetElevationDifference (HexDirection direction) {
		int difference = elevation - GetNeighbor(direction).elevation;
		return difference >= 0 ? difference : -difference;
	}

Jetzt können wir sicherstellen, dass Straßen mit einem ausreichend kleinen Höhenunterschied hinzugefügt werden. Ich beschränke mich nur auf Pisten, also maximal 1 Einheit.

	public void AddRoad (HexDirection direction) {
		if (
			!roads[(int)direction] && !HasRiverThroughEdge(direction) &&
			GetElevationDifference(direction) <= 1
		) {
			SetRoad((int)direction, true);
		}
	}

Die falschen Straßen entfernen


Wir haben Straßen nur dann hinzufügen, wenn dies zulässig ist. Jetzt müssen wir sicherstellen, dass sie entfernt werden, wenn sie später falsch werden, beispielsweise beim Hinzufügen eines Flusses. Wir können die Platzierung von Flüssen auf Straßen verbieten, aber Flüsse werden nicht durch Straßen unterbrochen. Lassen Sie sie die Straße aus dem Weg waschen.

Es wird für uns ausreichen, nach der Straße zu fragen false, unabhängig davon, ob die Straße war. Es wird immer beide Zellen aktualisiert werden, so dass wir nicht mehr benötigen , um explizit nennt RefreshSelfOnlyin SetOutgoingRiver.

	public void SetOutgoingRiver (HexDirection direction) {
		if (hasOutgoingRiver && outgoingRiver == direction) {
			return;
		}
		HexCell neighbor = GetNeighbor(direction);
		if (!neighbor || elevation < neighbor.elevation) {
			return;
		}
		RemoveOutgoingRiver();
		if (hasIncomingRiver && incomingRiver == direction) {
			RemoveIncomingRiver();
		}
		hasOutgoingRiver = true;
		outgoingRiver = direction;
//		RefreshSelfOnly();
		neighbor.RemoveIncomingRiver();
		neighbor.hasIncomingRiver = true;
		neighbor.incomingRiver = direction.Opposite();
//		neighbor.RefreshSelfOnly();
		SetRoad((int)direction, false);
	}

Eine andere Operation, die die Straße falsch machen kann, ist eine Änderung der Höhe. In diesem Fall müssen wir nach Straßen in alle Richtungen suchen. Ist der Höhenunterschied zu groß, muss die vorhandene Straße gelöscht werden.

	public int Elevation {
		get {
			return elevation;
		}
		set {
			…
			for (int i = 0; i < roads.Length; i++) {
				if (roads[i] && GetElevationDifference((HexDirection)i) > 1) {
					SetRoad(i, false);
				}
			}
			Refresh();
		}
	}

Einheitspaket

Straßenbearbeitung


Das Bearbeiten von Straßen funktioniert genauso wie das Bearbeiten von Flüssen. Daher HexMapEditorist ein weiterer Schalter sowie eine Methode zum Festlegen seines Status erforderlich.

	OptionalToggle riverMode, roadMode;
	public void SetRiverMode (int mode) {
		riverMode = (OptionalToggle)mode;
	}
	public void SetRoadMode (int mode) {
		roadMode = (OptionalToggle)mode;
	}

Die Methode EditCellsollte nun das Entfernen durch Hinzufügen von Straßen unterstützen. Dies bedeutet, dass er beim Ziehen und Ablegen eine von zwei möglichen Aktionen ausführen kann. Wir restrukturieren den Code ein wenig, so dass beim korrekten Ziehen und Ablegen die Zustände beider Schalter überprüft werden.

	void EditCell (HexCell cell) {
		if (cell) {
			if (applyColor) {
				cell.Color = activeColor;
			}
			if (applyElevation) {
				cell.Elevation = activeElevation;
			}
			if (riverMode == OptionalToggle.No) {
				cell.RemoveRiver();
			}
			if (roadMode == OptionalToggle.No) {
				cell.RemoveRoads();
			}
			if (isDrag) {
				HexCell otherCell = cell.GetNeighbor(dragDirection.Opposite());
				if (otherCell) {
					if (riverMode == OptionalToggle.Yes) {
						otherCell.SetOutgoingRiver(dragDirection);
					}
					if (roadMode == OptionalToggle.Yes) {
						otherCell.AddRoad(dragDirection);
					}
				}
			}
		}
	}

Sie können der Benutzeroberfläche schnell eine Roadbar hinzufügen, indem Sie die Riverbar kopieren und die von den Switches aufgerufene Methode ändern.

Als Ergebnis erhalten wir eine ziemlich hohe Benutzeroberfläche. Um dies zu beheben, habe ich das Layout der Farbtafeln geändert, um sie an die kompakteren Straßen- und Flussfelder anzupassen.


Benutzeroberfläche mit Straßen.

Da ich jetzt zwei Zeilen mit drei Optionen für Farben verwende, ist Platz für eine andere Farbe. Also habe ich einen Artikel für Orange hinzugefügt.



Fünf Farben: Gelb, Grün, Blau, Orange und Weiß.

Jetzt können wir die Straßen bearbeiten, aber bisher sind sie nicht sichtbar. Mit dem Inspektor können Sie sicherstellen, dass alles funktioniert.


Zelle mit Straßen im Inspektor.

Einheitspaket

Straßentriangulation


Um Straßen anzuzeigen, müssen Sie sie triangulieren. Dies ähnelt dem Erstellen eines Netzes für Flüsse, nur das Flussbett wird nicht im Relief angezeigt.

Erstellen Sie zunächst einen neuen Standard-Shader, der die Straßenoberfläche wieder mit UV-Koordinaten malt.

Shader "Custom/Road" {
	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200
		CGPROGRAM
		#pragma surface surf Standard fullforwardshadows
		#pragma target 3.0
		sampler2D _MainTex;
		struct Input {
			float2 uv_MainTex;
		};
		half _Glossiness;
		half _Metallic;
		fixed4 _Color;
		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c = fixed4(IN.uv_MainTex, 1, 1);
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

Erstellen Sie mit diesem Shader ein Straßenmaterial.


Materielle Straße.

Stellen Sie das Fertigteil des Fragments so ein, dass es ein weiteres untergeordnetes Sechsecknetz für die Straßen erhält. Dieses Netz sollte keine Schatten werfen und darf nur UV-Koordinaten verwenden. Der schnellste Weg, dies zu tun, führt über eine vorgefertigte Instanz - duplizieren Sie das Rivers- Objekt und ersetzen Sie das Material.



Untergeordnete Objektstraßen.

Fügen Sie danach das HexGridChunkallgemeine Feld hinzu HexMesh roadsund fügen Sie es hinzu Triangulate. Verbinden Sie es im Inspektor mit dem Roads- Objekt .

	public HexMesh terrain, rivers, roads;
	public void Triangulate () {
		terrain.Clear();
		rivers.Clear();
		roads.Clear();
		for (int i = 0; i < cells.Length; i++) {
			Triangulate(cells[i]);
		}
		terrain.Apply();
		rivers.Apply();
		roads.Apply();
	}


Das Roads-Objekt ist verbunden.

Straßen zwischen Zellen


Schauen wir uns zunächst die Straßensegmente zwischen den Zellen an. Wie Flüsse werden Straßen von zwei mittleren Quad gesperrt. Wir decken diese Verbindungsvierecke vollständig mit den Straßenvierecken ab, so dass die Positionen derselben sechs Spitzen verwendet werden können. Ergänzen Sie dazu die HexGridChunkMethode TriangulateRoadSegment.

	void TriangulateRoadSegment (
		Vector3 v1, Vector3 v2, Vector3 v3,
		Vector3 v4, Vector3 v5, Vector3 v6
	) {
		roads.AddQuad(v1, v2, v4, v5);
		roads.AddQuad(v2, v3, v5, v6);
	}

Da wir uns nicht mehr um den Wasserfluss kümmern müssen, ist die V-Koordinate nicht mehr erforderlich, daher weisen wir ihr überall den Wert 0. Mit der U-Koordinate können wir angeben, ob wir uns in der Mitte der Straße oder auf der Seite befinden. Lassen Sie es in der Mitte gleich 1 und auf beiden Seiten gleich 0 sein.

	void TriangulateRoadSegment (
		Vector3 v1, Vector3 v2, Vector3 v3,
		Vector3 v4, Vector3 v5, Vector3 v6
	) {
		roads.AddQuad(v1, v2, v4, v5);
		roads.AddQuad(v2, v3, v5, v6);
		roads.AddQuadUV(0f, 1f, 0f, 0f);
		roads.AddQuadUV(1f, 0f, 0f, 0f);
	}


Ein Straßenabschnitt zwischen Zellen.

Es wäre logisch, diese Methode aufzurufen TriangulateEdgeStrip, aber nur, wenn es wirklich eine Straße gibt. Fügen Sie der Methode einen Booleschen Parameter hinzu, um diese Informationen zu übergeben.

	void TriangulateEdgeStrip (
		EdgeVertices e1, Color c1,
		EdgeVertices e2, Color c2,
		bool hasRoad
	) {
		…
	}

Natürlich erhalten wir jetzt Compilerfehler, da diese Informationen bisher noch nicht übertragen wurden. In allen Fällen kann der Aufruf TriangulateEdgeStripals letztes Argument hinzugefügt werden false. Wir können jedoch auch deklarieren, dass der Standardwert dieses Parameters gleich ist false. Aus diesem Grund wird der Parameter optional und Kompilierungsfehler verschwinden.

	void TriangulateEdgeStrip (
		EdgeVertices e1, Color c1,
		EdgeVertices e2, Color c2,
		bool hasRoad = false
	) {
		…
	}

Wie funktionieren optionale Parameter?
Stellen Sie sich diese als Abkürzung vor, anstatt alternative Methoden zu schreiben und die fehlenden Argumente auszufüllen. Zum Beispiel die Methode

int MyMethod (int x = 1, int y = 2) { return x + y; }

äquivalent zu drei Methoden

int MyMethod (int x, int y) { return x + y; }
int MyMethod (int x) { return MyMethod(x, 2); }
int MyMethod () { return MyMethod(1, 2}; }

Die Reihenfolge ist hier wichtig. Optionale Parameter können von rechts nach links übersprungen werden. Der letzte Parameter wird zuerst verworfen. Sie gehen immer nach den erforderlichen Parametern.

Um die Straße zu triangulieren, rufen Sie TriangulateRoadSegmentbei Bedarf einfach mit den mittleren sechs Gipfeln an.

	void TriangulateEdgeStrip (
		EdgeVertices e1, Color c1,
		EdgeVertices e2, Color c2,
		bool hasRoad = false
	) {
		terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
		terrain.AddQuadColor(c1, c2);
		terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
		terrain.AddQuadColor(c1, c2);
		terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
		terrain.AddQuadColor(c1, c2);
		terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);
		terrain.AddQuadColor(c1, c2);
		if (hasRoad) {
			TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4);
		}
	}

So gehen wir mit Flachzellenverbindungen um. Um die Straßen auf den Felsvorsprüngen zu stützen, müssen wir auch angeben, TriangulateEdgeTerraceswo die Straße hinzugefügt werden soll. Er kann diese Informationen einfach übermitteln TriangulateEdgeStrip.

	void TriangulateEdgeTerraces (
		EdgeVertices begin, HexCell beginCell,
		EdgeVertices end, HexCell endCell,
		bool hasRoad
	) {
		EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1);
		Color c2 = HexMetrics.TerraceLerp(beginCell.Color, endCell.Color, 1);
		TriangulateEdgeStrip(begin, beginCell.Color, e2, c2, hasRoad);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			EdgeVertices e1 = e2;
			Color c1 = c2;
			e2 = EdgeVertices.TerraceLerp(begin, end, i);
			c2 = HexMetrics.TerraceLerp(beginCell.Color, endCell.Color, i);
			TriangulateEdgeStrip(e1, c1, e2, c2, hasRoad);
		}
		TriangulateEdgeStrip(e2, c2, end, endCell.Color, hasRoad);
	}

TriangulateEdgeTerracesInnen angerufen TriangulateConnection. Hier können wir feststellen, ob sich tatsächlich eine Straße in der aktuellen Richtung befindet, sowohl während der Triangulation der Rippe als auch der Triangulation der Leisten.


		if (cell.GetEdgeType(direction) == HexEdgeType.Slope) {
			TriangulateEdgeTerraces(
				e1, cell, e2, neighbor, cell.HasRoadThroughEdge(direction)
			);
		}
		else {
			TriangulateEdgeStrip(
				e1, cell.Color, e2, neighbor.Color,
				cell.HasRoadThroughEdge(direction)
			);
		}


Straßensegmente zwischen Zellen.

Zelle über Rendering


Beim Zeichnen von Straßen werden zwischen den Zellen Straßensegmente angezeigt. Die Mitte dieser Segmente ist lila mit einem Übergang zu blau an den Rändern.

Wenn Sie die Kamera bewegen, können die Segmente jedoch flackern und manchmal vollständig verschwinden. Dies geschieht, weil die Straßendreiecke genau die Geländedreiecke überlappen. Dreiecke zum Rendern werden zufällig ausgewählt. Dieses Problem kann in zwei Schritten behoben werden.

Zunächst möchten wir die Straßen zeichnen, nachdem das Relief gezeichnet wurde. Dies kann erreicht werden, indem sie nach dem Rendern der üblichen Geometrie gerendert werden, dh in eine spätere Renderwarteschlange gestellt werden.

		Tags {
			"RenderType"="Opaque"
			"Queue" = "Geometry+1"
		}

Zweitens müssen wir sicherstellen, dass Straßen in derselben Position über Geländedreiecke gezogen werden. Dies kann durch Hinzufügen des Tiefenversatzes erfolgen. Dadurch kann die GPU davon ausgehen, dass sich die Dreiecke näher an der Kamera befinden als sie tatsächlich sind.

		Tags {
			"RenderType"="Opaque"
			"Queue" = "Geometry+1"
		}
		LOD 200
		Offset -1, -1

Straßen durch Zellen


Bei der Triangulation von Flüssen mussten wir nicht mehr als zwei Flussrichtungen pro Zelle bewältigen. Wir konnten fünf mögliche Optionen identifizieren und sie unterschiedlich triangulieren, um die richtigen Flüsse zu erzeugen. Bei Straßen gibt es jedoch vierzehn mögliche Optionen. Wir werden nicht für jede dieser Optionen separate Ansätze verwenden. Stattdessen verarbeiten wir jede der sechs Zellenrichtungen auf dieselbe Weise, unabhängig von der spezifischen Straßenkonfiguration.

Wenn eine Straße an einem Teil der Zelle vorbeiführt, wird sie direkt in die Mitte der Zelle gezogen, ohne die Dreieckszone zu verlassen. Wir werden ein Straßensegment von der Kante zur Hälfte in Richtung des Zentrums zeichnen. Dann verwenden wir zwei Dreiecke, um den Rest zur Mitte zu schließen.


Triangulation eines Straßenabschnitts.

Um dieses Schema zu triangulieren, müssen wir das Zentrum der Zelle, den linken und rechten mittleren Scheitelpunkt und die Scheitelpunkte der Kante kennen. Fügen Sie eine Methode TriangulateRoadmit den entsprechenden Parametern hinzu.

	void TriangulateRoad (
		Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e
	) {
	}

Um ein Straßensegment zu bauen, benötigen wir einen zusätzlichen Gipfel. Es befindet sich zwischen dem linken und rechten Mittelgipfel.

	void TriangulateRoad (
		Vector3 center, Vector3 mL, Vector3 mR, EdgeVertices e
	) {
		Vector3 mC = Vector3.Lerp(mL, mR, 0.5f);
		TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4);
	}

Jetzt können wir auch die verbleibenden zwei Dreiecke hinzufügen.

		TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4);
		roads.AddTriangle(center, mL, mC);
		roads.AddTriangle(center, mC, mR);

Wir müssen auch die UV-Koordinaten der Dreiecke hinzufügen. Zwei ihrer Gipfel befinden sich in der Mitte der Straße und der Rest ist am Rande.

		roads.AddTriangle(center, mL, mC);
		roads.AddTriangle(center, mC, mR);
		roads.AddTriangleUV(
			new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f)
		);
		roads.AddTriangleUV(
			new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f)
		);

Beschränken wir uns vorerst auf Zellen, in denen es keine Flüsse gibt. In diesen Fällen wird Triangulatelediglich ein Fächer aus Dreiecken erstellt. Verschieben Sie diesen Code in eine separate Methode. Dann fügen wir einen Anruf hinzu, TriangulateRoadwenn die Straße tatsächlich ist. Der linke und der rechte mittlere Scheitelpunkt können durch Interpolation zwischen dem mittleren und zwei Eckscheitelpunkten ermittelt werden.

	void Triangulate (HexDirection direction, HexCell cell) {
		…
		if (cell.HasRiver) {
			…
		}
		else {
			TriangulateWithoutRiver(direction, cell, center, e);
		}
		…
	}
	void TriangulateWithoutRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		TriangulateEdgeFan(center, e, cell.Color);
		if (cell.HasRoadThroughEdge(direction)) {
			TriangulateRoad(
				center,
				Vector3.Lerp(center, e.v1, 0.5f),
				Vector3.Lerp(center, e.v5, 0.5f),
				e
			);
		}
	}


Straßen, die durch die Zellen führen.

Straßenrippen


Jetzt können wir die Straßen sehen, aber näher an der Mitte der Zellen verengen sie sich. Da wir nicht prüfen, um welche der vierzehn Optionen es sich handelt, können wir den Mittelpunkt der Straße nicht verschieben, um schönere Formen zu erstellen. Stattdessen können wir anderen Teilen der Zelle zusätzliche Straßenränder hinzufügen.

Wenn Straßen durch die Zelle verlaufen, jedoch nicht in der aktuellen Richtung, wird den Straßenrändern ein Dreieck hinzugefügt. Dieses Dreieck wird durch den mittleren, linken und rechten Eckpunkt definiert. In diesem Fall liegt nur der Mittelgipfel in der Mitte der Straße. Die anderen beiden liegen auf ihrer Rippe.

	void TriangulateRoadEdge (Vector3 center, Vector3 mL, Vector3 mR) {
		roads.AddTriangle(center, mL, mR);
		roads.AddTriangleUV(
			new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f)
		);
	}


Teil des Straßenrandes.

Wenn wir eine vollständige Straße oder nur eine Kante triangulieren müssen, müssen wir sie belassen TriangulateRoad. Dazu muss diese Methode wissen, ob die Straße durch die Richtung der aktuellen Zellenkante verläuft. Dazu fügen wir einen Parameter hinzu.

	void TriangulateRoad (
		Vector3 center, Vector3 mL, Vector3 mR,
		EdgeVertices e, bool hasRoadThroughCellEdge
	) {
		if (hasRoadThroughCellEdge) {
			Vector3 mC = Vector3.Lerp(mL, mR, 0.5f);
			TriangulateRoadSegment(mL, mC, mR, e.v2, e.v3, e.v4);
			roads.AddTriangle(center, mL, mC);
			roads.AddTriangle(center, mC, mR);
			roads.AddTriangleUV(
				new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f)
			);
			roads.AddTriangleUV(
				new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f)
			);
		}
		else {
			TriangulateRoadEdge(center, mL, mR);
		}
	}

Jetzt muss TriangulateWithoutRiveres anrufen, TriangulateRoadwenn eine Straße durch die Zelle führt. Und er muss Informationen darüber übermitteln, ob die Straße die aktuelle Kante passiert.

	void TriangulateWithoutRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		TriangulateEdgeFan(center, e, cell.Color);
		if (cell.HasRoads) {
			TriangulateRoad(
				center,
				Vector3.Lerp(center, e.v1, 0.5f),
				Vector3.Lerp(center, e.v5, 0.5f),
				e, cell.HasRoadThroughEdge(direction)
			);
		}
	}


Straßen mit ausgefüllten Rippen.

Straßenglättung


Die Straßen sind jetzt fertig. Leider erzeugt dieser Ansatz Ausbuchtungen in der Mitte der Zellen. Das Platzieren der linken und rechten Spitze in der Mitte zwischen der Mitte und den Ecken passt zu uns, wenn sich eine Straße neben ihnen befindet. Aber wenn nicht, dann gibt es eine Ausbuchtung. Um dies zu vermeiden, können wir in solchen Fällen die Eckpunkte näher an der Mitte platzieren. Genauer gesagt, dann interpolieren mit ¼, nicht mit ½.

Lassen Sie uns eine separate Methode erstellen, um herauszufinden, welche Interpolatoren verwendet werden sollen. Da es zwei davon gibt, können wir das Ergebnis einfügen Vector2. Die Komponente X ist der Interpolator des linken Punktes und die Komponente Y ist der Interpolator des rechten Punktes.

	Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) {
		Vector2 interpolators;
		return interpolators;
	}

Wenn eine Straße in die aktuelle Richtung führt, können wir die Punkte in der Mitte platzieren.

	Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) {
		Vector2 interpolators;
		if (cell.HasRoadThroughEdge(direction)) {
			interpolators.x = interpolators.y = 0.5f;
		}
		return interpolators;
	}

Andernfalls können die Optionen unterschiedlich sein. Für den linken Punkt können wir ½ verwenden, wenn eine Straße in die vorherige Richtung führt. Ist dies nicht der Fall, müssen wir ¼ verwenden. Gleiches gilt für den richtigen Punkt, jedoch unter Berücksichtigung der folgenden Richtung.

	Vector2 GetRoadInterpolators (HexDirection direction, HexCell cell) {
		Vector2 interpolators;
		if (cell.HasRoadThroughEdge(direction)) {
			interpolators.x = interpolators.y = 0.5f;
		}
		else {
			interpolators.x =
				cell.HasRoadThroughEdge(direction.Previous()) ? 0.5f : 0.25f;
			interpolators.y =
				cell.HasRoadThroughEdge(direction.Next()) ? 0.5f : 0.25f;
		}
		return interpolators;
	}

Mit dieser neuen Methode können Sie nun bestimmen, welche Interpolatoren verwendet werden. Dadurch werden die Straßen geglättet.

	void TriangulateWithoutRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		TriangulateEdgeFan(center, e, cell.Color);
		if (cell.HasRoads) {
			Vector2 interpolators = GetRoadInterpolators(direction, cell);
			TriangulateRoad(
				center,
				Vector3.Lerp(center, e.v1, interpolators.x),
				Vector3.Lerp(center, e.v5, interpolators.y),
				e, cell.HasRoadThroughEdge(direction)
			);
		}
	}



Glatte Straßen.

Einheitspaket

Die Kombination von Flüssen und Straßen


Gegenwärtig haben wir funktionierende Straßen, aber nur, wenn es keine Flüsse gibt. Befindet sich in der Zelle ein Fluss, werden die Straßen nicht trianguliert.


In der Nähe der Flüsse gibt es keine Straßen.

Lassen Sie uns eine Methode erstellen, TriangulateRoadAdjacentToRiverum mit dieser Situation umzugehen. Wir stellen die üblichen Parameter ein. Wir werden es am Anfang der Methode aufrufen TriangulateAdjacentToRiver.

	void TriangulateAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		if (cell.HasRoads) {
			TriangulateRoadAdjacentToRiver(direction, cell, center, e);
		}
		…
	}
	void TriangulateRoadAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
	}

Zunächst machen wir dasselbe wie für Straßen ohne Flüsse. Wir werden prüfen, ob die Straße die aktuelle Kante passiert, Interpolatoren abrufen, Mittelspitzen erzeugen und callen TriangulateRoad. Aber da Flüsse auf dem Weg erscheinen, müssen wir die Straßen von ihnen wegbewegen. Infolgedessen befindet sich die Mitte der Straße in einer anderen Position. Wir verwenden eine Variable, um diese neue Position zu speichern roadCenter. Anfangs ist es gleich der Mitte der Zelle.

void TriangulateRoadAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction);
		Vector2 interpolators = GetRoadInterpolators(direction, cell);
		Vector3 roadCenter = center;
		Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x);
		Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y);
		TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge);
	}

Also werden wir Teilstraßen in Zellen mit Flüssen erstellen. Die Richtungen, durch die die Flüsse fließen, werden die Straßenlücken durchtrennen.


Straßen mit Leerzeichen.

Anfang oder Ende des Flusses


Schauen wir uns zunächst Zellen an, die entweder den Anfang oder das Ende eines Flusses enthalten. Damit sich die Straßen nicht mit Wasser überschneiden, ziehen wir den Mittelpunkt der Straße vom Fluss weg. Fügen Sie die HexCellEigenschaft hinzu, um die Richtung des ankommenden oder abgehenden Flusses zu ermitteln .

	public HexDirection RiverBeginOrEndDirection {
		get {
			return hasIncomingRiver ? incomingRiver : outgoingRiver;
		}
	}

Jetzt können wir diese Eigenschaft verwenden HexGridChunk.TriangulateRoadAdjacentToRiver, um die Mitte der Straße in die entgegengesetzte Richtung zu verschieben. Es reicht aus, ein Drittel in diese Richtung zur Mittelrippe zu bewegen.

		bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction);
		Vector2 interpolators = GetRoadInterpolators(direction, cell);
		Vector3 roadCenter = center;
		if (cell.HasRiverBeginOrEnd) {
			roadCenter += HexMetrics.GetSolidEdgeMiddle(
				cell.RiverBeginOrEndDirection.Opposite()
			) * (1f / 3f);
		}
		Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x);
		Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y);
		TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge);


Geänderte Straßen.

Als nächstes müssen wir die Lücken schließen. Wir werden dies tun, indem wir zusätzliche Dreiecke an den Rändern der Straße anbringen, wenn wir in der Nähe des Flusses sind. Wenn es in der vorherigen Richtung einen Fluss gibt, fügen wir ein Dreieck zwischen der Mitte der Straße, der Mitte der Zelle und dem mittleren linken Punkt hinzu. Wenn sich der Fluss in der nächsten Richtung befindet, fügen wir ein Dreieck zwischen der Mitte der Straße, dem mittleren rechten Punkt und der Mitte der Zelle hinzu.

Wir werden dies unabhängig von der Konfiguration des Flusses tun, also setzen Sie diesen Code am Ende der Methode.

		Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x);
		Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y);
		TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge);
		if (cell.HasRiverThroughEdge(direction.Previous())) {
			TriangulateRoadEdge(roadCenter, center, mL);
		}
		if (cell.HasRiverThroughEdge(direction.Next())) {
			TriangulateRoadEdge(roadCenter, mR, center);
		}

Können Sie die else-Anweisung nicht verwenden?
Es wird nicht in allen Fällen funktionieren. Vielleicht die Existenz von Flüssen, die gleichzeitig in beide Richtungen fließen.


Fertige Straßen.

Gerade Flüsse


Zellen mit geraden Flüssen sind besonders schwierig, da sie das Zentrum der Zelle im Wesentlichen in zwei Teile teilen. Wir fügen bereits zusätzliche Dreiecke hinzu, um die Lücken zwischen den Flüssen zu füllen, aber wir müssen auch die Straßen auf den gegenüberliegenden Seiten des Flusses trennen.


Straßen, die einen geraden Fluss überlappen.

Wenn die Zelle keinen Anfang oder kein Ende eines Flusses hat, können wir prüfen, ob die ankommenden und abgehenden Flüsse in entgegengesetzte Richtungen verlaufen. Wenn ja, dann haben wir einen direkten Fluss.

		if (cell.HasRiverBeginOrEnd) {
			roadCenter += HexMetrics.GetSolidEdgeMiddle(
				cell.RiverBeginOrEndDirection.Opposite()
			) * (1f / 3f);
		}
		else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) {
		}

Um zu bestimmen, wo der Fluss relativ zur aktuellen Richtung ist, müssen wir die benachbarten Richtungen überprüfen. Der Fluss ist entweder links oder rechts. Da wir dies am Ende der Methode tun, werden diese Anforderungen in booleschen Variablen zwischengespeichert. Dies vereinfacht auch das Lesen unseres Codes.

		bool hasRoadThroughEdge = cell.HasRoadThroughEdge(direction);
		bool previousHasRiver = cell.HasRiverThroughEdge(direction.Previous());
		bool nextHasRiver = cell.HasRiverThroughEdge(direction.Next());
		Vector2 interpolators = GetRoadInterpolators(direction, cell);
		Vector3 roadCenter = center;
		if (cell.HasRiverBeginOrEnd) {
			roadCenter += HexMetrics.GetSolidEdgeMiddle(
				cell.RiverBeginOrEndDirection.Opposite()
			) * (1f / 3f);
		}
		else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) {
			if (previousHasRiver) {
			}
			else {
			}
		}
		Vector3 mL = Vector3.Lerp(roadCenter, e.v1, interpolators.x);
		Vector3 mR = Vector3.Lerp(roadCenter, e.v5, interpolators.y);
		TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge);
		if (previousHasRiver) {
			TriangulateRoadEdge(roadCenter, center, mL);
		}
		if (nextHasRiver) {
			TriangulateRoadEdge(roadCenter, mR, center);
		}

Wir müssen den Mittelpunkt der Straße auf einen Winkel verschieben, der vom Fluss in die entgegengesetzte Richtung zeigt. Wenn der Fluss durch die vorherige Richtung fließt, ist dies der zweite Raumwinkel. Ansonsten ist dies der erste Raumwinkel.

		else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) {
			Vector3 corner;
			if (previousHasRiver) {
				corner = HexMetrics.GetSecondSolidCorner(direction);
			}
			else {
				corner = HexMetrics.GetFirstSolidCorner(direction);
			}
		}

Um die Straße so zu verschieben, dass sie an den Fluss angrenzt, müssen wir den Mittelpunkt der Straße um die Hälfte der Entfernung zu dieser Ecke verschieben. Dann müssen wir auch den Mittelpunkt der Zelle um ein Viertel der Strecke in diese Richtung bewegen.

		else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) {
			Vector3 corner;
			if (previousHasRiver) {
				corner = HexMetrics.GetSecondSolidCorner(direction);
			}
			else {
				corner = HexMetrics.GetFirstSolidCorner(direction);
			}
			roadCenter += corner * 0.5f;
			center += corner * 0.25f;
		}


Geteilte Straßen.

Wir haben das Straßennetz innerhalb dieser Zelle geteilt. Dies ist normal, wenn sich die Straßen auf beiden Seiten des Flusses befinden. Aber wenn es auf einer Seite keine Straße gibt, haben wir ein kleines Stück einer abgelegenen Straße. Das ist unlogisch, also lassen Sie uns solche Teile loswerden.

Stellen Sie sicher, dass eine Straße in die aktuelle Richtung führt. Ist dies nicht der Fall, überprüfen Sie die andere Richtung derselben Flussseite auf das Vorhandensein der Straße. Wenn es weder dort noch dort eine vorbeifahrende Straße gibt, verlassen wir die Methode vor dem Triangulieren.

			if (previousHasRiver) {
				if (
					!hasRoadThroughEdge &&
					!cell.HasRoadThroughEdge(direction.Next())
				) {
					return;
				}
				corner = HexMetrics.GetSecondSolidCorner(direction);
			}
			else {
				if (
					!hasRoadThroughEdge &&
					!cell.HasRoadThroughEdge(direction.Previous())
				) {
					return;
				}
				corner = HexMetrics.GetFirstSolidCorner(direction);
			}


Abgeschnittene Straßen.

Was ist mit Brücken?
Bisher beschränken wir uns nur auf Straßen. Wir werden in einem zukünftigen Tutorial Brücken und andere Strukturen behandeln.

Zick-Zack-Flüsse


Die nächste Art von Fluss ist Zickzack. Solche Flüsse teilen sich nicht das Straßennetz, daher müssen wir nur die Mitte der Straße verschieben.


Zickzacke, die durch die Straßen überschreiten.

Der einfachste Weg, um nach Zickzack zu suchen, besteht darin, die Richtungen der ein- und ausgehenden Flüsse zu vergleichen. Wenn sie nebeneinander liegen, haben wir einen Zickzack. Dies führt zu zwei möglichen Optionen, abhängig von der Richtung der Strömung.

		if (cell.HasRiverBeginOrEnd) {
			…
		}
		else if (cell.IncomingRiver == cell.OutgoingRiver.Opposite()) {
			…
		}
		else if (cell.IncomingRiver == cell.OutgoingRiver.Previous()) {
		}
		else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) {
		}

Wir können die Mitte der Straße verschieben, indem wir eine der Ecken der Richtung des einlaufenden Flusses verwenden. Der von Ihnen gewählte Winkel hängt von der Strömungsrichtung ab. Bewegen Sie den Mittelpunkt der Straße aus diesem Winkel mit einem Faktor von 0,2.

		else if (cell.IncomingRiver == cell.OutgoingRiver.Previous()) {
			roadCenter -= HexMetrics.GetSecondCorner(cell.IncomingRiver) * 0.2f;
		}
		else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) {
			roadCenter -= HexMetrics.GetFirstCorner(cell.IncomingRiver) * 0.2f;
		}


Die Straße schob sich von den Zickzacklinien weg.

In den krummen Flüssen


Die letzte Flusskonfiguration ist eine glatte Kurve. Wie beim direkten Fluss kann man auch hier Straßen trennen. In diesem Fall sind die Parteien jedoch unterschiedlich. Zuerst müssen wir mit der Innenseite der Kurve arbeiten.


Ein geschwungener Fluss mit asphaltierten Straßen.

Wenn wir auf beiden Seiten der aktuellen Richtung einen Fluss haben, befinden wir uns innerhalb der Kurve.

		else if (cell.IncomingRiver == cell.OutgoingRiver.Next()) {
			…
		}
		else if (previousHasRiver && nextHasRiver) {
		}

Wir müssen die Mitte der Straße in Richtung des aktuellen Randes der Zelle verschieben und die Straße ein wenig verkürzen. Ein Koeffizient von 0,7 reicht aus. Das Zellzentrum sollte sich ebenfalls mit einem Koeffizienten von 0,5 verschieben.

		else if (previousHasRiver && nextHasRiver) {
			Vector3 offset = HexMetrics.GetSolidEdgeMiddle(direction) *
				HexMetrics.innerToOuter;
			roadCenter += offset * 0.7f;
			center += offset * 0.5f;
		}


Verkürzte Straßen.

Wie bei geraden Flüssen müssen wir die isolierten Teile der Straßen abschneiden. In diesem Fall reicht es aus, nur die aktuelle Richtung zu überprüfen.

		else if (previousHasRiver && nextHasRiver) {
			if (!hasRoadThroughEdge) {
				return;
			}
			Vector3 offset = HexMetrics.GetSolidEdgeMiddle(direction) *
				HexMetrics.innerToOuter;
			roadCenter += offset * 0.7f;
			center += offset * 0.5f;
		}


Straßen abschneiden.

Außerhalb der krummen Flüsse


Nach Prüfung aller vorherigen Fälle war die einzige verbleibende Option der äußere Teil des gekrümmten Flusses. Draußen gibt es drei Teile der Zelle. Wir müssen die mittlere Richtung finden. Nachdem wir es erhalten haben, können wir den Mittelpunkt der Straße um den Faktor 0,25 in Richtung dieser Rippe verschieben.

		else if (previousHasRiver && nextHasRiver) {
			…
		}
		else {
			HexDirection middle;
			if (previousHasRiver) {
				middle = direction.Next();
			}
			else if (nextHasRiver) {
				middle = direction.Previous();
			}
			else {
				middle = direction;
			}
			roadCenter += HexMetrics.GetSolidEdgeMiddle(middle) * 0.25f;
		}


Änderte die Außenseite der Straße.

Als letzten Schritt müssen wir die Straßen auf dieser Seite des Flusses abschneiden. Am einfachsten ist es, alle drei Fahrtrichtungen in Bezug auf die Mitte zu überprüfen. Wenn es keine Straßen gibt, hören wir auf zu arbeiten.

		else {
			HexDirection middle;
			if (previousHasRiver) {
				middle = direction.Next();
			}
			else if (nextHasRiver) {
				middle = direction.Previous();
			}
			else {
				middle = direction;
			}
			if (
				!cell.HasRoadThroughEdge(middle) &&
				!cell.HasRoadThroughEdge(middle.Previous()) &&
				!cell.HasRoadThroughEdge(middle.Next())
			) {
				return;
			}
			roadCenter += HexMetrics.GetSolidEdgeMiddle(middle) * 0.25f;
		}



Straßen vor und nach dem Beschneiden.

Nachdem alle Flussoptionen verarbeitet wurden, können unsere Flüsse und Straßen nebeneinander existieren. Flüsse ignorieren Straßen und Straßen passen sich an Flüsse an.


Die Kombination von Flüssen und Straßen.

Einheitspaket

Das Aussehen der Straßen


Bis zu diesem Moment haben wir ihre UV-Koordinaten als Straßenfarben verwendet. Da sich nur die U-Koordinate geändert hat, haben wir tatsächlich den Übergang zwischen der Mitte und dem Straßenrand angezeigt.


Anzeige von UV-Koordinaten.

Jetzt, da die Straßen exakt trianguliert sind, können wir den Roadshader so ändern, dass er etwas ähnlicheres wie Straßen darstellt. Wie bei Flüssen ist dies eine einfache Visualisierung ohne Schnickschnack.

Wir beginnen mit der Verwendung von Volltonfarben für Straßen. Verwenden Sie einfach die Farbe des Materials. Ich habe es rot gemacht.

		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c = _Color;
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


Rote Straßen.

Und es sieht schon viel besser aus! Aber fahren wir fort und mischen die Straße mit dem Gelände, wobei wir die U-Koordinate als Mischfaktor verwenden.

		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c = _Color;
			float blend = IN.uv_MainTex.x;
			o.Albedo = c.rgb;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = blend;
		}

Es scheint, dass dies nichts geändert hat. Es ist passiert, weil unser Shader undurchsichtig ist. Jetzt braucht er Alpha Blending. Insbesondere benötigen wir einen Shader für mischbare Abziehbilder. Wir können den erforderlichen Shader erhalten, indem wir der Direktive eine #pragma surfaceZeile hinzufügen decal:blend.

		#pragma surface surf Standard fullforwardshadows decal:blend


Die Mischung aus Straßen.

Also haben wir eine glatte lineare Mischung von der Mitte bis zur Kante erstellt, die nicht sehr hübsch aussieht. Damit es wie eine Straße aussieht, benötigen wir eine feste Fläche, gefolgt von einem schnellen Übergang zu einer undurchsichtigen Fläche. Hierfür können Sie die Funktion nutzen smoothstep. Es wandelt einen linearen Verlauf von 0 auf 1 in eine S-förmige Kurve um.


Lineare Progression und Smoothstep.

Die Funktion smoothstepverfügt über einen minimalen und einen maximalen Parameter, um die Kurve in einem beliebigen Intervall anzupassen. Eingabewerte außerhalb des Bereichs werden begrenzt, um die Kurve flach zu halten. Verwenden wir am Anfang der Kurve 0,4 und am Ende 0,7. Dies bedeutet, dass die U-Koordinate von 0 bis 0,4 vollständig transparent ist. Und die U-Koordinaten von 0,7 bis 1 sind vollständig undurchsichtig. Der Übergang erfolgt zwischen 0,4 und 0,7.

			float blend = IN.uv_MainTex.x;
			blend = smoothstep(0.4, 0.7, blend);


Быстрый переход между непрозрачной и прозрачной областями.

Дорога с шумом


Так как меш дороги будет искажён, дороги имеют варьирующуюся ширину. Поэтому ширина перехода на краях тоже будет переменной. Иногда она размыта, иногда резка. Такая вариативность нас устраивает, если воспринимать дороги как песчаные или земляные.

Давайте сделаем следующий шаг и добавим к краям дороги шума. Это сделает их более неровными и менее полигональными. Мы можем это сделать, сэмплируя текстуру шума. Для сэмплирования можно использовать координаты мира XZ, так же, как мы это делали при искажении вершин ячеек.

Чтобы получить доступ к позиции мира в поверхностном шейдере, добавим ко входной структуре float3 worldPos.

		struct Input {
			float2 uv_MainTex;
			float3 worldPos;
		};

Теперь мы можем использовать эту позицию в surf для сэмплирования основной текстуры. Уменьшите также масштаб координат, иначе текстура слишком часто будет повторяться.

			float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025);
			fixed4 c = _Color;
			float blend = IN.uv_MainTex.x;

Исказим переход, умножив координату U на noise.x. Но поскольку значения шума в среднем равны 0.5, при этом большинство дорог исчезнет. Чтобы избежать этого, перед умножением прибавим к шуму 0.5.

			float blend = IN.uv_MainTex.x;
			blend *= noise.x + 0.5;
			blend = smoothstep(0.4, 0.7, blend);



Искажённые края дорог.

Чтобы закончить с этим, исказим также и цвет дорог. Это придаст дорогам ощущение грязи, соответствующее нечётким краям.

Умножим цвет на другой канал шума, допустим на noise.y. Так мы получим в среднем половину значения цвета. Так как это слишком много, немного уменьшим масштаб шума и прибавим константу, чтобы сумма могла достичь 1.

			fixed4 c = _Color * (noise.y * 0.75 + 0.25);


Неровные дороги.

unitypackage

Jetzt auch beliebt: