Prozedurale Muster zur Verwendung mit Tilemaps, Teil 2

In Teil 1 haben wir uns einige Möglichkeiten angesehen, wie wir mit verschiedenen Methoden wie Perlin Noise und Random Walk prozedural Oberebenen erstellen können. In diesem Beitrag werden wir uns einige Möglichkeiten ansehen, wie man Höhlen mit prozeduraler Generierung erstellen kann, was Ihnen eine Vorstellung von den möglichen Variationen geben soll.
Alles, worüber wir in diesem Blogbeitrag sprechen werden, ist in diesem Projekt verfügbar. Sie können die Assets herunterladen und die Verfahrensalgorithmen ausprobieren.

Dieser Blogbeitrag unterliegt denselben Regeln wie Teil I. Zur Erinnerung: Diese Regeln lauten:
- Die Art und Weise, wie wir unterscheiden, ob es sich um eine Kachel handelt oder nicht, ist die Verwendung des Binärsystems. 1 ist ein und 0 ist aus.
- Wir werden alle unsere Karten in einem 2D-Integer-Array speichern, das am Ende jeder Funktion (außer beim Rendern) an den Benutzer zurückgegeben wird.
- Ich werde die Array-Funktion GetUpperBound() verwenden, um die Höhe und Breite der Karte zu ermitteln. Dies bedeutet, dass wir weniger Variablen in jeder Funktion haben, was einen saubereren Code ermöglicht.
- Ich verwende oft Mathf.FloorToInt(), weil das Koordinatensystem der Kachelkarte unten links beginnt und die Verwendung von Mathf.FloorToInt() es uns ermöglicht, die Zahlen auf eine ganze Zahl zu runden.
- Der gesamte Code in diesem Blogbeitrag ist in C# geschrieben.
Im vorangegangenen Blogbeitrag haben wir uns einige Möglichkeiten angesehen, Perlin-Rauschen zur Erstellung von Deckschichten zu verwenden. Glücklicherweise können wir auch Perlin Noise verwenden, um eine Höhle zu schaffen. Dazu erhalten wir einen neuen Perlin-Rauschwert, der die Parameter unserer aktuellen Position multipliziert mit einem Modifikator enthält. Der Modifikator ist ein Wert zwischen 0 und 1. Je größer der Wert des Modifikators, desto unordentlicher ist die Perlin-Erzeugung. Anschließend runden wir diesen Wert auf eine ganze Zahl von entweder 0 oder 1, die wir im Array map speichern. Schauen Sie sich die Umsetzung an:
Unbekannter Blocktyp "codeBlock", bitte geben Sie einen Serializer dafür in der `serializers.types` prop an
Der Grund für die Verwendung eines Modifikators anstelle eines Seeds ist, dass die Ergebnisse der Perlin-Generierung besser aussehen, wenn wir die Werte mit einer Zahl zwischen 0 und 0,5 multiplizieren. Je niedriger der Wert, desto blockiger das Ergebnis. Werfen Sie einen Blick auf einige der Ergebnisse. Dieses Gif beginnt mit einem Modifikatorwert von 0,01 und arbeitet sich schrittweise bis 0,25 vor.

Anhand dieses Gifs können Sie sehen, dass die Perlin-Generation das Muster mit jedem Tick vergrößert.
Im vorigen Blogbeitrag haben wir gesehen, dass wir mit einem Münzwurf bestimmen können, ob die Plattform steigt oder fällt. In diesem Beitrag werden wir die gleiche Idee verwenden, aber mit zwei zusätzlichen Optionen für links und rechts. Diese Variante des Random-Walk-Algorithmus ermöglicht es uns, Höhlen zu erstellen. Wir tun dies, indem wir eine zufällige Richtung erhalten, dann bewegen wir unsere Position und entfernen das Plättchen. Wir setzen diesen Prozess fort, bis wir die erforderliche Menge an Boden erreicht haben, die wir vernichten müssen. Im Moment verwenden wir nur 4 Richtungen: oben, unten, links, rechts.
Unbekannter Blocktyp "codeBlock", bitte geben Sie einen Serializer dafür in der `serializers.types` prop an
Wir beginnen die Funktion mit:
Unsere Startposition finden
Berechnung der Anzahl der zu entfernenden Bodenfliesen
Entfernen der Fliese an der Startposition
Ein Stockwerk mehr auf dem Konto
Als Nächstes gehen wir zur while-Schleife über. Damit wird die Höhle für uns geschaffen:
Unbekannter Blocktyp "codeBlock", bitte geben Sie einen Serializer dafür in der `serializers.types` prop an
Nun, zunächst einmal entscheiden wir anhand einer Zufallszahl, in welche Richtung wir uns bewegen sollen. Als Nächstes überprüfen wir die neue Richtung mit einer switch case-Anweisung. In dieser Anweisung wird geprüft, ob die Position eine Wand ist. Ist dies nicht der Fall, wird das Kachelstück aus dem Array entfernt. Dies wird so lange fortgesetzt, bis die erforderliche Mindestmenge erreicht ist. Das Endergebnis ist unten dargestellt:

Ich habe auch eine eigene Version dieser Funktion erstellt, die auch diagonale Richtungen einbezieht. Der Code für diese Funktion ist etwas lang. Wenn Sie ihn sich ansehen möchten, klicken Sie bitte auf den Link zum Projekt am Anfang dieses Blogbeitrags.
Ein Richtungstunnel beginnt an einem Ende der Karte und führt dann zum gegenüberliegenden Ende. Wir können die Kurve und die Rauheit des Tunnels steuern, indem wir sie in die Funktion eingeben. Wir können auch die minimale und maximale Länge der Tunnelteile bestimmen. Werfen wir einen Blick auf die nachstehende Umsetzung:
public static int[,] DirectionalTunnel(int[,] map, int minPathWidth, int maxPathWidth, int maxPathChange, int roughness, int curvyness)
{
//Dieser Wert geht von seinem negativen Gegenstück zu seinem positiven Wert, in diesem Fall mit einem Breitenwert von 1 ist die Breite des Tunnels 3
int tunnelWidth = 1;
//Setzen Sie die Start-X-Position auf die Mitte des Tunnels
int x = map.GetUpperBound(0) / 2;
//Zufallsgenerator mit dem Seed einrichten
System.Random rand = new System.Random(Time.time.GetHashCode());
//Erstellen des ersten Teils des Tunnels
for (int i = -tunnelBreite; i <= tunnelBreite; i++)
{
map[x + i, 0] = 0;
}
Zunächst legen wir einen Breitenwert fest. Dieser Breitenwert wird von seinem negativen Gegenstück zu seinem positiven Wert wechseln. Auf diese Weise erhalten wir die tatsächliche Größe, die wir wollen. In diesem Fall wird ein Wert von 1 verwendet. Dies wiederum ergibt eine Gesamtbreite von 3, da wir die Werte -1, 0, 1 verwenden.
Als Nächstes legen wir die x-Position fest, indem wir die Mitte der Breite der Karte bestimmen. Jetzt, wo wir die ersten Werte festgelegt haben, können wir den ersten Teil der Karte tunneln.

Lassen Sie uns nun mit dem Rest der Karte fortfahren.
Unbekannter Blocktyp "codeBlock", bitte geben Sie einen Serializer dafür in der `serializers.types` prop an
Erzeugen Sie eine Zufallszahl, die mit unserem Rauheitswert verglichen wird. Liegt sie über dem Wert, können wir die Breite des Pfades ändern. Wir prüfen auch, ob wir die Breite zu klein machen. Mit dem nächsten Teil des Codes arbeiten wir uns durch die Karte und graben dabei Tunnel. Bei jedem Schritt gehen wir wie folgt vor:
Erzeugen Sie eine neue Zufallszahl, die mit unserem Kurvenwert verglichen wird. Wie bei der vorherigen Prüfung können wir den Mittelpunkt des Pfades ändern, wenn er über dem Wert liegt. Wir überprüfen auch, ob wir uns nicht von den Rändern der Karte entfernen.
Zum Schluss tunneln wir den neuen Abschnitt, den wir geschaffen haben.
Das Endergebnis dieser Implementierung sieht folgendermaßen aus:

Zellulare Automaten verwenden eine Nachbarschaft von Zellen, um zu bestimmen, ob die aktuelle Zelle eingeschaltet (1) oder ausgeschaltet (0) ist. Die Grundlage für diese Nachbarschaften bildet ein nach dem Zufallsprinzip generiertes Raster von Zellen. In unserem Fall werden wir dieses anfängliche Gitter mit der Funktion Random.Next in C# erzeugen.
Da wir einige verschiedene Implementierungen von Cellular Automata haben, habe ich eine separate Funktion für die Erzeugung dieses Basisgitters erstellt. Die Funktion sieht wie folgt aus:
Unbekannter Blocktyp "codeBlock", bitte geben Sie einen Serializer dafür in der `serializers.types` prop an
In dieser Funktion können wir auch festlegen, ob wir Wände in unserem Raster haben wollen. Ansonsten ist es relativ einfach. Wir vergleichen eine Zufallszahl mit unserem Füllungsgrad, um festzustellen, ob die aktuelle Zelle ein- oder ausgeschaltet ist. Schauen Sie sich das Ergebnis an:

Die Moore-Nachbarschaft wird verwendet, um die anfängliche Generierung von Zellularautomaten zu glätten. Das Moore-Viertel sieht so aus:

Die Regeln für die Nachbarschaft lauten wie folgt:
- Suchen Sie in jeder Richtung nach einem Nachbarn.
- Wenn ein Nachbar ein aktives Plättchen ist, wird ein Plättchen zu den umliegenden Plättchen hinzugefügt.
- Wenn ein Nachbar keine aktive Kachel ist, wird nichts unternommen.
- Wenn die Zelle mehr als 4 umliegende Spielsteine hat, wird die Zelle zu einem aktiven Spielstein.
- Wenn die Zelle genau 4 Umgebungsplättchen hat, lässt man das Plättchen liegen.
- Wiederholen Sie dies, bis Sie alle Kacheln auf der Karte ausprobiert haben.
Die Funktion zur Überprüfung der Moore-Nachbarschaft lautet wie folgt:
static int GetMooreSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)
{
/* Moore Neighbourhood sieht so aus ('T' ist unsere Kachel, 'N' sind unsere Nachbarn)
*
* N N N
* N T N
* N N N
*
*/
int tileCount = 0;
for(int neighbourX = x - 1; neighbourX <= x + 1; neighbourX++)
{
for(int neighbourY = y - 1; neighbourY <= y + 1; neighbourY++)
{
if (NachbarX >= 0 && NachbarX < map.GetUpperBound(0) && NachbarY >= 0 && NachbarY < map.GetUpperBound(1))
{
//Wir wollen die Kachel, deren Umgebung wir überprüfen, nicht mitzählen
if(neighbourX != x || neighbourY != y)
{
tileCount += map[neighbourX, neighbourY];
}
}
}
}
return tileCount;
}
Nachdem wir unsere Kacheln überprüft haben, verwenden wir die Informationen in unserer Glättungsfunktion. Wie bei der anfänglichen Generierung von Zellularautomaten können wir auch hier festlegen, ob die Kanten der Karte Wände sind.
public static int[,] SmoothMooreCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)
{
for (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
int surroundingTiles = GetMooreSurroundingTiles(map, x, y, edgesAreWalls);
if (edgesAreWalls && (x == 0 || x == (map.GetUpperBound(0) - 1) || y == 0 || y == (map.GetUpperBound(1) - 1)))
{
//Einstellung, dass die Kante eine Wand ist, wenn edgesAreWalls auf true gesetzt ist
map[x, y] = 1;
}
//Die Standard-Moore-Regel erfordert mehr als 4 Nachbarn
else if (surroundingTiles > 4)
{
map[x, y] = 1;
}
else if (surroundingTiles < 4)
{
map[x, y] = 0;
}
}
}
}
//Rückgabe der geänderten Karte
Karte zurückgeben;
}
Ein wichtiger Punkt in dieser Funktion ist die Tatsache, dass wir eine for-Schleife haben, um die Karte eine bestimmte Anzahl von Malen zu durchlaufen. Dies führt zu einer schöneren Karte als Ergebnis.

Wir könnten diesen Algorithmus immer weiter abändern, indem wir Räume miteinander verbinden, wenn beispielsweise nur 2 Blöcke zwischen ihnen liegen.
Die von-Neumann-Nachbarschaft ist eine weitere beliebte Implementierungsmethode für zellulare Automaten. Bei dieser Generation verwenden wir eine einfachere Nachbarschaft als bei der Moore-Generation. Die Nachbarschaft sieht so aus:

Für dieses Viertel gelten die folgenden Regeln:
- Prüfen Sie den Bereich um die Fliese herum bis zu den direkten Nachbarn, ohne die Diagonalen einzubeziehen.
- Wenn die Zelle aktiv ist, wird der Zähler um eins erhöht.
- Wenn die Zelle inaktiv ist, unternehmen Sie nichts.
- Wenn wir mehr als 2 Nachbarn haben, wird die aktuelle Zelle aktiv.
- Wenn wir weniger als 2 Nachbarn haben, wird die aktuelle Zelle inaktiv.
- Wenn wir genau 2 Nachbarn haben, wird die aktuelle Zelle nicht verändert.
Das zweite Ergebnis beruht auf denselben Grundsätzen wie das erste, erweitert aber das Gebiet der Nachbarschaft.
Wir überprüfen die Nachbarn mit Hilfe der folgenden Funktion:
static int GetVNSurroundingTiles(int[,] map, int x, int y, bool edgesAreWalls)
{
/* Die von-Neumann-Nachbarschaft sieht wie folgt aus ('T' ist unsere Kachel, 'N' ist unsere Nachbarin)
*
* N
* N T N
* N
*
*/
int tileCount = 0;
//Behalten Sie die Kanten als Wände
if(edgesAreWalls && (x - 1 == 0 || x + 1 == map.GetUpperBound(0) || y - 1 == 0 || y + 1 == map.GetUpperBound(1)))
{
tileCount++;
}
//Versichern, dass wir die linke Seite der Karte nicht berühren
if(x - 1 > 0)
{
tileCount += map[x - 1, y];
}
//Versichern, dass wir nicht den Boden der Karte berühren
wenn(y - 1 > 0)
{
tileCount += map[x, y - 1];
}
//Versichern, dass wir die rechte Seite der Karte nicht berühren
if(x + 1 < map.GetUpperBound(0))
{
tileCount += map[x + 1, y];
}
//Versichern, dass wir den oberen Rand der Karte nicht berühren
if(y + 1 < map.GetUpperBound(1))
{
tileCount += map[x, y + 1];
}
return tileCount;
}
Nachdem wir nun wissen, wie viele Nachbarn wir haben, können wir mit der Glättung des Feldes beginnen. Wie zuvor haben wir eine for-Schleife, die die Glättung für den eingegebenen Betrag durchläuft.
public static int[,] SmoothVNCellularAutomata(int[,] map, bool edgesAreWalls, int smoothCount)
{
for (int i = 0; i < smoothCount; i++)
{
for (int x = 0; x < map.GetUpperBound(0); x++)
{
for (int y = 0; y < map.GetUpperBound(1); y++)
{
//Bestimmen Sie die umliegenden Fliesen
int surroundingTiles = GetVNSurroundingTiles(map, x, y, edgesAreWalls);
if (edgesAreWalls && (x == 0 || x == map.GetUpperBound(0) - 1 || y == 0 || y == map.GetUpperBound(1)))
{
//Behalten Sie unsere Kanten als Wände
map[x, y] = 1;
}
//von Neuemann Nachbarschaft erfordert nur 3 oder mehr umliegende Kacheln, um in eine Kachel geändert zu werden
else if (surroundingTiles > 2)
{
map[x, y] = 1;
}
else if (surroundingTiles < 2)
{
map[x, y] = 0;
}
}
}
}
//Rückgabe der geänderten Karte
Karte zurückgeben;
}
Das Endergebnis sieht viel blockiger aus als die Moore-Nachbarschaft, wie man unten sehen kann:

Auch hier könnten wir, wie bei der Moore-Nachbarschaft, ein zusätzliches Skript über die Generierung laufen lassen, um bessere Verbindungen zwischen den Bereichen der Karte herzustellen.
Ich hoffe, ich konnte Sie dazu inspirieren, in Ihren Projekten eine Form der prozeduralen Generierung zu verwenden. Wenn Sie das Projekt noch nicht heruntergeladen haben, können Sie es hier abrufen. Wenn Sie mehr über die prozedurale Generierung von Karten erfahren möchten, besuchen Sie das Procedural Generation Wiki oder Roguebasin.com, die beide großartige Ressourcen darstellen.
Wenn ihr etwas Cooles mit prozeduraler Generierung macht, dann hinterlasst mir eine Nachricht auf Twitter oder einen Kommentar unten!
Möchten Sie mehr darüber erfahren und eine Live-Demo erhalten? Ich spreche auch über prozedurale Muster zur Verwendung mit Tilemaps auf der Unite Berlin, im Mini-Theater der Expo-Halle am 20. Juni. Ich werde nach dem Vortrag in der Nähe sein, wenn Sie sich persönlich mit mir unterhalten möchten!
