Hexagon-Karten in Unity: Speichern und Laden, Texturen, Entfernungen

Teile 1-3: Netz, Farben und Zellenhöhen

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

Teile 8-11: Wasser, Landformen und Wälle

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

Teile 16-19: Weg finden, Spielerkader, Animationen

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

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

Teil 12: Speichern und laden


  • Verfolgen Sie die Art des Geländes anstelle der Farbe.
  • Erstellen Sie eine Datei.
  • Wir schreiben die Daten in eine Datei und lesen sie dann.
  • Wir serialisieren die Zellendaten.
  • Reduzieren Sie die Dateigröße.

Wir wissen bereits, wie man interessante Karten erstellt. Jetzt müssen Sie lernen, wie Sie sie speichern.


Wird aus der Datei test.map geladen .

Geländetyp


Beim Speichern einer Karte müssen nicht alle Daten gespeichert werden, die wir während der Ausführung der Anwendung verfolgen. Zum Beispiel müssen wir uns nur die Höhe der Zellen merken. Die vertikale Position selbst wird diesen Daten entnommen, sodass Sie sie nicht speichern müssen. Eigentlich ist es besser, wenn wir diese berechneten Metriken nicht speichern. Somit bleiben die Kartendaten korrekt, auch wenn wir später entscheiden, den Höhenversatz zu ändern. Daten sind von ihrer Darstellung getrennt.

Ebenso müssen wir nicht die genaue Farbe der Zelle speichern. Sie können schreiben, dass die Zelle grün ist. Der genaue Grünton kann sich jedoch mit einer Änderung des visuellen Stils ändern. Dazu können wir den Farbindex speichern, nicht die Farben selbst. Tatsächlich kann es für uns ausreichen, diesen Index zur Laufzeit anstelle von echten Farben in den Zellen zu speichern. Dies ermöglicht später eine komplexere Visualisierung des Reliefs.

Verschieben einer Reihe von Farben


Wenn die Zellen keine Farbdaten mehr haben, sollten sie an einem anderen Ort gespeichert werden. Es ist am bequemsten, es in HexMetrics zu speichern. Fügen wir also eine Reihe von Farben hinzu.

  public static Color[] colors; 

Wie alle anderen globalen Daten, wie z. B. Rauschen, können wir diese Farben mit HexGrid initialisieren.

  public Color[] colors; … void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; … } … void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; } } 

Und da wir jetzt Zellen nicht direkt Farben zuweisen, werden wir die Standardfarbe entfernen.

 // public Color defaultColor = Color.white; … void CreateCell (int x, int z, int i) { … HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab); cell.transform.localPosition = position; cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z); // cell.Color = defaultColor; … } 

Stellen Sie die neuen Farben so ein, dass sie dem allgemeinen Array des Sechseck-Karteneditors entsprechen.


Dem Raster hinzugefügte Farben.

Zell-Refactoring


Entfernen Sie das Farbfeld von HexCell . Stattdessen speichern wir den Index. Anstelle eines Farbindex verwenden wir einen allgemeineren Reliefindex.

 // Color color; int terrainTypeIndex; 

Die Farbeigenschaft kann diesen Index nur verwenden, um die entsprechende Farbe zu erhalten. Jetzt ist es nicht direkt eingestellt, also löschen Sie diesen Teil. In diesem Fall erhalten wir einen Kompilierungsfehler, den wir bald beheben werden.

  public Color Color { get { return HexMetrics.colors[terrainTypeIndex]; } // set { // … // } } 

Fügen Sie eine neue Eigenschaft hinzu, um einen neuen Höhenindex abzurufen und festzulegen.

  public int TerrainTypeIndex { get { return terrainTypeIndex; } set { if (terrainTypeIndex != value) { terrainTypeIndex = value; Refresh(); } } } 

Editor Refactoring


In HexMapEditor löschen HexMapEditor gesamten Code bezüglich der Farben. Dadurch wird der Kompilierungsfehler behoben.

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

Fügen Sie nun ein Feld und eine Methode hinzu, um den aktiven Höhenindex zu steuern.

  int activeTerrainTypeIndex; … public void SetTerrainTypeIndex (int index) { activeTerrainTypeIndex = index; } 

Wir verwenden diese Methode als Ersatz für die jetzt fehlende SelectColor Methode. Verbinden Sie die Farb-Widgets in der Benutzeroberfläche mit SetTerrainTypeIndex , und lassen Sie alles andere unverändert. Dies bedeutet, dass noch ein negativer Index verwendet wird und sich die Farbe nicht ändern sollte.

Ändern Sie EditCell so, dass der Elevationstypindex der zu bearbeitenden Zelle zugewiesen wird.

  void EditCell (HexCell cell) { if (cell) { if (activeTerrainTypeIndex >= 0) { cell.TerrainTypeIndex = activeTerrainTypeIndex; } … } } 

Obwohl wir die Farbdaten aus den Zellen entfernt haben, sollte die Karte genauso funktionieren wie zuvor. Der einzige Unterschied besteht darin, dass die Standardfarbe jetzt die erste im Array ist. In meinem Fall ist es gelb.


Gelb ist die neue Standardfarbe.

Einheitspaket

Daten in einer Datei speichern


Um das Speichern und Laden der Karte zu steuern, verwenden wir HexMapEditor . Wir werden zwei Methoden erstellen, die dies tun, und sie vorerst leer lassen.

  public void Save () { } public void Load () { } 

Fügen Sie der Benutzeroberfläche zwei Schaltflächen hinzu ( GameObject / UI / Button ). Verbinden Sie sie mit den Tasten und geben Sie die entsprechenden Beschriftungen. Ich habe sie unten im rechten Bereich platziert.


Schaltflächen zum Speichern und Laden.

Speicherort der Datei


Um eine Karte zu speichern, müssen Sie sie irgendwo speichern. Wie in den meisten Spielen werden Daten in einer Datei gespeichert. Aber wo soll diese Datei im Dateisystem abgelegt werden? Die Antwort hängt davon ab, auf welchem ​​Betriebssystem das Spiel ausgeführt wird. Jedes Betriebssystem hat seine eigenen Standards zum Speichern von Dateien, die sich auf Anwendungen beziehen.

Wir müssen diese Standards nicht kennen. Unity kennt den richtigen Pfad, den wir mit Application.persistentDataPath . Sie können überprüfen, wie es mit Ihnen sein wird, indem Sie es Save , in der Konsole anzeigen und die Taste im Wiedergabemodus drücken.

  public void Save () { Debug.Log(Application.persistentDataPath); } 

Auf Desktop-Systemen enthält der Pfad den Namen des Unternehmens und des Produkts. Dieser Pfad wird sowohl vom Editor als auch von der Assembly verwendet. Namen können unter Bearbeiten / Projekteinstellungen / Player konfiguriert werden.


Name des Unternehmens und des Produkts.

Warum kann ich den Bibliotheksordner auf dem Mac nicht finden?
Der Bibliotheksordner wird häufig ausgeblendet. Die Art und Weise, wie es angezeigt werden kann, hängt von der Version von OS X ab. Wenn Sie keine ältere Version haben, wählen Sie den Home-Ordner im Finder aus und gehen Sie zu Ansichtsoptionen anzeigen . Es gibt ein Kontrollkästchen für den Bibliotheksordner .

Was ist mit WebGL?
WebGL-Spiele können nicht auf das Dateisystem des Benutzers zugreifen. Stattdessen werden alle Dateivorgänge in ein Dateisystem im Speicher umgeleitet. Sie ist für uns transparent. Um die Daten zu speichern, müssen Sie die Webseite jedoch manuell bestellen, um die Daten in den Browserspeicher zu kopieren.

Dateierstellung


Um eine Datei zu erstellen, müssen Klassen aus dem System.IO Namespace verwendet werden. Daher fügen wir eine using Anweisung für die HexMapEditor Klasse hinzu.

 using UnityEngine; using UnityEngine.EventSystems; using System.IO; public class HexMapEditor : MonoBehaviour { … } 

Zuerst müssen wir den vollständigen Pfad zur Datei erstellen. Wir verwenden test.map als Dateinamen. Es muss dem Pfad der gespeicherten Daten hinzugefügt werden. Ob Sie einen Forward- oder Backslash (Slash oder Backslash) einfügen müssen, hängt von der Plattform ab. Die Path.Combine Methode führt Path.Combine .

  public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); } 

Als nächstes müssen wir an dieser Stelle auf die Datei zugreifen. Wir tun dies mit der File.Open Methode. Da wir Daten in diese Datei schreiben möchten, müssen wir den Erstellungsmodus verwenden. In diesem Fall wird entweder eine neue Datei auf dem angegebenen Pfad erstellt oder eine vorhandene Datei ersetzt.

  string path = Path.Combine(Application.persistentDataPath, "test.map"); File.Open(path, FileMode.Create); 

Das Ergebnis des Aufrufs dieser Methode ist ein offener Datenstrom, der dieser Datei zugeordnet ist. Wir können es verwenden, um Daten in eine Datei zu schreiben. Und wir dürfen nicht vergessen, den Strom zu schließen, wenn wir ihn nicht mehr brauchen.

  string path = Path.Combine(Application.persistentDataPath, "test.map"); Stream fileStream = File.Open(path, FileMode.Create); fileStream.Close(); 

Wenn Sie zu diesem Zeitpunkt auf die Schaltfläche Speichern klicken, wird die Datei test.map in dem Ordner erstellt, der als Pfad zu den gespeicherten Daten angegeben ist. Wenn Sie diese Datei studieren, ist sie leer und hat eine Größe von 0 Byte, da wir bisher nichts darauf geschrieben haben.

In Datei schreiben


Um Daten in eine Datei zu schreiben, benötigen wir eine Möglichkeit, Daten in diese Datei zu streamen. Der einfachste Weg, dies zu tun, ist mit BinaryWriter . Mit diesen Objekten können Sie primitive Daten in einen beliebigen Stream schreiben.

Erstellen Sie ein neues BinaryWriter Objekt, und unser Dateistream wird sein Argument sein. Closing Writer schließt den verwendeten Stream. Daher müssen wir keinen direkten Link mehr zum Stream speichern.

  string path = Path.Combine(Application.persistentDataPath, "test.map"); BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)); writer.Close(); 

Um Daten in einen Stream zu übertragen, können wir die BinaryWriter.Write Methode verwenden. Es gibt eine Variante der Write Methode für alle primitiven Typen wie Integer und Float. Es können auch Zeilen aufgezeichnet werden. Versuchen wir, eine Ganzzahl 123 zu schreiben.

  BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)); writer.Write(123); writer.Close(); 

Klicken Sie auf die Schaltfläche Speichern und überprüfen Sie test.map erneut. Jetzt beträgt seine Größe 4 Bytes, da die Ganzzahlgröße 4 Bytes beträgt.

Warum zeigt mein Dateimanager an, dass die Datei mehr Speicherplatz beansprucht?
Weil Dateisysteme den Speicherplatz in Byteblöcke unterteilen. Sie verfolgen keine einzelnen Bytes. Da test.map bisher nur vier Bytes benötigt, wird ein Block Speicherplatz benötigt.

Beachten Sie, dass wir Binärdaten speichern, keinen für Menschen lesbaren Text. Wenn wir die Datei in einem Texteditor öffnen, sehen wir daher eine Reihe undeutlicher Zeichen. Sie werden wahrscheinlich das Symbol { gefolgt von nichts oder ein paar Platzhaltern sehen.

Sie können die Datei in einem Hex-Editor öffnen. In diesem Fall sehen wir 7b 00 00 00 . Dies sind vier Bytes unserer Ganzzahl, die in hexadezimaler Notation abgebildet sind. In gewöhnlichen Dezimalzahlen ist dies 123 0 0 0 . In der Binärdatei sieht das erste Byte wie 01111011 aus .

Der ASCII-Code für { ist 123, sodass dieses Zeichen in einem Texteditor angezeigt werden kann. ASCII 0 ist ein Nullzeichen, das keinen sichtbaren Zeichen entspricht.

Die verbleibenden drei Bytes sind gleich Null, da wir eine Zahl kleiner als 256 geschrieben haben. Wenn wir 256 schreiben würden, würden wir 00 01 00 00 im Hex-Editor sehen.

Sollte 123 nicht als 00 00 00 7b gespeichert werden?
BinaryWriter verwendet das Little-Endian-Format zum Speichern von Zahlen. Dies bedeutet, dass die niedrigstwertigen Bytes zuerst geschrieben werden. Dieses Format wurde von Microsoft bei der Entwicklung des .NET-Frameworks verwendet. Es wurde wahrscheinlich gewählt, weil die Intel-CPU das Little-Endian-Format verwendet.

Eine Alternative dazu ist Big-Endian, bei dem die höchstwertigen Bytes zuerst gespeichert werden. Dies entspricht der üblichen Reihenfolge von Zahlen in Zahlen. 123 ist einhundertdreiundzwanzig, weil wir den Big-Endian-Rekord meinen. Wenn es ein Little-Endian wäre, würde 123 dreihunderteinundzwanzig bedeuten.

Wir machen Ressourcen frei


Es ist wichtig, dass wir den Schriftsteller schließen. Während es geöffnet ist, sperrt das Dateisystem die Datei und verhindert, dass andere Prozesse darauf schreiben. Wenn wir vergessen, es zu schließen, werden wir uns auch blockieren. Wenn wir zweimal auf die Schaltfläche Speichern klicken, können wir den Stream beim zweiten Mal nicht öffnen.

Anstatt den Writer manuell zu schließen, können wir hierfür einen using Block erstellen. Es definiert den Bereich, in dem der Writer gültig ist. Wenn der ausführbare Code diesen Bereich überschreitet, wird der Writer gelöscht und der Thread geschlossen.

  using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(123); } // writer.Close(); 

Dies funktioniert, da die IDisposable und File-Stream-Klassen die IDisposable Schnittstelle implementieren. Diese Objekte verfügen über eine Dispose Methode, die indirekt aufgerufen wird, wenn sie über den using hinausgehen.

Der große Vorteil der using ist, dass sie funktioniert, unabhängig davon, wie das Programm keinen Umfang mehr hat. Frühe Rücksendungen, Ausnahmen und Fehler stören ihn nicht. Außerdem ist er sehr prägnant.

Datenabruf


Um zuvor geschriebene Daten zu lesen, müssen wir den Code in die Load Methode einfügen. Wie beim Speichern müssen wir einen Pfad erstellen und den Dateistream öffnen. Der Unterschied ist, dass wir jetzt die Datei zum Lesen öffnen, nicht zum Schreiben. Und statt Schriftsteller brauchen wir BinaryReader .

  public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryReader reader = new BinaryReader(File.Open(path, FileMode.Open)) ) { } } 

In diesem Fall können wir die File.OpenRead Methode verwenden, um die Datei zum Lesen zu öffnen.

  using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { } 

Warum können wir File.OpenWrite beim Schreiben nicht verwenden?
Diese Methode erstellt einen Stream, der Daten zu vorhandenen Dateien hinzufügt, anstatt sie zu ersetzen.

Beim Lesen müssen wir den Typ der empfangenen Daten explizit angeben. Um eine Ganzzahl aus einem Stream zu lesen, müssen wir BinaryReader.ReadInt32 . Diese Methode liest eine 32-Bit-Ganzzahl, d. H. Vier Bytes.

  using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { Debug.Log(reader.ReadInt32()); } 

Es ist zu beachten, dass es beim Empfang von 123 ausreicht, ein Byte zu lesen. Gleichzeitig verbleiben jedoch drei zu dieser Ganzzahl gehörende Bytes im Stream. Darüber hinaus funktioniert dies nicht für Zahlen außerhalb des Intervalls 0–255. Tun Sie dies daher nicht.

Einheitspaket

Kartendaten schreiben und lesen


Eine wichtige Frage beim Speichern von Daten ist, ob ein für Menschen lesbares Format verwendet werden soll. In der Regel sind lesbare Formate JSON, XML und einfaches ASCII mit einer bestimmten Struktur. Solche Dateien können in Texteditoren geöffnet, interpretiert und bearbeitet werden. Darüber hinaus vereinfachen sie den Datenaustausch zwischen verschiedenen Anwendungen.

Solche Formate haben jedoch ihre eigenen Anforderungen. Dateien belegen mehr Speicherplatz (manchmal viel mehr) als die Verwendung von Binärdaten. Sie können auch die Kosten für das Codieren und Decodieren von Daten sowohl hinsichtlich der Laufzeit als auch des Speicherbedarfs erheblich erhöhen.

Im Gegensatz dazu sind Binärdaten kompakt und schnell. Dies ist wichtig, wenn Sie große Datenmengen aufzeichnen. Zum Beispiel, wenn in jeder Spielrunde automatisch eine große Karte gespeichert wird. Deshalb
Wir werden das Binärformat verwenden. Wenn Sie damit umgehen können, können Sie mit detaillierteren Formaten arbeiten.

Was ist mit der automatischen Serialisierung?
Unmittelbar während des Serialisierens von Unity-Daten können wir serialisierte Klassen direkt in den Stream schreiben. Details zur Erfassung einzelner Felder werden uns verborgen bleiben. Wir können die Zellen jedoch nicht direkt serialisieren. Dies sind MonoBehaviour Klassen, die Daten enthalten, die wir nicht speichern müssen. Daher müssen wir eine separate Hierarchie von Objekten verwenden, was die Einfachheit der automatischen Serialisierung zerstört. Darüber hinaus wird es schwieriger sein, zukünftige Codeänderungen zu unterstützen. Daher behalten wir die volle Kontrolle über die manuelle Serialisierung. Außerdem werden wir wirklich verstehen, was passiert.

Um die Karte zu serialisieren, müssen wir die Daten jeder Zelle speichern. HexCell Methoden Save und Load hinzu, um eine einzelne Zelle zu Save und zu HexCell . Da sie einen Schreiber oder Leser benötigen, um zu arbeiten, werden wir sie als Parameter hinzufügen.

 using UnityEngine; using System.IO; public class HexCell : MonoBehaviour { … public void Save (BinaryWriter writer) { } public void Load (BinaryReader reader) { } } 

Fügen Save HexGrid Methoden zum Save und Load HexGrid . Diese Methoden umgehen einfach alle Zellen, indem sie ihre Load und Save Methoden aufrufen.

 using UnityEngine; using UnityEngine.UI; using System.IO; public class HexGrid : MonoBehaviour { … public void Save (BinaryWriter writer) { for (int i = 0; i < cells.Length; i++) { cells[i].Save(writer); } } public void Load (BinaryReader reader) { for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } } } 

Wenn wir eine Karte herunterladen, muss sie aktualisiert werden, nachdem die Zellendaten geändert wurden. Aktualisieren Sie dazu einfach alle Fragmente.

  public void Load (BinaryReader reader) { for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } for (int i = 0; i < chunks.Length; i++) { chunks[i].Refresh(); } } 

Schließlich ersetzen wir unseren HexMapEditor in HexMapEditor durch Aufrufe der Save and Load Methoden des Grids, wobei wir den Writer oder Reader damit übergeben.

  public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { hexGrid.Save(writer); } } public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { hexGrid.Load(reader); } } 

Relieftyp speichern


Beim erneuten Speichern wird derzeit eine leere Datei erstellt, und beim Herunterladen wird nichts ausgeführt. Beginnen wir schrittweise, indem wir nur den HexCell Höhenindex aufzeichnen und laden.

Weisen Sie den Wert direkt dem Feld TerrainTypeIndex zu. Wir werden keine Eigenschaften verwenden. Da wir alle Fragmente explizit aktualisieren, sind keine Aufrufe der Aktualisierungseigenschaften erforderlich. Da wir nur die richtigen Karten speichern, gehen wir außerdem davon aus, dass alle heruntergeladenen Karten auch korrekt sind. Daher werden wir beispielsweise nicht prüfen, ob der Fluss oder die Straße zulässig ist.

  public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); } public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); } 

Beim Speichern in dieser Datei wird nacheinander der Index des Relieftyps aller Zellen geschrieben. Da der Index eine Ganzzahl ist, beträgt seine Größe vier Bytes. Meine Karte enthält 300 Zellen, d. H. Die Dateigröße beträgt 1200 Byte.

Das Laden liest die Indizes in derselben Reihenfolge, in der sie geschrieben wurden. Wenn Sie die Farben der Zellen nach dem Speichern geändert haben, werden beim Laden der Karte die Farben beim Speichern in den Status zurückgesetzt. Da wir nichts mehr speichern, bleiben die restlichen Zellendaten gleich. Das heißt, durch das Laden wird die Art des Geländes geändert, nicht jedoch die Höhe, der Wasserstand, die Geländemerkmale usw.

Alle Ganzzahlen speichern


Das Speichern eines Relief-Index reicht uns nicht aus. Sie müssen alle anderen Daten speichern. Beginnen wir mit allen ganzzahligen Feldern. Dies ist ein Index der Art des Reliefs, der Zellhöhe, des Wasserspiegels, des Stadtspiegels, des Bauernhofniveaus, des Vegetationsniveaus und des Index spezieller Objekte. Sie müssen in derselben Reihenfolge gelesen werden, in der sie aufgezeichnet wurden.

  public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); writer.Write(elevation); writer.Write(waterLevel); writer.Write(urbanLevel); writer.Write(farmLevel); writer.Write(plantLevel); writer.Write(specialIndex); } public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); waterLevel = reader.ReadInt32(); urbanLevel = reader.ReadInt32(); farmLevel = reader.ReadInt32(); plantLevel = reader.ReadInt32(); specialIndex = reader.ReadInt32(); } 

Versuchen Sie jetzt, die Karte zu speichern und zu laden, und nehmen Sie zwischen diesen Vorgängen Änderungen vor. Alles, was wir in die gespeicherten Daten aufgenommen haben, wurde so gut wie möglich wiederhergestellt, mit Ausnahme der Höhe der Zelle. Dies geschah, weil Sie beim Ändern der Höhenstufe die vertikale Position der Zelle aktualisieren müssen. Dies kann erreicht werden, indem der Eigenschaft und nicht dem Feld der Wert der geladenen Höhe zugewiesen wird. Diese Eigenschaft erledigt jedoch zusätzliche Arbeiten, die wir nicht benötigen. Extrahieren wir daher den Code, der die RefreshPosition aktualisiert, aus dem Elevation Setter und fügen ihn in eine separate RefreshPosition Methode ein. Die einzige Änderung, die Sie hier vornehmen müssen, besteht darin, den value Verweis auf das elevation zu ersetzen.

  void RefreshPosition () { Vector3 position = transform.localPosition; position.y = elevation * 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; } 

Jetzt können wir die Methode beim Festlegen der Eigenschaft sowie nach dem Laden der Höhendaten aufrufen.

  public int Elevation { … set { if (elevation == value) { return; } elevation = value; RefreshPosition(); ValidateRivers(); … } } … public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); RefreshPosition(); … } 

Nach dieser Änderung ändern die Zellen beim Laden korrekt ihre scheinbare Höhe.

Alle Daten speichern


Das Vorhandensein von Mauern und ein- und ausgehenden Flüssen in der Zelle wird in Booleschen Feldern gespeichert. Wir können sie einfach als ganze Zahl schreiben. Außerdem sind Straßendaten ein Array von sechs Booleschen Werten, die wir mit einer Schleife schreiben können.

  public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); writer.Write(elevation); writer.Write(waterLevel); writer.Write(urbanLevel); writer.Write(farmLevel); writer.Write(plantLevel); writer.Write(specialIndex); writer.Write(walled); writer.Write(hasIncomingRiver); writer.Write(hasOutgoingRiver); for (int i = 0; i < roads.Length; i++) { writer.Write(roads[i]); } } 

HexDirection für eingehende und ausgehende Flüsse werden in HexDirection Feldern gespeichert. Der HexDirection Typ ist eine Aufzählung, die intern als mehrere ganzzahlige Werte gespeichert wird. Daher können wir sie auch als Ganzzahl mithilfe einer expliziten Konvertierung serialisieren.

  writer.Write(hasIncomingRiver); writer.Write((int)incomingRiver); writer.Write(hasOutgoingRiver); writer.Write((int)outgoingRiver); 

Boolesche Werte werden mit der BinaryReader.ReadBoolean Methode gelesen. Die Richtungen der Flüsse sind ganzzahlig, die wir zurück in HexDirection konvertieren HexDirection .

  public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); RefreshPosition(); waterLevel = reader.ReadInt32(); urbanLevel = reader.ReadInt32(); farmLevel = reader.ReadInt32(); plantLevel = reader.ReadInt32(); specialIndex = reader.ReadInt32(); walled = reader.ReadBoolean(); hasIncomingRiver = reader.ReadBoolean(); incomingRiver = (HexDirection)reader.ReadInt32(); hasOutgoingRiver = reader.ReadBoolean(); outgoingRiver = (HexDirection)reader.ReadInt32(); for (int i = 0; i < roads.Length; i++) { roads[i] = reader.ReadBoolean(); } } 

Jetzt speichern wir alle Zellendaten, die zum vollständigen Speichern und Wiederherstellen der Karte erforderlich sind.Dies erfordert neun Ganzzahlen und neun Boolesche Werte pro Zelle. Jeder Boolesche Wert benötigt ein Byte, daher verwenden wir insgesamt 45 Bytes pro Zelle. Das heißt, eine Karte mit 300 Zellen benötigt insgesamt 13.500 Bytes.

Einheitspaket

Dateigröße reduzieren


Obwohl es den Anschein hat, dass 13.500 Bytes für 300 Zellen nicht sehr viel sind, können wir vielleicht mit einer geringeren Menge fertig werden. Am Ende haben wir die volle Kontrolle darüber, wie Daten serialisiert werden. Mal sehen, ob es eine kompaktere Möglichkeit gibt, sie zu speichern.

Numerische Intervallreduzierung


Verschiedene Zellebenen und Indizes werden als Ganzzahl gespeichert. Sie verwenden jedoch nur einen kleinen Wertebereich. Jeder von ihnen wird definitiv im Bereich von 0 bis 255 bleiben. Dies bedeutet, dass nur das erste Byte jeder Ganzzahl verwendet wird. Die restlichen drei sind immer Null. Es macht keinen Sinn, diese leeren Bytes zu speichern. Wir können sie verwerfen, indem wir Ganzzahl zu Byte schreiben, bevor wir in den Stream schreiben.

  writer.Write((byte)terrainTypeIndex); writer.Write((byte)elevation); writer.Write((byte)waterLevel); writer.Write((byte)urbanLevel); writer.Write((byte)farmLevel); writer.Write((byte)plantLevel); writer.Write((byte)specialIndex); writer.Write(walled); writer.Write(hasIncomingRiver); writer.Write((byte)incomingRiver); writer.Write(hasOutgoingRiver); writer.Write((byte)outgoingRiver); 

Um diese Zahlen zurückzugeben, müssen wir verwenden BinaryReader.ReadByte. Die Konvertierung von Byte in Ganzzahl erfolgt implizit, sodass keine expliziten Konvertierungen hinzugefügt werden müssen.

  terrainTypeIndex = reader.ReadByte(); elevation = reader.ReadByte(); RefreshPosition(); waterLevel = reader.ReadByte(); urbanLevel = reader.ReadByte(); farmLevel = reader.ReadByte(); plantLevel = reader.ReadByte(); specialIndex = reader.ReadByte(); walled = reader.ReadBoolean(); hasIncomingRiver = reader.ReadBoolean(); incomingRiver = (HexDirection)reader.ReadByte(); hasOutgoingRiver = reader.ReadBoolean(); outgoingRiver = (HexDirection)reader.ReadByte(); 

Wir werden also drei Bytes pro Ganzzahl los, was 27 Bytes pro Zelle spart. Jetzt geben wir 18 Bytes pro Zelle und nur 5.400 Bytes pro 300 Zellen aus.

Es ist erwähnenswert, dass die alten Kartendaten zu diesem Zeitpunkt bedeutungslos werden. Beim Laden des alten Speichers werden die Daten verwechselt und es entstehen verwirrende Zellen. Dies liegt daran, dass wir jetzt weniger Daten lesen. Wenn wir mehr Daten als zuvor lesen, wird beim Versuch, über das Ende der Datei hinaus zu lesen, eine Fehlermeldung angezeigt.

Die Unfähigkeit, alte Daten zu verarbeiten, passt zu uns, da wir gerade dabei sind, das Format zu bestimmen. Wenn wir uns jedoch für das Speicherformat entscheiden, müssen wir sicherstellen, dass zukünftiger Code es immer lesen kann. Selbst wenn wir das Format ändern, sollten wir im Idealfall das alte Format lesen können.

River Byte Union


In dieser Phase verwenden wir vier Bytes zum Speichern von Flussdaten, zwei pro Richtung. Für jede Richtung speichern wir das Vorhandensein des Flusses und die Richtung, in die er fließt.

Es scheint offensichtlich, dass wir die Richtung des Flusses nicht speichern müssen, wenn dies nicht der Fall ist. Dies bedeutet, dass Zellen ohne Fluss zwei Bytes weniger benötigen. Tatsächlich wird ein Byte in Richtung des Flusses für uns ausreichen, unabhängig von seiner Existenz.

Wir haben sechs mögliche Richtungen, die als Zahlen im Intervall 0–5 gespeichert sind. Dafür reichen drei Bits aus, denn in binärer Form sehen Zahlen von 0 bis 5 wie 000, 001, 010, 011, 100, 101 und 110 aus. Das heißt, fünf weitere Bits bleiben in einem Byte unbenutzt. Wir können einen davon verwenden, um anzuzeigen, ob ein Fluss existiert. Sie können beispielsweise das achte Bit verwenden, das der Zahl 128 entspricht.

Dazu addieren wir 128, bevor wir die Richtung in Bytes umwandeln. Wenn also ein Fluss nach Nordwesten fließt, schreiben wir 133, was in binärer Form 10000101 ist. Und wenn es keinen Fluss gibt, schreiben wir einfach ein Null-Byte.

Gleichzeitig bleiben vier weitere Bits unbenutzt, dies ist jedoch normal. Wir können beide Richtungen des Flusses in einem Byte kombinieren, aber das wird schon zu verwirrend sein.

 // writer.Write(hasIncomingRiver); // writer.Write((byte)incomingRiver); if (hasIncomingRiver) { writer.Write((byte)(incomingRiver + 128)); } else { writer.Write((byte)0); } // writer.Write(hasOutgoingRiver); // writer.Write((byte)outgoingRiver); if (hasOutgoingRiver) { writer.Write((byte)(outgoingRiver + 128)); } else { writer.Write((byte)0); } 

Um Flussdaten zu dekodieren, müssen wir zuerst das Byte zurücklesen. Wenn sein Wert nicht weniger als 128 beträgt, bedeutet dies, dass es einen Fluss gibt. Subtrahieren Sie 128 und konvertieren Sie dann zu HexDirection.

 // hasIncomingRiver = reader.ReadBoolean(); // incomingRiver = (HexDirection)reader.ReadByte(); byte riverData = reader.ReadByte(); if (riverData >= 128) { hasIncomingRiver = true; incomingRiver = (HexDirection)(riverData - 128); } else { hasIncomingRiver = false; } // hasOutgoingRiver = reader.ReadBoolean(); // outgoingRiver = (HexDirection)reader.ReadByte(); riverData = reader.ReadByte(); if (riverData >= 128) { hasOutgoingRiver = true; outgoingRiver = (HexDirection)(riverData - 128); } else { hasOutgoingRiver = false; } 

Als Ergebnis haben wir 16 Bytes pro Zelle. Die Verbesserung scheint nicht groß zu sein, aber dies ist einer dieser Tricks, mit denen die Größe von Binärdaten reduziert wird.

Speichern Sie Straßen in einem Byte


Wir können einen ähnlichen Trick verwenden, um Straßendaten zu komprimieren. Wir haben sechs boolesche Werte, die in den ersten sechs Bits eines Bytes gespeichert werden können. Das heißt, jede Richtung der Straße wird durch eine Zahl dargestellt, die eine Zweierpotenz ist. Dies sind 1, 2, 4, 8, 16 und 32 oder in binärer Form 1, 10, 100, 1000, 10000 und 100000.

Um ein fertiges Byte zu erstellen, müssen wir die Bits setzen, die den verwendeten Richtungen der Straßen entsprechen. Um die richtige Richtung für die Richtung zu erhalten, können wir den Operator verwenden <<. Kombinieren Sie sie dann mit dem bitweisen ODER-Operator. Wenn beispielsweise die erste, zweite, dritte und sechste Straße verwendet werden, lautet das fertige Byte 100111.

  int roadFlags = 0; for (int i = 0; i < roads.Length; i++) { // writer.Write(roads[i]); if (roads[i]) { roadFlags |= 1 << i; } } writer.Write((byte)roadFlags); 

Wie funktioniert <<?
. integer . . integer . , . 1 << n 2 n , .

Um den Booleschen Wert der Straße zurück zu erhalten, müssen Sie überprüfen, ob das Bit gesetzt ist. Wenn ja, maskieren Sie alle anderen Bits mit dem bitweisen UND-Operator mit der entsprechenden Nummer. Wenn das Ergebnis nicht gleich Null ist, wird das Bit gesetzt und die Straße existiert.

  int roadFlags = reader.ReadByte(); for (int i = 0; i < roads.Length; i++) { roads[i] = (roadFlags & (1 << i)) != 0; } 

Nachdem wir sechs Bytes zu einem zusammengedrückt hatten, erhielten wir 11 Bytes pro Zelle. Bei 300 Zellen sind dies nur 3.300 Bytes. Nachdem wir ein wenig mit Bytes gearbeitet haben, haben wir die Dateigröße um 75% reduziert.

Immer bereit für die Zukunft


Bevor wir unser Speicherformat für vollständig erklären, fügen wir noch ein Detail hinzu. Vor dem Speichern der Kartendaten wird erzwungen HexMapEditor, eine ganzzahlige Null zu schreiben.

  public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(0); hexGrid.Save(writer); } } 

Dadurch werden am Anfang unserer Daten vier leere Bytes hinzugefügt. Das heißt, bevor wir die Karte laden, müssen wir diese vier Bytes lesen.

  public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { reader.ReadInt32(); hexGrid.Load(reader); } } 

Obwohl diese Bytes bisher unbrauchbar sind, werden sie als Header verwendet, der in Zukunft Abwärtskompatibilität bietet. Wenn wir diese Null-Bytes nicht hinzugefügt hatten, hing der Inhalt der ersten paar Bytes von der ersten Zelle der Karte ab. Daher wäre es für uns in Zukunft schwieriger herauszufinden, mit welcher Version des Sicherungsformats wir es zu tun haben. Jetzt können wir nur die ersten vier Bytes überprüfen. Wenn sie leer sind, handelt es sich um eine Version des Formats 0. In zukünftigen Versionen wird es möglich sein, dort etwas anderes hinzuzufügen.

Das heißt, wenn der Titel nicht Null ist, handelt es sich um eine unbekannte Version. Da wir nicht herausfinden können, welche Daten vorhanden sind, müssen wir das Herunterladen der Karte verweigern.

  using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header == 0) { hexGrid.Load(reader); } else { Debug.LogWarning("Unknown map format " + header); } } 


Einheitspaket

Teil 13: Kartenverwaltung


  • Wir erstellen neue Karten im Spielmodus.
  • Unterstützung für verschiedene Kartengrößen hinzufügen.
  • Fügen Sie den gespeicherten Daten die Größe der Karte hinzu.
  • Speichern und laden Sie beliebige Karten.
  • Zeigen Sie eine Liste der Karten an.

In diesem Teil werden wir Unterstützung für verschiedene Kartengrößen sowie das Speichern verschiedener Dateien hinzufügen.

Ab diesem Teil werden Tutorials in Unity 5.5.0 erstellt.


Der Anfang der Kartenbibliothek.

Neue Karten erstellen


Bis zu diesem Punkt haben wir das Sechseckraster nur einmal erstellt - beim Laden der Szene. Jetzt können Sie jederzeit eine neue Karte erstellen. Die neue Karte ersetzt einfach die aktuelle.

In Awake HexGridwerden einige Metriken initialisiert, und dann wird die Anzahl der Zellen bestimmt und die erforderlichen Fragmente und Zellen erstellt. Wenn wir einen neuen Satz von Fragmenten und Zellen erstellen, erstellen wir eine neue Karte. HexGrid.AwakeTeilen wir uns in zwei Teile auf - den Initialisierungsquellcode und die allgemeine Methode CreateMap.

  void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; CreateMap(); } public void CreateMap () { cellCountX = chunkCountX * HexMetrics.chunkSizeX; cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); } 

Fügen Sie der Benutzeroberfläche eine Schaltfläche hinzu, um eine neue Karte zu erstellen. Ich habe es groß gemacht und es unter die Schaltflächen Speichern und Laden gelegt.


Neue Kartenschaltfläche.

Verbinden wir das On Click- Ereignis dieser Schaltfläche mit der Methode CreateMapunseres Objekts HexGrid. Das heißt, wir werden nicht den Hex Map Editor durchlaufen , sondern direkt die Hex Grid- Objektmethode aufrufen .


Erstellen Sie eine Karte, indem Sie auf klicken.

Alte Daten löschen


Wenn Sie nun auf die Schaltfläche Neue Karte klicken , wird ein neuer Satz von Fragmenten und Zellen erstellt. Die alten werden jedoch nicht automatisch gelöscht. Als Ergebnis erhalten wir mehrere übereinanderliegende Kartennetze. Um dies zu vermeiden, müssen wir zuerst alte Objekte entfernen. Dies kann erreicht werden, indem zu Beginn alle aktuellen Fragmente zerstört werden CreateMap.

  public void CreateMap () { if (chunks != null) { for (int i = 0; i < chunks.Length; i++) { Destroy(chunks[i].gameObject); } } … } 

Können wir vorhandene Objekte wiederverwenden?
, . , . , — , .

Ist es möglich, untergeordnete Elemente wie diese in einer Schleife zu zerstören?
Natürlich. .

Geben Sie die Größe in Zellen anstelle von Fragmenten an


Während wir die Größe der Karte durch die Felder chunkCountXund das chunkCountZObjekt einstellen HexGrid. Es ist jedoch viel bequemer, die Größe der Karte in Zellen anzugeben. Gleichzeitig können wir in Zukunft sogar die Größe des Fragments ändern, ohne die Größe der Karten zu ändern. Lassen Sie uns daher die Rollen der Anzahl der Zellen und der Anzahl der Fragmentfelder vertauschen.

 // public int chunkCountX = 4, chunkCountZ = 3; public int cellCountX = 20, cellCountZ = 15; … // int cellCountX, cellCountZ; int chunkCountX, chunkCountZ; … public void CreateMap () { … // cellCountX = chunkCountX * HexMetrics.chunkSizeX; // cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ; chunkCountX = cellCountX / HexMetrics.chunkSizeX; chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); } 

Dies führt zu einem Kompilierungsfehler, da HexMapCameraFragmentgrößen verwendet werden , um die Position zu begrenzen . Ändern Sie dies HexMapCamera.ClampPositionso, dass er direkt die Anzahl der Zellen verwendet, die er noch benötigt.

  Vector3 ClampPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * (2f * HexMetrics.innerRadius); position.x = Mathf.Clamp(position.x, 0f, xMax); float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); return position; } 

Ein Fragment hat eine Größe von 5 x 5 Zellen, und Karten haben standardmäßig eine Größe von 4 x 3 Fragmenten. Um die Karten gleich zu halten, müssen wir daher eine Größe von 20 x 15 Zellen verwenden. Und obwohl wir im Code Standardwerte zugewiesen haben, werden diese vom Rasterobjekt immer noch nicht automatisch verwendet, da die Felder bereits vorhanden und standardmäßig auf 0 gesetzt waren.


Standardmäßig hat die Karte eine Größe von 20 x 15.

Benutzerdefinierte Kartengrößen


Der nächste Schritt ist die Unterstützung für das Erstellen von Karten jeder Größe, nicht nur der Standardgröße. HexGrid.CreateMapFügen Sie dazu den Parametern X und Z hinzu. Sie ersetzen die vorhandene Anzahl von Zellen. Im Inneren werden Awakewir sie nur mit der aktuellen Anzahl von Zellen aufrufen.

  void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; CreateMap(cellCountX, cellCountZ); } public void CreateMap (int x, int z) { … cellCountX = x; cellCountZ = z; chunkCountX = cellCountX / HexMetrics.chunkSizeX; chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); } 

Dies funktioniert jedoch nur mit der Anzahl der Zellen, die ein Vielfaches der Fragmentgröße beträgt, korrekt. Andernfalls werden durch die Ganzzahldivision zu wenige Fragmente erstellt. Obwohl wir Unterstützung für Fragmente hinzufügen können, die teilweise mit Zellen gefüllt sind, verbieten wir einfach die Verwendung von Größen, die nicht Fragmenten entsprechen.

Wir können den Operator verwenden %, um den Rest der Division der Anzahl der Zellen durch die Anzahl der Fragmente zu berechnen. Wenn es nicht gleich Null ist, gibt es eine Diskrepanz und wir werden keine neue Karte erstellen. Und während wir dies tun, fügen wir Schutz vor Null und negativen Größen hinzu.

  public void CreateMap (int x, int z) { if ( x <= 0 || x % HexMetrics.chunkSizeX != 0 || z <= 0 || z % HexMetrics.chunkSizeZ != 0 ) { Debug.LogError("Unsupported map size."); return; } … } 

Neues Kartenmenü


In der aktuellen Phase funktioniert die Schaltfläche Neue Karte nicht mehr, da die Methode HexGrid.CreateMapjetzt zwei Parameter hat. Wir können Unity-Ereignisse nicht direkt mit solchen Methoden verbinden. Um verschiedene Kartengrößen zu unterstützen, benötigen wir außerdem einige Schaltflächen. Anstatt alle diese Schaltflächen zur Hauptbenutzeroberfläche hinzuzufügen, erstellen wir ein separates Popup-Menü.

Fügen Sie der Szene eine neue Leinwand hinzu ( GameObject / UI / Canvas ). Wir werden die gleichen Einstellungen wie die vorhandene Zeichenfläche verwenden, außer dass die Sortierreihenfolge gleich 1 sein sollte. Dank dieser Option befindet sie sich über der Benutzeroberfläche des Haupteditors. Ich habe sowohl die Zeichenfläche als auch das Ereignissystem zu einem untergeordneten Element des neuen UI-Objekts gemacht, damit die Szenenhierarchie sauber bleibt.



Canvas-Menü Neue Karte.

Fügen Sie dem Menü "Neue Karte" ein Bedienfeld hinzu , das den gesamten Bildschirm schließt. Es ist erforderlich, um den Hintergrund abzudunkeln und dem Cursor nicht zu erlauben, mit allem anderen zu interagieren, wenn das Menü geöffnet ist. Ich gab ihm eine einheitliche Farbe, löschte sein Quellbild und stellte (0, 0, 0, 200) als Farbe ein .


Hintergrundbildeinstellungen.

Fügen Sie in der Mitte der Leinwand eine Menüleiste hinzu, ähnlich wie in den Hex Map Editor- Bedienfeldern . Lassen Sie uns ein klares Etikett und Schaltflächen für ihre kleinen, mittleren und großen Karten erstellen. Wir werden ihr auch eine Schaltfläche zum Abbrechen hinzufügen, falls der Spieler seine Meinung ändert. Deaktivieren Sie nach Abschluss der Erstellung des Designs das gesamte Menü "Neue Karte" .



Neues Kartenmenü.

Um das Menü zu verwalten, erstellen Sie eine Komponente NewMapMenuund fügen Sie sie dem Canvas New Map Menu- Objekt hinzu . Um eine neue Karte zu erstellen, benötigen wir Zugriff auf das Hex Grid- Objekt . Daher fügen wir ein gemeinsames Feld hinzu und verbinden es.

 using UnityEngine; public class NewMapMenu : MonoBehaviour { public HexGrid hexGrid; } 


Komponente des neuen Kartenmenüs.

Öffnen und Schließen


Wir können das Popup-Menü öffnen und schließen, indem wir einfach das Canvas-Objekt aktivieren und deaktivieren. Fügen wir dazu NewMapMenuzwei gängige Methoden hinzu.

  public void Open () { gameObject.SetActive(true); } public void Close () { gameObject.SetActive(false); } 

Verbinden Sie nun die Schaltfläche New Map UI des Editors mit der Methode Openim New Map Menu- Objekt .


Öffnen Sie das Menü durch Drücken von.

Verbinden Sie auch die Schaltfläche Abbrechen mit der Methode Close. Dadurch können wir das Popup-Menü öffnen und schließen.

Neue Karten erstellen


Um neue Karten zu erstellen, müssen wir die Methode im Hex Grid- Objekt aufrufen CreateMap. Außerdem müssen wir danach das Popup-Menü schließen. Fügen Sie der NewMapMenuMethode, die dies behandelt, unter Berücksichtigung einer beliebigen Größe hinzu.

  void CreateMap (int x, int z) { hexGrid.CreateMap(x, z); Close(); } 

Diese Methode sollte nicht allgemein sein, da wir sie immer noch nicht direkt mit Schaltflächenereignissen verbinden können. Erstellen Sie stattdessen eine Methode pro Schaltfläche, die CreateMapmit der angegebenen Größe aufgerufen wird . Für eine kleine Karte habe ich eine Größe von 20 x 15 verwendet, die der Standardgröße der Karte entspricht. Für die mittlere Karte habe ich beschlossen, diese Größe zu verdoppeln, 40 mal 30 zu erhalten und sie für die große Karte erneut zu verdoppeln. Verbinden Sie die Tasten mit den entsprechenden Methoden.

  public void CreateSmallMap () { CreateMap(20, 15); } public void CreateMediumMap () { CreateMap(40, 30); } public void CreateLargeMap () { CreateMap(80, 60); } 

Kamerasperre


Jetzt können wir das Popup-Menü verwenden, um neue Karten mit drei verschiedenen Größen zu erstellen! Alles funktioniert gut, aber wir müssen uns um ein kleines Detail kümmern. Wenn das Menü "Neue Karte" aktiv ist, können wir nicht mehr mit der Benutzeroberfläche des Editors interagieren und Zellen bearbeiten. Wir können die Kamera jedoch weiterhin steuern. Idealerweise sollte die Kamera bei geöffnetem Menü gesperrt sein.

Da wir nur eine Kamera haben, besteht eine schnelle und pragmatische Lösung darin, einfach eine statische Eigenschaft hinzuzufügen Locked. Für den weit verbreiteten Einsatz ist diese Lösung nicht sehr geeignet, aber für unsere einfache Benutzeroberfläche reicht sie aus. Dies erfordert, dass wir die statische Instanz im Inneren verfolgen HexMapCamera, die bei der Awake-Kamera eingestellt wird.

  static HexMapCamera instance; … void Awake () { instance = this; swivel = transform.GetChild(0); stick = swivel.GetChild(0); } 

Eine Eigenschaft Lockedkann nur mit einem Setter eine einfache statische boolesche Eigenschaft sein. Alles, was es tut, ist, die Instanz auszuschalten, HexMapCamerawenn sie gesperrt ist, und sie einzuschalten, wenn sie entsperrt ist.

  public static bool Locked { set { instance.enabled = !value; } } 

Jetzt NewMapMenu.Openkann es die Kamera blockieren und NewMapMenu.Close- entsperren.

  public void Open () { gameObject.SetActive(true); HexMapCamera.Locked = true; } public void Close () { gameObject.SetActive(false); HexMapCamera.Locked = false; } 

Beibehaltung der richtigen Kameraposition


Es gibt ein weiteres wahrscheinliches Problem mit der Kamera. Wenn Sie eine neue Karte erstellen, die kleiner als die aktuelle ist, wird die Kamera möglicherweise außerhalb der Kartenränder angezeigt. Sie bleibt dort, bis der Spieler versucht, die Kamera zu bewegen. Und nur dann wird es durch die Grenzen der neuen Karte begrenzt.

Um dieses Problem zu lösen, können wir die HexMapCamerastatische Methode ergänzen ValidatePosition. Wenn Sie eine AdjustPositionInstanzmethode mit einem Versatz von Null aufrufen, wird die Kamera gezwungen, sich an die Ränder der Karte zu bewegen. Befindet sich die Kamera bereits innerhalb der Grenzen der neuen Karte, bleibt sie an Ort und Stelle.

  public static void ValidatePosition () { instance.AdjustPosition(0f, 0f); } 

Rufen Sie die darin enthaltene Methode auf, NewMapMenu.CreateMapnachdem Sie eine neue Karte erstellt haben.

  void CreateMap (int x, int z) { hexGrid.CreateMap(x, z); HexMapCamera.ValidatePosition(); Close(); } 

Einheitspaket

Kartengröße speichern


Obwohl wir Karten unterschiedlicher Größe erstellen können, wird dies beim Speichern und Laden nicht berücksichtigt. Dies bedeutet, dass das Laden einer Karte zu einem Fehler oder einer falschen Karte führt, wenn die Größe der aktuellen Karte nicht mit der Größe der geladenen Karte übereinstimmt.

Um dieses Problem zu lösen, müssen wir vor dem Laden der Zellendaten eine neue Karte mit der entsprechenden Größe erstellen. Nehmen wir an, wir haben eine kleine Karte gespeichert. In diesem Fall ist alles in Ordnung, wenn wir zu Beginn HexGrid.Loadeine 20 x 15-Karte erstellen .

  public void Load (BinaryReader reader) { CreateMap(20, 15); for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } for (int i = 0; i < chunks.Length; i++) { chunks[i].Refresh(); } } 

Speicher in Kartengröße


Natürlich können wir eine Karte jeder Größe aufbewahren. Daher besteht eine verallgemeinerte Lösung darin, die Größe der Karte vor diesen Zellen zu speichern.

  public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); for (int i = 0; i < cells.Length; i++) { cells[i].Save(writer); } } 

Dann können wir die wahre Größe ermitteln und daraus eine Karte mit den richtigen Größen erstellen.

  public void Load (BinaryReader reader) { CreateMap(reader.ReadInt32(), reader.ReadInt32()); … } 

Da wir jetzt Karten unterschiedlicher Größe laden können, stehen wir erneut vor dem Problem der Kameraposition. Wir werden es lösen, indem wir seine Position HexMapEditor.Loadnach dem Laden der Karte einchecken .

  public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header == 0) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } } 

Neues Dateiformat


Obwohl dieser Ansatz mit Karten funktioniert, die wir in Zukunft behalten werden, funktioniert er nicht mit alten. Und umgekehrt - der Code aus dem vorherigen Teil des Tutorials kann neue Kartendateien nicht korrekt laden. Um zwischen alten und neuen Formaten zu unterscheiden, erhöhen wir den ganzzahligen Wert des Headers. Das alte Speicherformat ohne Kartengröße hatte Version 0. Das neue Format mit Kartengröße hat Version 1. Daher sollte bei der Aufnahme HexMapEditor.Save1 anstelle von 0 geschrieben werden.

  public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(1); hexGrid.Save(writer); } } 

Von nun an werden die Karten als Version 1 gespeichert. Wenn wir versuchen, sie in der Assembly aus dem vorherigen Lernprogramm zu öffnen, lehnen sie es ab, ein unbekanntes Kartenformat zu laden und zu melden. In der Tat wird dies passieren, wenn wir bereits versuchen, eine solche Karte zu laden. Sie müssen die Methode HexMapEditor.Loadso ändern , dass sie die neue Version akzeptiert.

  public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header == 1) { hexGrid.Load(reader); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } } 

Abwärtskompatibilität


Wenn wir möchten, können wir weiterhin Karten der Version 0 herunterladen, vorausgesetzt, sie haben alle die gleiche Größe von 20 x 15. Das heißt, der Titel muss nicht 1 sein, sondern kann auch Null sein. Da jede Version ihren eigenen Ansatz erfordert, HexMapEditor.Loadmuss der Header an die Methode übergeben werden HexGrid.Load.

  if (header <= 1) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); } 

Fügen Sie dem HexGrid.LoadParameter einen Titel hinzu und treffen Sie damit Entscheidungen über weitere Aktionen. Wenn der Header nicht kleiner als 1 ist, müssen Sie die Daten zur Kartengröße lesen. Andernfalls verwenden wir die alte feste Kartengröße von 20 x 15 und überspringen das Lesen der Größendaten.

  public void Load (BinaryReader reader, int header) { int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } CreateMap(x, z); … } 

Kartendatei Version 0

Kartengrößenprüfung


Wie beim Erstellen einer neuen Karte ist es theoretisch möglich, dass wir eine Karte laden müssen, die mit der Fragmentgröße nicht kompatibel ist. In diesem Fall müssen wir den Download der Karte unterbrechen. HexGrid.CreateMapweigert sich bereits, eine Karte zu erstellen und zeigt einen Fehler in der Konsole an. Um dies dem Aufrufer der Methode mitzuteilen, geben wir einen Bool zurück, der angibt, ob die Map erstellt wurde.

  public bool CreateMap (int x, int z) { if ( x <= 0 || x % HexMetrics.chunkSizeX != 0 || z <= 0 || z % HexMetrics.chunkSizeZ != 0 ) { Debug.LogError("Unsupported map size."); return false; } … return true; } 

Jetzt HexGrid.Loadkann die Ausführung auch gestoppt werden, wenn die Kartenerstellung fehlschlägt.

  public void Load (BinaryReader reader, int header) { int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } if (!CreateMap(x, z)) { return; } … } 

Da beim Laden alle Daten in vorhandenen Zellen überschrieben werden, müssen wir keine neue Karte erstellen, wenn eine Karte derselben Größe geladen wird. Daher kann dieser Schritt übersprungen werden.

  if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z)) { return; } } 

Einheitspaket

Dateiverwaltung


Wir können Karten unterschiedlicher Größe speichern und laden, aber immer test.map schreiben und lesen . Jetzt werden wir Unterstützung für verschiedene Dateien hinzufügen.

Anstatt die Karte direkt zu speichern oder zu laden, verwenden wir ein anderes Popup-Menü, das eine erweiterte Dateiverwaltung bietet. Erstellen Sie eine weitere Zeichenfläche, wie im Menü "Neue Karte" , aber dieses Mal nennen wir sie " Menü" Laden laden " . In diesem Menü werden Karten gespeichert und geladen, je nachdem, welche Taste zum Öffnen gedrückt wurde.

Wir werden das Design zum Speichern des Lademenüs erstellen.als wäre es ein Speichermenü. Später werden wir es dynamisch in ein Boot-Menü verwandeln. Wie ein anderes Menü sollte es einen Hintergrund und eine Menüleiste, eine Menübezeichnung und eine Abbrechen-Schaltfläche haben. Fügen Sie dann dem Menü eine Bildlaufansicht ( GameObject / UI / Bildlaufansicht ) hinzu, um eine Liste der Dateien anzuzeigen. Unten fügen wir das Eingabefeld ( GameObject / UI / Eingabefeld ) ein, um die Namen der neuen Karten anzugeben. Wir brauchen auch eine Aktionsschaltfläche, um die Karte zu speichern. Und endlich. Fügen Sie eine Schaltfläche Löschen hinzu , um unnötige Karten zu löschen.



Design Save Load-Menü.

Standardmäßig erlaubt die Bildlaufansicht sowohl horizontales als auch vertikales Scrollen, wir benötigen jedoch nur eine Liste mit vertikalem Bildlauf. Daher deaktivieren Scrollen horizontal und ziehen Sie die horizontale Bildlaufleiste. Wir setzen außerdem den Bewegungstyp auf "Klemmen" und deaktivieren die Trägheit , damit die Liste restriktiver erscheint.


Dateilistenoptionen.

Entfernen Sie das Kind Element Scrollbar Horizontal von dem Objekt von der Datei einer Liste , weil es nicht benötigt werden. Ändern Sie dann die Größe der Bildlaufleiste vertikal, sodass sie am Ende der Liste ankommt .

Platzhalter Textobjekt Name Eingabe kann in seine Kinder geändert werden Platzhalter . Ich habe beschreibenden Text verwendet, aber Sie können ihn einfach leer lassen und den Platzhalter entfernen.


Menügestaltung geändert.

Wir sind mit dem Design fertig und deaktivieren jetzt das Menü, sodass es standardmäßig ausgeblendet ist.

Menüverwaltung


Damit das Menü funktioniert, benötigen wir in diesem Fall ein anderes Skript - SaveLoadMenu. Wie NewMapMenues erfordert einen Verweis auf das Gitter, sowie Methoden Openund Close.

 using UnityEngine; public class SaveLoadMenu : MonoBehaviour { public HexGrid hexGrid; public void Open () { gameObject.SetActive(true); HexMapCamera.Locked = true; } public void Close () { gameObject.SetActive(false); HexMapCamera.Locked = false; } } 

Fügen Sie diese Komponente zu SaveLoadMenu hinzu und verknüpfen Sie sie mit dem Rasterobjekt .


Komponente SaveLoadMenu.

Ein Menü zum Speichern oder Laden wird geöffnet. Fügen Sie der Methode einen Openbooleschen Parameter hinzu , um die Arbeit zu vereinfachen . Es bestimmt, ob sich das Menü im Speichermodus befinden soll. Wir werden diesen Modus vor Ort verfolgen, um zu wissen, welche Aktion später ausgeführt werden soll.

  bool saveMode; public void Open (bool saveMode) { this.saveMode = saveMode; gameObject.SetActive(true); HexMapCamera.Locked = true; } 

verbinden Sie nun die Tasten speichern und laden Objekt Hex Map Editor mit der Methode Opendes Objekts Speichern Sie das Menü laden . Überprüfen Sie den booleschen Parameter nur für die Schaltfläche Speichern .


Öffnen des Menüs im Speichermodus.

Wenn Sie dies noch nicht getan haben, verbinden Sie das Ereignis der Schaltfläche Abbrechen mit der Methode Close. Jetzt Speicher Laden Menü kann geöffnet und geschlossen werden.

Veränderung des Aussehens


Wir haben das Menü als Speichermenü erstellt, aber sein Modus wird durch die zum Öffnen gedrückte Taste bestimmt. Wir müssen das Erscheinungsbild des Menüs je nach Modus ändern. Insbesondere müssen wir die Menübezeichnung und die Bezeichnung der Aktionsschaltfläche ändern. Dies bedeutet, dass wir Links zu diesen Tags benötigen.

 using UnityEngine; using UnityEngine.UI; public class SaveLoadMenu : MonoBehaviour { public Text menuLabel, actionButtonLabel; … } 


Verbindung mit Tags.

Wenn das Menü im Speichermodus geöffnet wird, verwenden wir die vorhandenen Beschriftungen, dh Karte für das Menü speichern und Speichern für die Aktionsschaltfläche. Andernfalls befinden wir uns im Lademodus, dh wir verwenden Load Map und Load .

  public void Open (bool saveMode) { this.saveMode = saveMode; if (saveMode) { menuLabel.text = "Save Map"; actionButtonLabel.text = "Save"; } else { menuLabel.text = "Load Map"; actionButtonLabel.text = "Load"; } gameObject.SetActive(true); HexMapCamera.Locked = true; } 

Kartennamen eingeben


Lassen wir die Liste der Dateien für jetzt. Der Benutzer kann die gespeicherte oder heruntergeladene Datei angeben, indem er den Namen der Karte in das Eingabefeld eingibt. Um diese Daten zu erhalten, benötigen wir einen Verweis auf die Komponente InputFielddes Name Input- Objekts .

  public InputField nameInput; 


Verbindung zum Eingabefeld.

Der Benutzer muss nicht gezwungen werden, den vollständigen Pfad zur Kartendatei einzugeben. Es reicht nur der Name der Karte ohne die Erweiterung .map . Fügen wir eine Methode hinzu, die Benutzereingaben verwendet und den richtigen Pfad dafür erstellt. Dies ist nicht möglich, wenn die Eingabe leer ist. In diesem Fall kehren wir zurück null.

 using UnityEngine; using UnityEngine.UI; using System.IO; public class SaveLoadMenu : MonoBehaviour { … string GetSelectedPath () { string mapName = nameInput.text; if (mapName.Length == 0) { return null; } return Path.Combine(Application.persistentDataPath, mapName + ".map"); } } 

Was passiert, wenn der Benutzer ungültige Zeichen eingibt?
, . , , .

Content Type . , - , . , , .

Speichern und laden


Jetzt wird es mit dem Speichern und Laden beschäftigt SaveLoadMenu. Deshalb haben wir die Methoden bewegen Saveund Loadder HexMapEditorin SaveLoadMenu. Sie müssen nicht mehr gemeinsam genutzt werden und arbeiten mit dem Pfadparameter anstelle des festen Pfads.

  void Save (string path) { // string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(1); hexGrid.Save(writer); } } void Load (string path) { // string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { int header = reader.ReadInt32(); if (header <= 1) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); } else { Debug.LogWarning("Unknown map format " + header); } } } 

Da wir jetzt beliebige Dateien hochladen, wäre es schön zu überprüfen, ob die Datei tatsächlich vorhanden ist, und erst dann zu versuchen, sie zu lesen. Ist dies nicht der Fall, geben wir einen Fehler aus und beenden den Vorgang.

  void Load (string path) { if (!File.Exists(path)) { Debug.LogError("File does not exist " + path); return; } … } 

Fügen Sie nun die allgemeine Methode hinzu Action. Es beginnt damit, den vom Benutzer ausgewählten Pfad abzurufen. Wenn es einen Pfad gibt, speichern oder laden Sie ihn. Schließen Sie dann das Menü.

  public void Action () { string path = GetSelectedPath(); if (path == null) { return; } if (saveMode) { Save(path); } else { Load(path); } Close(); } 

Durch Anhängen eines Aktionsschaltflächenereignisses an diese Methode können wir mit beliebigen Kartennamen speichern und laden. Da wir das Eingabefeld nicht zurücksetzen, bleibt der ausgewählte Name bis zum nächsten Speichern oder Laden erhalten. Dies ist praktisch, um eine Datei mehrmals hintereinander zu speichern oder zu laden, sodass wir nichts ändern.

Kartenlistenelemente


Als nächstes füllen wir die Dateiliste mit allen Karten aus, die sich im Datenspeicherpfad befinden. Wenn Sie auf eines der Elemente in der Liste klicken, wird es als Text in der Namenseingabe verwendet . Fügen Sie dazu eine SaveLoadMenuallgemeine Methode hinzu.

  public void SelectItem (string name) { nameInput.text = name; } 

Wir brauchen etwas, das ein Listenelement ist. Die übliche Taste reicht aus. Erstellen Sie es und reduzieren Sie die Höhe auf 20 Einheiten, damit es vertikal nicht viel Platz einnimmt. Es sollte nicht wie eine Schaltfläche aussehen, daher wird der Link " Quellbild" der Bildkomponente gelöscht . In diesem Fall wird es vollständig weiß. Außerdem stellen wir sicher, dass die Beschriftung nach links ausgerichtet ist und zwischen dem Text und der linken Seite der Schaltfläche Platz ist. Nachdem wir mit dem Design des Knopfes fertig sind, verwandeln wir ihn in ein Fertighaus.



Die Schaltfläche ist ein Listenelement.

Wir können das Schaltflächenereignis nicht direkt mit dem Menü "Neue Karte" verbinden , da es sich um ein Fertighaus handelt und es in der Szene noch nicht vorhanden ist. Daher benötigt ein Menüelement einen Link zum Menü, damit es beim Klicken eine Methode aufrufen kann SelectItem. Er muss auch den Namen der Karte, die er repräsentiert, im Auge behalten und seinen Text festlegen. Erstellen wir hierfür eine kleine Komponente SaveLoadItem.

 using UnityEngine; using UnityEngine.UI; public class SaveLoadItem : MonoBehaviour { public SaveLoadMenu menu; public string MapName { get { return mapName; } set { mapName = value; transform.GetChild(0).GetComponent<Text>().text = value; } } string mapName; public void Select () { menu.SelectItem(mapName); } } 

Fügen Sie dem Menüelement eine Komponente hinzu und lassen Sie die Schaltfläche ihre Methode aufrufen Select.


Artikelkomponente.

Listenfüllung


Um die Liste zu füllen, SaveLoadMenumüssen Sie einen Verweis auf den Inhalt innerhalb des Ansichtsfenster - Objekt die Datei eine Liste . Er benötigt auch einen Link zum Artikel Fertighaus.

  public RectTransform listContent; public SaveLoadItem itemPrefab; 


Mischen Sie den Inhalt einer Liste und eines Fertighauses.

Wir verwenden eine neue Methode, um diese Liste zu füllen. Der erste Schritt besteht darin, vorhandene Kartendateien zu identifizieren. Um ein Array aller Dateipfade innerhalb des Verzeichnisses zu erhalten, können wir die Methode verwenden Directory.GetFiles. Diese Methode verfügt über einen zweiten Parameter, mit dem Sie Dateien filtern können. In unserem Fall sind nur Dateien erforderlich, die mit der * .map- Maske übereinstimmen .

  void FillList () { string[] paths = Directory.GetFiles(Application.persistentDataPath, "*.map"); } 

Leider ist die Reihenfolge der Dateien nicht garantiert. Um sie in alphabetischer Reihenfolge anzuzeigen, müssen wir das Array mit sortieren System.Array.Sort.

 using UnityEngine; using UnityEngine.UI; using System; using System.IO; public class SaveLoadMenu : MonoBehaviour { … void FillList () { string[] paths = Directory.GetFiles(Application.persistentDataPath, "*.map"); Array.Sort(paths); } … } 

Als Nächstes erstellen wir vorgefertigte Instanzen für jedes Element des Arrays. Binden Sie das Element an das Menü, legen Sie seinen Kartennamen fest und machen Sie es zu einem untergeordneten Element des Listeninhalts.

  Array.Sort(paths); for (int i = 0; i < paths.Length; i++) { SaveLoadItem item = Instantiate(itemPrefab); item.menu = this; item.MapName = paths[i]; item.transform.SetParent(listContent, false); } 

Da Directory.GetFilesdie vollständigen Pfade zu Dateien zurückgegeben werden, müssen diese gelöscht werden. Glücklicherweise ist dies genau das, was die bequeme Methode ausmacht Path.GetFileNameWithoutExtension.

  item.MapName = Path.GetFileNameWithoutExtension(paths[i]); 

Bevor wir das Menü anzeigen, müssen wir eine Liste ausfüllen. Und da sich die Dateien wahrscheinlich ändern, müssen wir dies jedes Mal tun, wenn wir das Menü öffnen.

  public void Open (bool saveMode) { … FillList(); gameObject.SetActive(true); HexMapCamera.Locked = true; } 

Beim erneuten Ausfüllen der Liste müssen alle alten gelöscht werden, bevor neue Elemente hinzugefügt werden.

  void FillList () { for (int i = 0; i < listContent.childCount; i++) { Destroy(listContent.GetChild(i).gameObject); } … } 


Artikel ohne Vereinbarung.

Anordnung der Punkte


Jetzt werden in der Liste Elemente angezeigt, die sich jedoch überlappen und sich in einer schlechten Position befinden. Damit sie eine vertikale Liste werden, fügen Sie die Objekt Inhalt Listenkomponente des Vertical Group ist das Layout ( die Komponente / das Layout / die Vertical Group ist das Layout ).

Aktivieren Sie die Breite der untergeordneten Steuerelementgröße und der untergeordneten Force-Erweiterung , damit die Anordnung ordnungsgemäß funktioniert . Beide Höhenoptionen sollten deaktiviert sein.



Vertikale Layoutgruppe verwenden.

Wir haben eine schöne Liste von Artikeln. Die Größe des Inhalts der Liste passt sich jedoch nicht der tatsächlichen Anzahl der Elemente an. Daher ändert die Bildlaufleiste niemals die Größe. Wir können die automatische Größenänderung von Inhalten erzwingen , indem wir eine Content Size Fitter- Komponente ( Komponente / Layout / Content Size Fitter ) hinzufügen . Der vertikale Anpassungsmodus sollte auf Bevorzugte Größe eingestellt sein .



Verwenden des Inhaltsgrößen-Monteurs.

Mit einer kleinen Anzahl von Punkten verschwindet die Bildlaufleiste. Wenn die Liste zu viele Elemente enthält, die nicht in das Ansichtsfenster passen, wird die Bildlaufleiste angezeigt und hat eine geeignete Größe.


Eine Bildlaufleiste wird angezeigt.

Löschen der Karte


Jetzt können wir bequem mit vielen Kartendateien arbeiten. Manchmal ist es jedoch notwendig, einige Karten loszuwerden. Dazu können Sie die Schaltfläche Löschen verwenden . Lassen Sie uns eine Methode dafür erstellen und die Schaltfläche dazu bringen, sie aufzurufen. Wenn ein Pfad ausgewählt ist, löschen Sie ihn einfach mit File.Delete.

  public void Delete () { string path = GetSelectedPath(); if (path == null) { return; } File.Delete(path); } 

Hier sollten wir auch überprüfen, ob wir mit einer wirklich vorhandenen Datei arbeiten. Wenn dies nicht der Fall ist, sollten wir nicht versuchen, es zu entfernen, aber dies führt nicht zu einem Fehler.

  if (File.Exists(path)) { File.Delete(path); } 

Nach dem Entfernen der Karte müssen wir das Menü nicht schließen. Dies erleichtert das gleichzeitige Löschen mehrerer Dateien. Doch nach der Entfernung, müssen wir klar den Namen der Eingabe , und die Liste der Dateien zu aktualisieren.

  if (File.Exists(path)) { File.Delete(path); } nameInput.text = ""; FillList(); 

Einheitspaket

Teil 14: Reliefstrukturen


  • Verwenden Sie Scheitelpunktfarben, um eine Splat-Karte zu erstellen.
  • Erstellen eines Array-Textur-Assets.
  • Hinzufügen von Höhenindizes zu Netzen.
  • Übergänge zwischen Relieftexturen.

Bis zu diesem Moment haben wir Volltonfarben zum Ausmalen von Karten verwendet. Jetzt werden wir die Textur anwenden.


Texturen zeichnen.

Eine Mischung aus drei Typen


Obwohl einheitliche Farben klar unterscheidbar sind und die Aufgabe recht gut bewältigen, sehen sie nicht sehr interessant aus. Die Verwendung von Texturen erhöht die Attraktivität von Karten erheblich. Dazu müssen wir natürlich Texturen mischen, nicht nur Farben. Im Rendering 3- Tutorial , Kombinieren von Texturen, habe ich darüber gesprochen, wie mehrere Texturen mithilfe der Splat-Map gemischt werden. In unseren Sechseckkarten können Sie einen ähnlichen Ansatz verwenden.

Im Rendering 3- TutorialEs werden nur vier Texturen gemischt, und mit einer Splat-Map können bis zu fünf Texturen unterstützt werden. Im Moment verwenden wir fünf verschiedene Farben, daher ist dies für uns sehr gut geeignet. Später können wir jedoch andere Typen hinzufügen. Daher ist die Unterstützung einer beliebigen Anzahl von Entlastungstypen erforderlich. Wenn Sie explizit festgelegte Textureigenschaften verwenden, ist dies nicht möglich. Sie müssen daher ein Array von Texturen verwenden. Später werden wir es schaffen.

Bei der Verwendung von Textur-Arrays müssen wir dem Shader irgendwie mitteilen, welche Texturen gemischt werden sollen. Das schwierigste Mischen ist für eckige Dreiecke erforderlich, die zwischen drei Zellen mit ihrem eigenen Geländetyp liegen können. Daher benötigen wir eine Mischunterstützung zwischen den drei Typen pro Dreieck.

Verwenden von Scheitelpunktfarben als Splat-Karten


Angenommen, wir können Ihnen sagen, welche Texturen gemischt werden sollen, können wir Scheitelpunktfarben verwenden, um eine Splat-Map für jedes Dreieck zu erstellen. Da jeweils maximal drei Texturen verwendet werden, benötigen wir nur drei Farbkanäle. Rot steht für die erste Textur, Grün für die zweite und Blau für die dritte.


Dreieck Splat Karte.

Ist die Summe der Dreiecks-Splat-Map immer gleich eins?
Ja . . , (1, 0, 0) , (½, ½, 0) (&frac13;, &frac13;, &frac13;) .

Wenn ein Dreieck nur eine Textur benötigt, verwenden wir nur den ersten Kanal. Das heißt, seine Farbe wird vollständig rot sein. Beim Mischen zwischen zwei verschiedenen Typen verwenden wir den ersten und den zweiten Kanal. Das heißt, die Farbe des Dreiecks ist eine Mischung aus Rot und Grün. Und wenn alle drei Typen gefunden sind, ist es eine Mischung aus Rot, Grün und Blau.


Drei Splat-Map-Konfigurationen.

Wir werden diese Splat-Map-Konfigurationen verwenden, unabhängig davon, welche Texturen tatsächlich gemischt werden. Das heißt, die Splat-Karte ist immer dieselbe. Nur Texturen ändern sich. Wie das geht, erfahren wir später.

Wir müssen Änderungen HexGridChunkvornehmen, damit diese Splat-Maps erstellt werden, anstatt Zellenfarben zu verwenden. Da wir häufig drei Farben verwenden, erstellen wir statische Felder für diese.

  static Color color1 = new Color(1f, 0f, 0f); static Color color2 = new Color(0f, 1f, 0f); static Color color3 = new Color(0f, 0f, 1f); 

Zellzentren


Beginnen wir damit, standardmäßig die Farbe der Mitte der Zellen zu ersetzen. Hier wird nicht gemischt, daher verwenden wir nur die erste Farbe, d. H. Rot.

  void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, color1); … } 


Rote Zellzentren.

Zellzentren werden jetzt rot. Sie alle verwenden die erste der drei Texturen, unabhängig von der Textur. Ihre Splat-Maps sind unabhängig von der Farbe, mit der wir die Zellen einfärben, gleich.

Flussviertel


Wir haben Segmente nur innerhalb von Zellen geändert, ohne dass Flüsse entlang dieser fließen. Wir müssen dasselbe für die Segmente neben den Flüssen tun. In unserem Fall ist dies sowohl ein Rippenstreifen als auch ein Fächer aus Rippendreiecken. Auch hier reicht uns nur Rot.

  void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeFan(center, m, color1); … } 


Rote Segmente neben Flüssen.

Flüsse


Als nächstes müssen wir uns um die Geometrie der Flüsse in den Zellen kümmern. Alle sollten auch rot werden. Schauen wir uns zunächst den Anfang und das Ende von Flüssen an.

  void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeFan(center, m, color1); … } 

Und dann die Geometrie, aus der die Ufer und das Flussbett bestehen. Ich habe die Farbmethodenaufrufe gruppiert, um das Lesen des Codes zu erleichtern.

  void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); terrain.AddTriangle(centerL, m.v1, m.v2); // terrain.AddTriangleColor(cell.Color); terrain.AddQuad(centerL, center, m.v2, m.v3); // terrain.AddQuadColor(cell.Color); terrain.AddQuad(center, centerR, m.v3, m.v4); // terrain.AddQuadColor(cell.Color); terrain.AddTriangle(centerR, m.v4, m.v5); // terrain.AddTriangleColor(cell.Color); terrain.AddTriangleColor(color1); terrain.AddQuadColor(color1); terrain.AddQuadColor(color1); terrain.AddTriangleColor(color1); … } 


Rote Flüsse entlang der Zellen.

Rippen


Alle Kanten sind unterschiedlich, da sie sich zwischen Zellen befinden, die unterschiedliche Geländetypen haben können. Wir verwenden die erste Farbe für den aktuellen Zelltyp und die zweite Farbe für den Nachbartyp. Infolgedessen wird die Splat-Karte zu einem rot-grünen Farbverlauf, selbst wenn beide Zellen vom gleichen Typ sind. Wenn beide Zellen dieselbe Textur verwenden, wird auf beiden Seiten nur eine Mischung aus derselben Textur.

  void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad); } else { TriangulateEdgeStrip(e1, color1, e2, color2, hasRoad); } … } 


Rot-grüne Rippen ohne Leisten.

Würde der scharfe Übergang zwischen Rot und Grün nicht Probleme verursachen?
, , . . splat map, . .

, .

Die Kanten mit den Leisten sind etwas komplizierter, da sie zusätzliche Eckpunkte haben. Glücklicherweise funktioniert der vorhandene Interpolationscode hervorragend mit Splat-Map-Farben. Verwenden Sie einfach die erste und die zweite Farbe, nicht die Farben der Zellen am Anfang und am Ende.

  void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(color1, color2, 1); TriangulateEdgeStrip(begin, color1, 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(color1, color2, i); TriangulateEdgeStrip(e1, c1, e2, c2, hasRoad); } TriangulateEdgeStrip(e2, c2, end, color2, hasRoad); } 


Rot-grüne Rippenleisten.

Winkel


Zellwinkel sind am schwierigsten, da sie drei verschiedene Texturen mischen müssen. Wir verwenden Rot für den unteren Peak, Grün für den linken und Blau für den rechten. Beginnen wir mit den Ecken eines Dreiecks.

  void TriangulateCorner ( Vector3 bottom, HexCell bottomCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … else { terrain.AddTriangle(bottom, left, right); terrain.AddTriangleColor(color1, color2, color3); } features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell); } 


Rot-Grün-Blau-Ecken, außer Leisten.

Hier können wir wieder den vorhandenen Farbinterpolationscode für Ecken mit Leisten verwenden. Es wird nur zwischen drei und nicht zwischen zwei Farben interpoliert. Betrachten Sie zunächst die Felsvorsprünge, die sich nicht in der Nähe der Klippen befinden.

  void TriangulateCornerTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1); Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1); Color c3 = HexMetrics.TerraceLerp(color1, color2, 1); Color c4 = HexMetrics.TerraceLerp(color1, color3, 1); terrain.AddTriangle(begin, v3, v4); terrain.AddTriangleColor(color1, c3, c4); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v3; Vector3 v2 = v4; Color c1 = c3; Color c2 = c4; v3 = HexMetrics.TerraceLerp(begin, left, i); v4 = HexMetrics.TerraceLerp(begin, right, i); c3 = HexMetrics.TerraceLerp(color1, color2, i); c4 = HexMetrics.TerraceLerp(color1, color3, i); terrain.AddQuad(v1, v2, v3, v4); terrain.AddQuadColor(c1, c2, c3, c4); } terrain.AddQuad(v3, v4, left, right); terrain.AddQuadColor(c3, c4, color2, color3); } 


Rot-Grün-Blau-Eckleisten, mit Ausnahme von Vorsprüngen entlang der Klippen.

Wenn es um Klippen geht, müssen wir eine Methode anwenden TriangulateBoundaryTriangle. Diese Methode erhielt die Start- und linken Zellen als Parameter. Jetzt benötigen wir jedoch die entsprechenden Splat-Farben, die je nach Topologie variieren können. Daher ersetzen wir diese Parameter durch Farben.

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

Ändern Sie es TriangulateCornerTerracesCliffso, dass es die richtigen Farben verwendet.

  void TriangulateCornerTerracesCliff ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … Color boundaryColor = Color.Lerp(color1, color3, b); TriangulateBoundaryTriangle( begin, color1, left, color2, boundary, boundaryColor ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); } } 

Und mach das gleiche für TriangulateCornerCliffTerraces.

  void TriangulateCornerCliffTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … Color boundaryColor = Color.Lerp(color1, color2, b); TriangulateBoundaryTriangle( right, color3, begin, color1, boundary, boundaryColor ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); } } 


Volle Splat-Reliefkarte.

Einheitspaket

Textur-Arrays


Nachdem unser Gelände eine Splat-Karte hat, können wir die Textur-Sammlung an den Shader übergeben. Wir können einem Array von C # -Texturen nicht einfach einen Shader zuweisen, da das Array als einzelne Entität im GPU-Speicher vorhanden sein muss. Wir müssen ein spezielles Objekt verwenden Texture2DArray, das seit Version 5.4 in Unity unterstützt wird.

Unterstützen alle GPUs Textur-Arrays?
GPU , . Unity .
  • Direct3D 11/12 (Windows, Xbox One)
  • OpenGL Core (Mac OS X, Linux)
  • Metal (iOS, Mac OS X)
  • OpenGL ES 3.0 (Android, iOS, WebGL 2.0)
  • Playstation 4


Der Meister


Leider ist die Unterstützung von Unity für Textur-Arrays in Version 5.5 minimal. Wir können nicht einfach ein Texturarray-Asset erstellen und ihm Texturen zuweisen. Wir müssen es manuell machen. Wir können entweder ein Array von Texturen im Wiedergabemodus erstellen oder ein Asset im Editor erstellen. Lassen Sie uns einen Vermögenswert erstellen.

Warum einen Vermögenswert erstellen?
, Play . , .

, . Unity . , . , .

Um eine Reihe von Texturen zu erstellen, werden wir unseren eigenen Master zusammenstellen. Erstellen Sie ein Skript TextureArrayWizardund legen Sie es im Editor- Ordner ab . Stattdessen MonoBehavioursollte der Typ ScriptableWizardaus dem Namespace erweitert werden UnityEditor.

 using UnityEditor; using UnityEngine; public class TextureArrayWizard : ScriptableWizard { } 

Wir können den Assistenten über eine verallgemeinerte statische Methode öffnen ScriptableWizard.DisplayWizard. Seine Parameter sind die Namen des Assistentenfensters und seiner Schaltfläche zum Erstellen. Wir werden diese Methode in einer statischen Methode aufrufen CreateWizard.

  static void CreateWizard () { ScriptableWizard.DisplayWizard<TextureArrayWizard>( "Create Texture Array", "Create" ); } 

Um über den Editor auf den Assistenten zuzugreifen, müssen Sie diese Methode zum Unity-Menü hinzufügen. Dies kann durch Hinzufügen eines Attributs zur Methode erfolgen MenuItem. Fügen wir es dem Menü " Assets" und insbesondere dem Array "Assets / Create / Texture" hinzu .

  [MenuItem("Assets/Create/Texture Array")] static void CreateWizard () { … } 


Unser benutzerdefinierter Assistent.

Mit dem neuen Menüpunkt können Sie das Popup-Menü unseres benutzerdefinierten Assistenten öffnen. Es ist nicht sehr schön, aber zur Lösung des Problems geeignet. Es ist jedoch noch leer. Um ein Array von Texturen zu erstellen, benötigen wir ein Array von Texturen. Fügen Sie ein allgemeines Feld für den Master hinzu. Die Standard-GUI des Assistenten zeigt sie wie ein Standardinspektor an.

  public Texture2D[] textures; 


Meister mit Texturen.

Lassen Sie uns etwas schaffen


Wenn Sie auf die Schaltfläche Erstellen des Assistenten klicken , wird dieser ausgeblendet. Darüber hinaus beschwert sich Unity, dass es keine Methode gibt OnWizardCreate. Dies ist die Methode, die aufgerufen wird, wenn auf die Schaltfläche "Erstellen" geklickt wird. Daher müssen wir sie dem Assistenten hinzufügen.

  void OnWizardCreate () { } 

Hier erstellen wir unser Texturarray. Zumindest wenn der Benutzer dem Master Texturen hinzugefügt hat. Wenn nicht, gibt es nichts zu erstellen und die Arbeit muss gestoppt werden.

  void OnWizardCreate () { if (textures.Length == 0) { return; } } 

Der nächste Schritt besteht darin, den Speicherort zum Speichern des Texturarray-Assets anzufordern. Das Dateispeicherfenster kann mit dieser Methode geöffnet werden EditorUtility.SaveFilePanelInProject. Seine Parameter definieren den Panel-Namen, den Standard-Dateinamen, die Dateierweiterung und die Beschreibung. Textur-Arrays verwenden die allgemeine Asset- Dateierweiterung .

  if (textures.Length == 0) { return; } EditorUtility.SaveFilePanelInProject( "Save Texture Array", "Texture Array", "asset", "Save Texture Array" ); 

SaveFilePanelInProjectGibt den vom Benutzer ausgewählten Dateipfad zurück. Wenn der Benutzer in diesem Bereich auf Abbrechen geklickt hat, ist der Pfad eine leere Zeichenfolge. Daher müssen wir in diesem Fall die Arbeit unterbrechen.

  string path = EditorUtility.SaveFilePanelInProject( "Save Texture Array", "Texture Array", "asset", "Save Texture Array" ); if (path.Length == 0) { return; } 

Erstellen eines Arrays von Texturen


Wenn wir den richtigen Weg haben, können wir weitermachen und ein neues Objekt erstellen Texture2DArray. Seine Konstruktormethode erfordert die Angabe der Breite und Höhe der Textur, der Länge des Arrays, des Formats der Texturen und der Notwendigkeit der Mip-Texturierung. Diese Parameter sollten für alle Texturen im Array gleich sein. Um das Objekt zu konfigurieren, verwenden wir die erste Textur. Der Benutzer muss überprüfen, ob alle Texturen das gleiche Format haben.

  if (path.Length == 0) { return; } Texture2D t = textures[0]; Texture2DArray textureArray = new Texture2DArray( t.width, t.height, textures.Length, t.format, t.mipmapCount > 1 ); 

Da es sich bei dem Texturarray um eine einzelne GPU-Ressource handelt, werden für alle Texturen dieselben Filter- und Faltmodi verwendet. Hier verwenden wir wieder die erste Textur, um alles einzurichten.

  Texture2DArray textureArray = new Texture2DArray( t.width, t.height, textures.Length, t.format, t.mipmapCount > 1 ); textureArray.anisoLevel = t.anisoLevel; textureArray.filterMode = t.filterMode; textureArray.wrapMode = t.wrapMode; 

Jetzt können wir die Texturen mit der Methode in ein Array kopieren Graphics.CopyTexture. Die Methode kopiert rohe Texturdaten nacheinander. Daher müssen wir alle Texturen und ihre Mip-Levels durchlaufen. Die Methodenparameter sind zwei Sätze, die aus einer Texturressource, einem Index und einer Mip-Ebene bestehen. Da die ursprünglichen Texturen keine Arrays sind, ist ihr Index immer Null.

  textureArray.wrapMode = t.wrapMode; for (int i = 0; i < textures.Length; i++) { for (int m = 0; m < t.mipmapCount; m++) { Graphics.CopyTexture(textures[i], 0, m, textureArray, i, m); } } 

Zu diesem Zeitpunkt haben wir die richtige Anordnung von Texturen im Speicher, aber es ist noch kein Aktivposten. Der letzte Schritt besteht darin, AssetDatabase.CreateAssetmit dem Array und seinem Pfad aufzurufen . In diesem Fall werden die Daten in eine Datei in unserem Projekt geschrieben und im Projektfenster angezeigt.

  for (int i = 0; i < textures.Length; i++) { … } AssetDatabase.CreateAsset(textureArray, path); 


Texturen


Um eine echte Reihe von Texturen zu erstellen, benötigen wir die Originaltexturen. Hier sind fünf Texturen, die den bisher verwendeten Farben entsprechen. Gelb wird Sand, Grün wird Gras, Blau wird Erde, Orange wird Stein und Weiß wird Schnee.






Texturen aus Sand, Gras, Erde, Stein und Schnee.

Beachten Sie, dass diese Texturen keine Fotos dieses Reliefs sind. Dies sind die einfachen Pseudozufallsmuster, die ich mit NumberFlow erstellt habe . Ich bemühte mich, erkennbare Relieftypen und Details zu schaffen, die nicht mit abstrakten polygonalen Reliefs in Konflikt stehen. Der Fotorealismus erwies sich dafür als ungeeignet. Obwohl Muster die Variabilität erhöhen, enthalten sie nur wenige unterschiedliche Merkmale, die Wiederholungen sofort wahrnehmbar machen würden.

Fügen Sie diese Texturen dem Master-Array hinzu und stellen Sie sicher, dass ihre Reihenfolge mit den Farben übereinstimmt. Das heißt, zuerst Sand, dann Gras, Erde, Stein und schließlich Schnee.



Erstellen eines Arrays von Texturen.

Nachdem Sie das Texturarray-Asset erstellt haben, wählen Sie es aus und untersuchen Sie es im Inspektor.


Texturarray-Inspektor.

Dies ist die einfachste Anzeige eines Teils der Texturarraydaten. Beachten Sie, dass es einen Is Readable- Schalter gibt, der anfänglich eingeschaltet ist. Deaktivieren Sie diese Option, da Sie keine Pixeldaten aus dem Array lesen müssen. Dies ist im Assistenten nicht möglich, da Texture2DArraykeine Methoden oder Eigenschaften für den Zugriff auf diesen Parameter vorhanden sind.

(In Unity 5.6 gibt es einen Fehler, der Textur-Arrays in Assemblys auf mehreren Plattformen beschädigt . Sie können ihn umgehen, ohne Is Readable zu deaktivieren .)

Beachten Sie auch, dass ein Farbraumfeld vorhanden istDies bedeutet, dass angenommen wird, dass sich die Texturen im Gammaraum befinden, was wahr ist. Wenn sie sich im linearen Raum befinden sollten, musste das Feld auf 0 gesetzt werden. Der Designer Texture2DArrayverfügt zwar über einen zusätzlichen Parameter zum Festlegen des Farbraums, zeigt Texture2Djedoch nicht an, ob er sich im linearen Raum befindet oder nicht. Daher müssen Sie in jedem Fall einen Wert festlegen Wert manuell.

Shader


Jetzt, da wir eine Reihe von Texturen haben, müssen wir dem Shader beibringen, wie man damit arbeitet. Im Moment verwenden wir den VertexColors- Shader, um das Gelände zu rendern . Da wir jetzt Texturen anstelle von Farben verwenden, benennen Sie sie in Terrain um . Dann verwandeln wir den Parameter _MainTex in ein Array von Texturen und weisen ihm ein Asset zu.

 Shader "Custom/Terrain" { Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } … } 


Reliefmaterial mit einer Reihe von Texturen.

Um Textur-Arrays auf allen Plattformen zu aktivieren, die sie unterstützen, müssen Sie die Zielstufe des Shaders von 3.0 auf 3.5 erhöhen.

  #pragma target 3.5 

Da sich die Variable _MainTexjetzt auf ein Array von Texturen bezieht, müssen wir ihren Typ ändern. Der Typ hängt von der Zielplattform ab und das Makro kümmert sich darum UNITY_DECLARE_TEX2DARRAY.

 // sampler2D _MainTex; UNITY_DECLARE_TEX2DARRAY(_MainTex); 

Wie bei anderen Shadern benötigen wir die Koordinaten der XZ-Welt, um die Textur des Reliefs abzutasten. Daher werden wir der Eingabestruktur des Surface Shader eine Position in der Welt hinzufügen. Wir löschen auch die Standard-UV-Koordinaten, da wir sie nicht benötigen.

  struct Input { // float2 uv_MainTex; float4 color : COLOR; float3 worldPos; }; 

Um ein Array von Texturen abzutasten, müssen wir ein Makro verwenden UNITY_SAMPLE_TEX2DARRAY. Zum Abtasten eines Arrays werden drei Koordinaten benötigt. Die ersten beiden sind reguläre UV-Koordinaten. Wir werden die XZ-Weltkoordinaten verwenden, die auf 0,02 skaliert sind. So erhalten wir eine gute Texturauflösung bei voller Vergrößerung. Texturen werden ungefähr alle vier Zellen wiederholt.

Die dritte Koordinate wird wie in einem regulären Array als Index des Texturarrays verwendet. Da die Koordinaten float sind, werden sie vor dem Indizieren durch das GPU-Array gerundet. Da wir, bis wir wissen, welche Textur benötigt wird, immer die erste verwenden. Außerdem hat die Farbe des Scheitelpunkts keinen Einfluss auf das Endergebnis, da es sich um eine Splat-Karte handelt.

  void surf (Input IN, inout SurfaceOutputStandard o) { float2 uv = IN.worldPos.xz * 0.02; fixed4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, float3(uv, 0)); Albedo = c.rgb * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } 


Alles ist Sand geworden.

Einheitspaket

Texturauswahl


Wir brauchen eine Relief-Splat-Karte, die die drei Typen zu einem Dreieck mischt. Wir haben eine Reihe von Texturen mit einer Textur für jeden Geländetyp. Wir haben einen Shader, der eine Reihe von Texturen abtastet. Derzeit können wir dem Shader jedoch nicht mitteilen, welche Texturen für jedes Dreieck ausgewählt werden sollen.

Da jedes Dreieck bis zu drei Typen mischt, müssen wir jedem Dreieck drei Indizes zuordnen. Wir können keine Informationen für Dreiecke speichern, daher müssen wir Indizes für Scheitelpunkte speichern. Alle drei Eckpunkte des Dreiecks speichern einfach die gleichen Indizes wie bei der Volltonfarbe.

Vernetzt Daten


Wir können einen der Sätze des UV-Netzes zum Speichern von Indizes verwenden. Da auf jedem Scheitelpunkt drei Indizes gespeichert sind, reichen die vorhandenen 2D-UV-Sätze nicht aus. Glücklicherweise können UV-Sets bis zu vier Koordinaten enthalten. Daher fügen wir der HexMeshzweiten Liste hinzu Vector3, auf die wir als Relieftypen verweisen.

  public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates; public bool useTerrainTypes; [NonSerialized] List<Vector3> vertices, terrainTypes; 

Aktivieren Sie Geländetypen für das Terrain- Kind des Hex Grid Chunk- Fertighauses .


Wir verwenden Reliefarten.

Bei Bedarf erstellen wir Vector3während der Netzreinigung eine weitere Liste der Entlastungsarten.

  public void Clear () { … if (useTerrainTypes) { terrainTypes = ListPool<Vector3>.Get(); } triangles = ListPool<int>.Get(); } 

Beim Anwenden der Netzdaten speichern wir die Relieftypen im dritten UV-Satz. Aus diesem Grund werden sie nicht mit zwei anderen Sets in Konflikt geraten, wenn wir uns jemals dazu entschließen, sie zusammen zu verwenden.

  public void Apply () { … if (useTerrainTypes) { hexMesh.SetUVs(2, terrainTypes); ListPool<Vector3>.Add(terrainTypes); } hexMesh.SetTriangles(triangles, 0); … } 

Um die Relieftypen des Dreiecks festzulegen, verwenden wir Vector3. Da die für das gesamte Dreieck gleich sind, fügen wir nur dreimal dieselben Daten hinzu.

  public void AddTriangleTerrainTypes (Vector3 types) { terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); } 

Das Mischen im Quad funktioniert genauso. Alle vier Eckpunkte sind vom gleichen Typ.

  public void AddQuadTerrainTypes (Vector3 types) { terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); } 

Fans von Triangles of Ribs


Jetzt müssen wir den Netzdaten in Typen hinzufügen HexGridChunk. Beginnen wir mit TriangulateEdgeFan. Lassen Sie uns zur besseren Lesbarkeit zunächst die Vertex- und Color-Methodenaufrufe aufteilen. Denken Sie daran, dass wir sie bei jedem Aufruf dieser Methode an ihn übergeben color1, damit wir diese Farbe direkt verwenden und den Parameter nicht anwenden können.

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

Nach den Farben fügen wir Reliefarten hinzu. Da die Typen im Dreieck unterschiedlich sein können, sollte dies ein Parameter sein, der die Farbe ersetzt. Verwenden Sie diesen einfachen Typ zum Erstellen Vector3. Nur die ersten vier Kanäle sind uns wichtig, da in diesem Fall die Splat-Map immer rot ist. Da alle drei Komponenten des Vektors zugewiesen werden müssen, weisen wir ihnen einen Typ zu.

  void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, float type) { … Vector3 types; types.x = types.y = types.z = type; terrain.AddTriangleTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); } 

Jetzt müssen wir alle Aufrufe dieser Methode ändern und das Farbargument durch einen Index des Geländetyps der Zelle ersetzen. Vnesom diese Änderung TriangulateWithoutRiver, TriangulateAdjacentToRiverund TriangulateWithRiverBeginOrEnd.

 // TriangulateEdgeFan(center, e, color1); TriangulateEdgeFan(center, e, cell.TerrainTypeIndex); 

Zu diesem Zeitpunkt, wenn Sie den Wiedergabemodus starten, werden Fehler angezeigt, die Sie darüber informieren, dass dritte Sätze von UV-Netzen außerhalb der Grenzen liegen. Dies geschah, weil wir noch nicht jedem Dreieck und Quad Relieftypen hinzufügen. Also lasst uns weiter ändern HexGridChunk.

Rippenstreifen


Wenn wir nun einen Randstreifen erstellen, müssen wir wissen, welche Geländetypen sich auf beiden Seiten befinden. Daher fügen wir sie als Parameter hinzu und erstellen dann einen Vektor von Typen, deren zwei Kanälen diese Typen zugewiesen sind. Der dritte Kanal ist nicht wichtig, also setzen Sie ihn einfach dem ersten gleich. Fügen Sie nach dem Hinzufügen der Farben die Typen zum Quad hinzu.

  void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, float type1, EdgeVertices e2, Color c2, float type2, bool hasRoad = false ) { terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); Vector3 types; types.x = types.z = type1; types.y = type2; terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); if (hasRoad) { TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4); } } 

Jetzt müssen wir die Herausforderungen ändern TriangulateEdgeStrip. Erstens TriangulateAdjacentToRiver, TriangulateWithRiverBeginOrEndund TriangulateWithRiverSie müssen den Zelltyp für beiden Seiten des Rippenstreifens verwenden.

 // TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeStrip( m, color1, cell.TerrainTypeIndex, e, color1, cell.TerrainTypeIndex ); 

Als nächstes muss der einfachste Fall einer Kante TriangulateConnectionden Zelltyp für die nächste Kante und den Nachbartyp für die entfernte Kante verwenden. Sie können gleich oder verschieden sein.

  void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad); } else { // TriangulateEdgeStrip(e1, color1, e2, color2, hasRoad); TriangulateEdgeStrip( e1, color1, cell.TerrainTypeIndex, e2, color2, neighbor.TerrainTypeIndex, hasRoad ); } … } 

Gleiches gilt für das TriangulateEdgeTerraces, was dreimal auslöst TriangulateEdgeStrip. Die Typen für die Leisten sind gleich.

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

Winkel


Der einfachste Fall eines Winkels ist ein einfaches Dreieck. Die untere Zelle überträgt den ersten Typ, der linke den zweiten und den rechten den dritten. Erstellen Sie mit ihnen einen Vektor von Typen und fügen Sie ihn dem Dreieck hinzu.

  void TriangulateCorner ( Vector3 bottom, HexCell bottomCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … else { terrain.AddTriangle(bottom, left, right); terrain.AddTriangleColor(color1, color2, color3); Vector3 types; types.x = bottomCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; terrain.AddTriangleTerrainTypes(types); } features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell); } 

Wir verwenden den gleichen Ansatz in TriangulateCornerTerraces, nur dass wir hier eine Gruppe von Quads erstellen.

  void TriangulateCornerTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1); Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1); Color c3 = HexMetrics.TerraceLerp(color1, color2, 1); Color c4 = HexMetrics.TerraceLerp(color1, color3, 1); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; terrain.AddTriangle(begin, v3, v4); terrain.AddTriangleColor(color1, c3, c4); terrain.AddTriangleTerrainTypes(types); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v3; Vector3 v2 = v4; Color c1 = c3; Color c2 = c4; v3 = HexMetrics.TerraceLerp(begin, left, i); v4 = HexMetrics.TerraceLerp(begin, right, i); c3 = HexMetrics.TerraceLerp(color1, color2, i); c4 = HexMetrics.TerraceLerp(color1, color3, i); terrain.AddQuad(v1, v2, v3, v4); terrain.AddQuadColor(c1, c2, c3, c4); terrain.AddQuadTerrainTypes(types); } terrain.AddQuad(v3, v4, left, right); terrain.AddQuadColor(c3, c4, color2, color3); terrain.AddQuadTerrainTypes(types); } 

Wenn wir Felsvorsprünge und Klippen mischen, müssen wir verwenden TriangulateBoundaryTriangle. Geben Sie ihm einfach einen Typvektorparameter und fügen Sie ihn allen Dreiecken hinzu.

  void TriangulateBoundaryTriangle ( Vector3 begin, Color beginColor, Vector3 left, Color leftColor, Vector3 boundary, Color boundaryColor, Vector3 types ) { Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1)); Color c2 = HexMetrics.TerraceLerp(beginColor, leftColor, 1); terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary); terrain.AddTriangleColor(beginColor, c2, boundaryColor); terrain.AddTriangleTerrainTypes(types); for (int i = 2; i < HexMetrics.terraceSteps; i++) { Vector3 v1 = v2; Color c1 = c2; v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i)); c2 = HexMetrics.TerraceLerp(beginColor, leftColor, i); terrain.AddTriangleUnperturbed(v1, v2, boundary); terrain.AddTriangleColor(c1, c2, boundaryColor); terrain.AddTriangleTerrainTypes(types); } terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary); terrain.AddTriangleColor(c2, leftColor, boundaryColor); terrain.AddTriangleTerrainTypes(types); } 

Das TriangulateCornerTerracesCliffschafft Vektor basierend auf den übertragenen Zelltypen. Fügen Sie es dann zu einem Dreieck hinzu und geben Sie es ein TriangulateBoundaryTriangle.

  void TriangulateCornerTerracesCliff ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { float b = 1f / (rightCell.Elevation - beginCell.Elevation); if (b < 0) { b = -b; } Vector3 boundary = Vector3.Lerp( HexMetrics.Perturb(begin), HexMetrics.Perturb(right), b ); Color boundaryColor = Color.Lerp(color1, color3, b); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; TriangulateBoundaryTriangle( begin, color1, left, color2, boundary, boundaryColor, types ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor, types ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); terrain.AddTriangleTerrainTypes(types); } } 

Das gilt auch für TriangulateCornerCliffTerraces.

  void TriangulateCornerCliffTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { float b = 1f / (leftCell.Elevation - beginCell.Elevation); if (b < 0) { b = -b; } Vector3 boundary = Vector3.Lerp( HexMetrics.Perturb(begin), HexMetrics.Perturb(left), b ); Color boundaryColor = Color.Lerp(color1, color2, b); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; TriangulateBoundaryTriangle( right, color3, begin, color1, boundary, boundaryColor, types ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor, types ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); terrain.AddTriangleTerrainTypes(types); } } 

Flüsse


Die letzte Methode, die geändert werden muss, ist diese TriangulateWithRiver. Da wir uns hier in der Mitte der Zelle befinden, haben wir es nur mit dem Typ der aktuellen Zelle zu tun. Erstellen Sie daher einen Vektor dafür und fügen Sie ihn Dreiecken und Quadraten hinzu.

  void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … terrain.AddTriangleColor(color1); terrain.AddQuadColor(color1); terrain.AddQuadColor(color1); terrain.AddTriangleColor(color1); Vector3 types; types.x = types.y = types.z = cell.TerrainTypeIndex; terrain.AddTriangleTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); … } 

Typ mix


Zu diesem Zeitpunkt enthalten die Netze die erforderlichen Höhenindizes. Wir müssen nur den Terrain- Shader zwingen, sie zu verwenden. Damit die Indizes in den Fragment-Shader fallen, müssen sie zuerst durch den Vertex-Shader geleitet werden. Wir können dies in unserer eigenen Scheitelpunktfunktion tun, wie wir es im Estuary- Shader getan haben . In diesem Fall fügen wir der Eingabestruktur ein Feld hinzu float3 terrainund kopieren es in diese v.texcoord2.xyz.

  #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.5 … struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; }; void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); data.terrain = v.texcoord2.xyz; } 

Wir müssen das Texturarray dreimal pro Fragment abtasten. Erstellen wir daher eine praktische Funktion zum Erstellen von Texturkoordinaten, zum Abtasten eines Arrays und zum Modulieren eines Samples mit einer Splat-Map für einen Index.

  float4 GetTerrainColor (Input IN, int index) { float3 uvw = float3(IN.worldPos.xz * 0.02, IN.terrain[index]); float4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, uvw); return c * IN.color[index]; } void surf (Input IN, inout SurfaceOutputStandard o) { … } 

Können wir mit einem Vektor als Array arbeiten?
Ja - color[0] color.r . color[1] color.g , .

Mit dieser Funktion können wir das Texturarray einfach dreimal abtasten und die Ergebnisse kombinieren.

  void surf (Input IN, inout SurfaceOutputStandard o) { // float2 uv = IN.worldPos.xz * 0.02; fixed4 c = GetTerrainColor(IN, 0) + GetTerrainColor(IN, 1) + GetTerrainColor(IN, 2); o.Albedo = c.rgb * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } 


Strukturiertes Relief.

Jetzt können wir das Relief mit Texturen bemalen. Sie mischen sich wie Volltonfarben. Da wir die Weltkoordinaten als UV-Koordinaten verwenden, ändern sie sich nicht mit der Höhe. Infolgedessen werden die Texturen entlang scharfer Klippen gedehnt. Wenn die Texturen ziemlich neutral und sehr variabel sind, sind die Ergebnisse akzeptabel. Ansonsten bekommen wir große hässliche Dehnungsstreifen. Sie können versuchen, es mit zusätzlicher Geometrie oder Textur von Klippen auszublenden, aber im Tutorial werden wir dies nicht tun.

Fegen


Wenn wir nun Texturen anstelle von Farben verwenden, ist es logisch, das Editorfenster zu ändern. Wir können eine schöne Oberfläche erstellen, die sogar Relieftexturen anzeigen kann, aber ich werde mich auf Abkürzungen konzentrieren, die dem Stil des vorhandenen Schemas entsprechen.


Entlastungsoptionen.

Außerdem wird die HexCellFarbeigenschaft nicht mehr benötigt. Löschen Sie sie daher.

 // public Color Color { // get { // return HexMetrics.colors[terrainTypeIndex]; // } // } 

Sie HexGridkönnen auch ein Array von Farben und den dazugehörigen Code entfernen.

 // public Color[] colors; … void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); // HexMetrics.colors = colors; CreateMap(cellCountX, cellCountZ); } … … void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); // HexMetrics.colors = colors; } } 

Schließlich wird auch eine Reihe von Farben in nicht benötigt HexMetrics.

 // public static Color[] colors; 

Einheitspaket

Teil 15: Entfernungen


  • Zeigen Sie die Gitterlinien an.
  • Wechseln Sie zwischen Bearbeitungs- und Navigationsmodus.
  • Berechnen Sie den Abstand zwischen den Zellen.
  • Wir finden Wege um Hindernisse herum.
  • Wir berücksichtigen die variablen Umzugskosten.

Nachdem wir hochwertige Karten erstellt haben, beginnen wir mit der Navigation.


Der kürzeste Weg ist nicht immer gerade.

Rasteranzeige


Die Navigation auf der Karte erfolgt durch Bewegen von Zelle zu Zelle. Um irgendwohin zu gelangen, müssen Sie eine Reihe von Zellen durchlaufen. Um die Schätzung von Entfernungen zu vereinfachen, fügen wir die Option hinzu, das Sechseckgitter anzuzeigen, auf dem unsere Karte basiert.

Maschentextur


Trotz der Unregelmäßigkeiten des Kartennetzes ist das darunter liegende Netz vollkommen flach. Wir können dies zeigen, indem wir ein Gittermuster auf eine Karte projizieren. Dies kann unter Verwendung einer sich wiederholenden Maschentextur erreicht werden.


Wiederholte Netzstruktur.

Die oben gezeigte Textur enthält einen kleinen Teil des Sechseckgitters, der 2 mal 2 Zellen bedeckt. Dieser Bereich ist rechteckig und nicht quadratisch. Da die Textur selbst ein Quadrat ist, sieht das Muster gestreckt aus. Bei der Probenahme müssen wir dies kompensieren.

Gitterprojektion


Um ein Netzmuster zu projizieren, müssen wir dem Terrain- Shader eine Textur-Eigenschaft hinzufügen .

  Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _GridTex ("Grid Texture", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 } 


Reliefmaterial mit Netzstruktur.

Probieren Sie die Textur mit den XZ-Koordinaten der Welt aus und multiplizieren Sie sie dann mit Albedo. Da die Gitterlinien auf der Textur grau sind, wird das Muster in das Relief verwoben.

  sampler2D _GridTex; … void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = GetTerrainColor(IN, 0) + GetTerrainColor(IN, 1) + GetTerrainColor(IN, 2); fixed4 grid = tex2D(_GridTex, IN.worldPos.xz); o.Albedo = c.rgb * grid * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; } 


Albedo multipliziert mit feinmaschigem.

Wir müssen das Muster so skalieren, dass es mit den Zellen in der Karte übereinstimmt. Der Abstand zwischen den Zentren benachbarter Zellen beträgt 15, er muss verdoppelt werden, um zwei Zellen nach oben zu bewegen. Das heißt, wir müssen die Koordinaten des V-Gitters durch 30 teilen. Der Innenradius der Zellen beträgt 5√3, und um zwei Zellen nach rechts zu verschieben, benötigen wir viermal so viel. Daher ist es notwendig, die Koordinaten des U-Gitters durch 20√3 zu teilen.

  float2 gridUV = IN.worldPos.xz; gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); fixed4 grid = tex2D(_GridTex, gridUV); 


Die richtige Maschenweite.

Jetzt entsprechen die Gitterlinien den Zellen der Karte. Wie Relief-Texturen ignorieren sie die Höhe, sodass die Linien entlang der Klippen gespannt werden.


Projektion auf Zellen mit Höhe.

Die Netzverformung ist normalerweise nicht so schlimm, insbesondere wenn Sie eine Karte aus großer Entfernung betrachten.


Mesh in der Ferne.

Netzeinschluss


Das Anzeigen eines Rasters ist zwar praktisch, aber nicht immer erforderlich. Sie sollten es beispielsweise deaktivieren, wenn Sie einen Screenshot machen. Darüber hinaus zieht es nicht jeder vor, das Raster ständig zu sehen. Machen wir es also optional. Wir werden dem Shader die Anweisung multi_compile hinzufügen, um Optionen mit und ohne Raster zu erstellen. Dazu verwenden wir das Schlüsselwort GRID_ON. Die bedingte Shader-Kompilierung wird im Tutorial Rendering 5, Mehrere Lichter, beschrieben .

  #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.5 #pragma multi_compile _ GRID_ON 

Wenn Sie eine Variable deklarieren, gridweisen Sie ihr zuerst den Wert 1 zu. Infolgedessen wird das Raster deaktiviert. Dann werden wir die Gittertextur nur für die Variante mit einem bestimmten Schlüsselwort abtasten GRID_ON.

  fixed4 grid = 1; #if defined(GRID_ON) float2 gridUV = IN.worldPos.xz; gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); grid = tex2D(_GridTex, gridUV); #endif o.Albedo = c.rgb * grid * _Color; 

Da das Schlüsselwort GRID_ONnicht im Terrain-Shader enthalten ist, wird das Raster ausgeblendet. Um es wieder zu aktivieren, fügen wir der Benutzeroberfläche des Karteneditors einen Schalter hinzu. Um dies zu ermöglichen, HexMapEditormuss ich einen Link zum Terrain- Material und eine Methode zum Aktivieren oder Deaktivieren des Schlüsselworts erhalten GRID_ON.

  public Material terrainMaterial; … public void ShowGrid (bool visible) { if (visible) { terrainMaterial.EnableKeyword("GRID_ON"); } else { terrainMaterial.DisableKeyword("GRID_ON"); } } 


Editor März Sechsecke mit Bezug auf das Material.

Fügen Sie der Benutzeroberfläche einen Grid- Schalter hinzu und verbinden Sie ihn mit der Methode ShowGrid.


Gitterschalter.

Status speichern


Jetzt im Wiedergabemodus können wir die Anzeige des Gitters umschalten. Beim ersten Test wird das Gitter zunächst ausgeschaltet und wird sichtbar, wenn wir den Schalter einschalten. Wenn Sie es ausschalten, verschwindet das Raster wieder. Wenn wir jedoch den Wiedergabemodus verlassen, wenn das Raster sichtbar ist, wird es beim nächsten Start des Wiedergabemodus wieder eingeschaltet, obwohl der Schalter ausgeschaltet ist.

Dies liegt daran, dass wir das Schlüsselwort für das allgemeine Terrain- Material ändern . Wir bearbeiten das Material-Asset, sodass die Änderung im Unity-Editor gespeichert wird. Es wird nicht in der Baugruppe gespeichert.

Um das Spiel immer ohne Raster zu starten, deaktivieren wir das Schlüsselwort GRID_ONin Awake HexMapEditor.

  void Awake () { terrainMaterial.DisableKeyword("GRID_ON"); } 

Einheitspaket

Bearbeitungsmodus


Wenn wir die Bewegung auf der Karte steuern möchten, müssen wir mit ihr interagieren. Zumindest müssen wir die Zelle als Startpunkt des Pfades auswählen. Wenn Sie jedoch auf eine Zelle klicken, wird diese bearbeitet. Wir können alle Bearbeitungsoptionen manuell deaktivieren, dies ist jedoch unpraktisch. Außerdem möchten wir nicht, dass Verschiebungsberechnungen während der Kartenbearbeitung durchgeführt werden. Fügen wir also einen Schalter hinzu, der bestimmt, ob wir uns im Bearbeitungsmodus befinden.

Schalter bearbeiten


Fügen Sie dem HexMapEditorBooleschen Feld editModedie Methode hinzu, die es definiert. Fügen Sie dann der Benutzeroberfläche einen weiteren Schalter hinzu, um sie zu steuern. Beginnen wir mit dem Navigationsmodus, dh der Bearbeitungsmodus ist standardmäßig deaktiviert.

  bool editMode; … public void SetEditMode (bool toggle) { editMode = toggle; } 


Bearbeitungsmodusschalter.

Um die Bearbeitung wirklich zu deaktivieren, machen Sie den Anruf EditCellsabhängig von editMode.

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

Debuggen von Labels


Bisher haben wir keine Einheiten, um uns auf der Karte zu bewegen. Stattdessen visualisieren wir Bewegungsentfernungen. Dazu können Sie vorhandene Zellenbezeichnungen verwenden. Daher werden sie sichtbar gemacht, wenn der Bearbeitungsmodus deaktiviert ist.

  public void SetEditMode (bool toggle) { editMode = toggle; hexGrid.ShowUI(!toggle); } 

Da wir mit dem Navigationsmodus beginnen, sollten die Standardbezeichnungen aktiviert sein. Derzeit HexGridChunk.Awakedeaktiviert sie, aber er sollte dies nicht mehr tun.

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


Beschriftungen koordinieren.

Die Zellkoordinaten werden jetzt sofort nach dem Starten des Wiedergabemodus sichtbar. Wir brauchen aber keine Koordinaten, wir verwenden Beschriftungen, um Entfernungen anzuzeigen. Da hierfür nur eine Zahl pro Zelle erforderlich ist, können Sie die Schriftgröße erhöhen, damit sie besser gelesen werden können. Ändern Sie das Fertighaus von Hex Cell Label so, dass Fettdruck mit der Größe 8 verwendet wird.


Tags mit fetter Schriftgröße 8.

Nachdem Sie den Wiedergabemodus gestartet haben, werden große Tags angezeigt. Es sind nur die ersten Koordinaten der Zelle sichtbar, der Rest wird nicht in die Beschriftung eingefügt.


Große Tags.

Da wir die Koordinaten nicht mehr benötigen, löschen wir den HexGrid.CreateCellWert in der Zuordnung label.text.

  void CreateCell (int x, int z, int i) { … Text label = Instantiate<Text>(cellLabelPrefab); label.rectTransform.anchoredPosition = new Vector2(position.x, position.z); // label.text = cell.coordinates.ToStringOnSeparateLines(); cell.uiRect = label.rectTransform; … } 

Sie können den Labels- Schalter und die zugehörige Methode auch von der Benutzeroberfläche entfernen HexMapEditor.ShowUI.

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


Der Methodenwechsel ist nicht mehr.

Einheitspaket

Entfernungen finden


Nachdem wir den markierten Navigationsmodus haben, können wir Entfernungen anzeigen. Wir werden eine Zelle auswählen und dann den Abstand von dieser Zelle zu allen Zellen auf der Karte anzeigen.

Entfernungsanzeige


Fügen Sie dem HexCellGanzzahlfeld hinzu, um die Entfernung zur Zelle zu verfolgen distance. Es zeigt den Abstand zwischen dieser Zelle und der ausgewählten an. Daher ist sie für die ausgewählte Zelle selbst Null, für den unmittelbaren Nachbarn 1 und so weiter.

  int distance; 

Wenn der Abstand eingestellt ist, müssen wir die Zellenbezeichnung aktualisieren, um ihren Wert anzuzeigen. HexCellhat einen Verweis auf das RectTransformUI-Objekt. Wir müssen ihn anrufen GetComponent<Text>, um in die Zelle zu gelangen. Überlegen Sie, was Textsich im Namespace UnityEngine.UIbefindet. Verwenden Sie ihn daher am Anfang des Skripts.

  void UpdateDistanceLabel () { Text label = uiRect.GetComponent<Text>(); label.text = distance.ToString(); } 

Sollten wir nicht einen direkten Link zur Textkomponente behalten?
, . , , , . , .

Legen Sie die allgemeine Eigenschaft für den Empfang und die Entfernung zur Zelle sowie die Aktualisierung ihrer Beschriftung fest.

  public int Distance { get { return distance; } set { distance = value; UpdateDistanceLabel(); } } 

Fügen Sie der HexGridallgemeinen Methode FindDistancesToden Zellenparameter hinzu. Im Moment setzen wir einfach den Nullabstand zu jeder Zelle.

  public void FindDistancesTo (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = 0; } } 

Wenn der Bearbeitungsmodus nicht aktiviert ist, HexMapEditor.HandleInputrufen wir eine neue Methode mit der aktuellen Zelle auf.

  if (editMode) { EditCells(currentCell); } else { hexGrid.FindDistancesTo(currentCell); } 

Abstände zwischen Koordinaten


Im Navigationsmodus zeigen alle Zellen nach dem Berühren einer von ihnen Null an. Aber natürlich sollten sie den wahren Abstand zur Zelle anzeigen. Um den Abstand zu ihnen zu berechnen, können wir die Koordinaten der Zelle verwenden. Nehmen Sie daher an, dass es HexCoordinateseine Methode hat DistanceTo, und verwenden Sie sie in HexGrid.FindDistancesTo.

  public void FindDistancesTo (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = cell.coordinates.DistanceTo(cells[i].coordinates); } } 

Fügen Sie nun der HexCoordinatesMethode hinzu DistanceTo. Er muss seine eigenen Koordinaten mit den Koordinaten eines anderen Satzes vergleichen. Beginnen wir nur mit der Messung von X und subtrahieren die X-Koordinaten voneinander.

  public int DistanceTo (HexCoordinates other) { return x - other.x; } 

Als Ergebnis erhalten wir einen Versatz entlang X relativ zur ausgewählten Zelle. Die Abstände können jedoch nicht negativ sein, daher müssen Sie die Koordinatendifferenz X modulo zurückgeben.

  return x < other.x ? other.x - x : x - other.x; 


Entfernungen entlang X.

Wir erhalten also nur dann die richtigen Entfernungen, wenn wir nur eine Dimension berücksichtigen. Es gibt jedoch drei Dimensionen in einem Sechseckgitter. Addieren wir also die Abstände für alle drei Dimensionen und sehen, was es uns gibt.

  return (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y) + (z < other.z ? other.z - z : z - other.z); 


Summe der XYZ-Entfernungen.

Es stellt sich heraus, dass wir die doppelte Entfernung bekommen. Das heißt, um den richtigen Abstand zu erhalten, muss dieser Betrag in zwei Hälften geteilt werden.

  return ((x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y) + (z < other.z ? other.z - z : z - other.z)) / 2; 


Echte Entfernungen.

Warum ist die Summe doppelt so groß wie die Entfernung?
, . , (1, −3, 2). . , . . , . .


.

Einheitspaket

Arbeite mit Hindernissen


Die von uns berechneten Abstände entsprechen den kürzesten Wegen von der ausgewählten Zelle zu jeder anderen Zelle. Wir können keinen kürzeren Weg finden. Diese Pfade sind jedoch garantiert korrekt, wenn die Route nichts blockiert. Klippen, Wasser und andere Hindernisse können uns herumlaufen lassen. Vielleicht können einige Zellen überhaupt nicht erreicht werden.

Um Hindernisse zu umgehen, müssen wir einen anderen Ansatz verwenden, anstatt einfach den Abstand zwischen den Koordinaten zu berechnen. Wir können nicht mehr jede Zelle einzeln untersuchen. Wir müssen die Karte durchsuchen, bis wir jede Zelle gefunden haben, die erreicht werden kann.

Suchvisualisierung


Die Kartensuche ist ein iterativer Prozess. Um zu verstehen, was wir tun, wäre es hilfreich, jede Phase der Suche zu sehen. Wir können dies tun, indem wir den Suchalgorithmus in eine Coroutine verwandeln, für die wir einen Suchraum benötigen System.Collections. Die Aktualisierungsrate von 60 Iterationen pro Sekunde ist klein genug, um zu sehen, was passiert, und die Suche auf einer kleinen Karte hat nicht allzu viel Zeit in Anspruch genommen.

  public void FindDistancesTo (HexCell cell) { StartCoroutine(Search(cell)); } IEnumerator Search (HexCell cell) { WaitForSeconds delay = new WaitForSeconds(1 / 60f); for (int i = 0; i < cells.Length; i++) { yield return delay; cells[i].Distance = cell.coordinates.DistanceTo(cells[i].coordinates); } } 

Wir müssen sicherstellen, dass jeweils nur eine Suche aktiv ist. Bevor wir eine neue Suche starten, stoppen wir daher alle Coroutinen.

  public void FindDistancesTo (HexCell cell) { StopAllCoroutines(); StartCoroutine(Search(cell)); } 

Außerdem müssen wir die Suche abschließen, wenn wir eine neue Karte laden.

  public void Load (BinaryReader reader, int header) { StopAllCoroutines(); … } 

Breitensuche


Noch bevor wir mit der Suche beginnen, wissen wir, dass der Abstand zur ausgewählten Zelle Null ist. Und natürlich beträgt die Entfernung zu allen Nachbarn 1, wenn sie erreichbar sind. Dann können wir uns einen dieser Nachbarn ansehen. Diese Zelle hat höchstwahrscheinlich ihre eigenen Nachbarn, die erreicht werden können und für die die Entfernung noch nicht berechnet wurde. Wenn ja, sollte der Abstand zu diesen Nachbarn 2 betragen. Wir können diesen Vorgang für alle Nachbarn in einem Abstand von 1 wiederholen. Danach wiederholen wir ihn für alle Nachbarn in einem Abstand von 2. Und so weiter, bis wir alle Zellen erreichen.

Das heißt, zuerst finden wir alle Zellen in einem Abstand von 1, dann finden wir alles in einem Abstand von 2, dann in einem Abstand von 3 und so weiter, bis wir fertig sind. Dies stellt sicher, dass wir den kleinsten Abstand zu jeder erreichbaren Zelle finden. Dieser Algorithmus wird als Breitensuche bezeichnet.

Damit es funktioniert, müssen wir wissen, ob wir den Abstand zur Zelle bereits bestimmt haben. Zu diesem Zweck werden Zellen häufig in einer Sammlung abgelegt, die als fertige oder beiliegende Gruppe bezeichnet wird. Wir können jedoch den Abstand zur Zelle festlegen, int.MaxValueum anzuzeigen, dass wir sie noch nicht besucht haben. Wir müssen dies für alle Zellen tun, bevor wir eine Suche durchführen.

  IEnumerator Search (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } … } 

Sie können dies auch verwenden, um alle nicht besuchten Zellen durch Ändern auszublenden HexCell.UpdateDistanceLabel. Danach beginnen wir jede Suche auf einer leeren Karte.

  void UpdateDistanceLabel () { Text label = uiRect.GetComponent<Text>(); label.text = distance == int.MaxValue ? "" : distance.ToString(); } 

Als nächstes müssen wir die Zellen verfolgen, die besucht werden müssen, und die Reihenfolge, in der sie besucht werden. Eine solche Sammlung wird oft als Grenze oder offene Menge bezeichnet. Wir müssen nur die Zellen in der Reihenfolge verarbeiten, in der wir sie getroffen haben. Dazu können Sie die Warteschlange verwenden Queue, die Teil des Namespace ist System.Collections.Generic. Die ausgewählte Zelle wird als erste in diese Warteschlange gestellt und hat einen Abstand von 0.

  IEnumerator Search (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } WaitForSeconds delay = new WaitForSeconds(1 / 60f); Queue<HexCell> frontier = new Queue<HexCell>(); cell.Distance = 0; frontier.Enqueue(cell); // for (int i = 0; i < cells.Length; i++) { // yield return delay; // cells[i].Distance = // cell.coordinates.DistanceTo(cells[i].coordinates); // } } 

Von diesem Moment an führt der Algorithmus die Schleife aus, während sich etwas in der Warteschlange befindet. Bei jeder Iteration wird die vorderste Zelle aus der Warteschlange abgerufen.

  frontier.Enqueue(cell); while (frontier.Count > 0) { yield return delay; HexCell current = frontier.Dequeue(); } 

Jetzt haben wir die aktuelle Zelle, die sich in beliebiger Entfernung befinden kann. Als nächstes müssen wir alle Nachbarn einen Schritt weiter von der ausgewählten Zelle entfernt zur Warteschlange hinzufügen.

  while (frontier.Count > 0) { yield return delay; HexCell current = frontier.Dequeue(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor != null) { neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); } } } 

Wir sollten jedoch nur die Zellen hinzufügen, denen noch kein Abstand zugewiesen wurde.

  if (neighbor != null && neighbor.Distance == int.MaxValue) { neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); } 


Breite Suche.

Vermeiden Sie Wasser


Nachdem wir sichergestellt haben, dass die Breitensuche die richtigen Entfernungen auf der monotonen Karte findet, können wir beginnen, Hindernisse hinzuzufügen. Dies kann erreicht werden, indem das Hinzufügen von Zellen zur Warteschlange verweigert wird, wenn bestimmte Bedingungen erfüllt sind.

Tatsächlich überspringen wir bereits einige Zellen: diejenigen, die nicht existieren, und diejenigen, zu denen wir bereits die Entfernung angegeben haben. Schreiben wir den Code neu, sodass wir in diesem Fall die Nachbarn explizit überspringen.

  for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor == null || neighbor.Distance != int.MaxValue) { continue; } neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); } 

Lassen Sie uns auch alle Zellen überspringen, die sich unter Wasser befinden. Dies bedeutet, dass wir bei der Suche nach den kürzesten Entfernungen nur Bewegungen am Boden berücksichtigen.

  if (neighbor == null || neighbor.Distance != int.MaxValue) { continue; } if (neighbor.IsUnderwater) { continue; } 


Entfernungen ohne Bewegung durch Wasser.

Der Algorithmus findet immer noch die kürzesten Entfernungen, vermeidet jetzt jedoch alles Wasser. Daher gewinnen Unterwasserzellen niemals Abstand, wie isolierte Landflächen. Die Unterwasserzelle erhält nur dann eine Entfernung, wenn sie ausgewählt ist.

Vermeiden Sie Klippen


Um die Möglichkeit eines Besuchs bei einem Nachbarn zu bestimmen, können wir auch die Art der Rippe verwenden. Zum Beispiel können Sie Klippen den Weg blockieren lassen. Wenn Sie Bewegung an Hängen zulassen, können die Zellen auf der anderen Seite der Klippe nur auf anderen Pfaden erreicht werden. Daher können sie sich in sehr unterschiedlichen Abständen befinden.

  if (neighbor.IsUnderwater) { continue; } if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) { continue; } 


Entfernungen ohne Klippen zu überqueren.

Einheitspaket

Reisekosten


Wir können Zellen und Kanten vermeiden, aber diese Optionen sind binär. Man kann sich vorstellen, dass es einfacher ist, in einige Richtungen zu navigieren als in andere. In diesem Fall wird die Entfernung in Arbeit oder Zeit gemessen.

Schnelle Straßen


Es ist logisch, dass das Fahren auf Straßen einfacher und schneller ist. Lassen Sie uns also die Kreuzung von Kanten mit Straßen kostengünstiger gestalten. Da wir ganzzahlige Werte verwenden, um die Bewegungsentfernung festzulegen, belaufen sich die Kosten für das Bewegen entlang der Straßen auf 1, und die Kosten für das Überqueren anderer Kanten steigen auf 10. Dies ist ein großer Unterschied, sodass wir sofort feststellen können, ob wir die richtigen Ergebnisse erzielen.

  int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += 10; } neighbor.Distance = distance; 


Straßen mit falschen Entfernungen.

Rahmensortierung


Leider stellt sich heraus, dass die Breitensuche nicht mit variablen Umzugskosten funktionieren kann. Er geht davon aus, dass Zellen in der Reihenfolge zunehmender Entfernung zur Grenze hinzugefügt werden, und für uns ist dies nicht mehr relevant. Wir brauchen eine Prioritätswarteschlange, dh eine Warteschlange, die sich selbst sortiert. Es gibt keine Warteschlangen mit Standardpriorität, da Sie sie nicht so programmieren können, dass sie für alle Situationen geeignet sind.

Wir können unsere eigene Prioritätswarteschlange erstellen, diese jedoch für das zukünftige Lernprogramm optimieren. Im Moment ersetzen wir einfach die Warteschlange durch eine Liste mit einer Methode Sort.

  List<HexCell> frontier = new List<HexCell>(); cell.Distance = 0; frontier.Add(cell); while (frontier.Count > 0) { yield return delay; HexCell current = frontier[0]; frontier.RemoveAt(0); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … neighbor.Distance = distance; frontier.Add(neighbor); } } 

Kann ich ListPool <HexCell> nicht verwenden?
, , . , , .

Damit der Rand korrekt ist, müssen wir ihn sortieren, nachdem wir ihm eine Zelle hinzugefügt haben. Tatsächlich können wir die Sortierung verschieben, bis alle Nachbarn der Zelle hinzugefügt wurden, aber ich wiederhole, bis uns Optimierungen nicht mehr interessieren.

Wir wollen die Zellen nach Entfernung sortieren. Dazu müssen wir die Listensortierungsmethode mit einem Link zu der Methode aufrufen, die diesen Vergleich durchführt.

  frontier.Add(neighbor); frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance)); 

Wie funktioniert diese Sortiermethode?
. , . .

  frontier.Sort(CompareDistances); … static int CompareDistances (HexCell x, HexCell y) { return x.Distance.CompareTo(y.Distance); } 


Der sortierte Rand ist immer noch falsch.

Grenzaktualisierung


Nachdem wir mit dem Sortieren der Grenze begonnen hatten, erzielten wir bessere Ergebnisse, aber es gibt immer noch Fehler. Dies liegt daran, dass beim Hinzufügen einer Zelle zum Rand nicht unbedingt der kürzeste Abstand zu dieser Zelle gefunden wird. Dies bedeutet, dass wir jetzt keine Nachbarn mehr überspringen können, denen bereits eine Entfernung zugewiesen wurde. Stattdessen müssen wir prüfen, ob wir einen kürzeren Weg gefunden haben. Wenn ja, müssen wir den Abstand zum Nachbarn ändern, anstatt ihn an der Grenze hinzuzufügen.

  HexCell neighbor = current.GetNeighbor(d); if (neighbor == null) { continue; } if (neighbor.IsUnderwater) { continue; } if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) { continue; } int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += 10; } if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; frontier.Add(neighbor); } else if (distance < neighbor.Distance) { neighbor.Distance = distance; } frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance)); 


Die richtigen Abstände.

Jetzt, da wir die richtigen Entfernungen haben, werden wir anfangen, die Kosten für den Umzug zu berücksichtigen. Möglicherweise stellen Sie fest, dass die Abstände zu einigen Zellen anfangs zu groß sind, aber korrigiert werden, wenn sie vom Rand entfernt werden. Dieser Ansatz wird als Dijkstra-Algorithmus bezeichnet und ist nach dem ersten von Edsger Dijkstra erfundenen benannt.

Pisten


Wir wollen uns nicht nur auf unterschiedliche Kosten für Straßen beschränken. Beispielsweise können Sie die Kosten für das Überqueren flacher Kanten ohne Straßen auf 5 reduzieren, sodass Hänge ohne Straßen einen Wert von 10 haben.

  HexEdgeType edgeType = current.GetEdgeType(neighbor); if (edgeType == HexEdgeType.Cliff) { continue; } int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; } 


Um die Pisten zu überwinden, müssen Sie mehr arbeiten, und die Straßen sind immer schnell.

Reliefobjekte


Bei Vorhandensein von Reliefobjekten können wir Kosten hinzufügen. In vielen Spielen ist es beispielsweise schwieriger, durch Wälder zu navigieren. In diesem Fall fügen wir einfach alle Objektebenen zur Entfernung hinzu. Und auch hier beschleunigt die Straße alles.

  if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; distance += neighbor.UrbanLevel + neighbor.FarmLevel + neighbor.PlantLevel; } 


Objekte werden langsamer, wenn keine Straße vorhanden ist.

Die Wände


Lassen Sie uns zum Schluss die Wände berücksichtigen. Wände sollten die Bewegung blockieren, wenn die Straße nicht durch sie führt.

  if (current.HasRoadThroughEdge(d)) { distance += 1; } else if (current.Walled != neighbor.Walled) { continue; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; distance += neighbor.UrbanLevel + neighbor.FarmLevel + neighbor.PlantLevel; } 


Die Wände lassen uns nicht passieren, Sie müssen nach dem Tor suchen.

Einheitspaket

Source: https://habr.com/ru/post/de425919/


All Articles