Roguelike in Unity von Grund auf neu erstellen

Bild

Es gibt nicht viele Tutorials zum Erstellen von Roguelike in Unity, daher habe ich beschlossen, es zu schreiben. Nicht zu prahlen, sondern Wissen mit denen zu teilen, die sich in der Phase befinden, in der ich schon eine ganze Weile war.

Hinweis: Ich sage nicht, dass dies der einzige Weg ist, um in Unity ein Roguelike zu erstellen. Er ist nur einer von . Wahrscheinlich nicht die beste und effektivste, habe ich durch Ausprobieren gelernt. Und ich werde einige Dinge direkt beim Erstellen eines Tutorials lernen.

Nehmen wir an, Sie kennen zumindest die Grundlagen von UnityZum Beispiel, wie man ein Fertighaus oder ein Skript erstellt und dergleichen. Erwarten Sie nicht, dass ich Ihnen beibringe, wie man Sprite-Blätter erstellt. Es gibt viele großartige Tutorials dazu. Ich werde mich nicht darauf konzentrieren, die Engine zu studieren, sondern darauf, wie das Spiel implementiert wird, das wir gemeinsam erstellen werden. Wenn Sie auf Schwierigkeiten stoßen , besuchen Sie eine der großartigen Communitys von Discord und bitten Sie um Hilfe:

Unity Developer Community

Roguelikes

Also, fangen wir an!

Stufe 0 - Planung


Ja, das ist richtig. Das erste, was erstellt werden muss, ist ein Plan. Es ist gut für Sie, das Spiel zu planen, und für mich, das Tutorial so zu planen, dass wir nach einer Weile nicht mehr vom Thema abgelenkt werden. Es ist leicht, in den Funktionen des Spiels verwirrt zu werden, genau wie in den schurkenhaften Dungeons.

Wir werden roguelike schreiben. Wir werden vor allem den klugen Ratschlägen von Cogmind Entwickler Josh Ge folgen hier . Folgen Sie dem Link, lesen Sie den Beitrag oder sehen Sie sich das Video an und kommen Sie dann zurück.

Was ist der Zweck dieses Tutorials? Holen Sie sich ein solides einfaches Roguelike, mit dem Sie dann experimentieren können. Es sollte eine Dungeon-Generation, einen Spieler, der sich auf der Karte bewegt, Sichtbarkeitsnebel, Feinde und Objekte haben. Nur das Notwendigste. Der Spieler sollte also mehrere Stockwerke die Treppe hinuntergehen können. Nehmen wir an, Sie erhöhen Ihr Level um fünf, verbessern sich und kämpfen am Ende mit dem Boss und besiegen ihn. Oder stirb. Das ist in der Tat alles.

Nach dem Rat von Josh Ge werden wir die Funktionen des Spiels so gestalten, dass sie uns zum Ziel führen. So erhalten wir das Roguelike-Framework, das weiter ausgebaut werden kann. Fügen Sie Ihre eigenen Chips hinzu, um Einzigartigkeit zu schaffen. Oder werfen Sie alles in den Korb, nutzen Sie die gesammelten Erfahrungen und fangen Sie von vorne an. Es wird sowieso großartig sein.

Ich werde Ihnen keine grafischen Ressourcen geben. Zeichnen Sie sie selbst oder verwenden Sie die kostenlosen Kachelsätze, die hier , hier oder bei der Suche in Google heruntergeladen werden können . Vergiss nur nicht, die Autoren der Grafiken im Spiel zu erwähnen.

Lassen Sie uns nun alle Funktionen, die in unserem Roguelike enthalten sein werden, in der Reihenfolge ihrer Implementierung auflisten:

  1. Dungeon Map Generation
  2. Spielercharakter und seine Bewegung
  3. Sichtbereich
  4. Feinde
  5. Suche nach einem Weg
  6. Kampf, Gesundheit und Tod
  7. Spieler Level Up
  8. Gegenstände (Waffen und Tränke)
  9. Konsolen-Cheats (zum Testen)
  10. Dungeonböden
  11. Speichern und Laden
  12. Endgegner

Nachdem wir all dies implementiert haben, werden wir ein starkes Roguelike haben und Sie werden Ihre Fähigkeiten zur Spieleentwicklung erheblich verbessern. Eigentlich war es meine Art, meine Fähigkeiten zu verbessern: Code erstellen und Funktionen implementieren. Daher bin ich mir sicher, dass Sie auch damit umgehen können.

Stufe 1 - MapManager-Klasse


Dies ist das erste Skript, das wir erstellen werden, und es wird das Rückgrat unseres Spiels. Es ist einfach, enthält aber den Großteil der wichtigen Informationen für das Spiel.

Erstellen Sie also ein CS-Skript namens MapManager und öffnen Sie es.

Löschen Sie ": MonoBehaviour", da es nicht von ihm erbt und an kein GameObject angehängt wird.

Entfernen Sie die Funktionen Start () und Update ().

Erstellen Sie am Ende der MapManager-Klasse eine neue öffentliche Klasse mit dem Namen Tile.


Die Tile-Klasse enthält alle Informationen einer einzelnen Kachel. Bisher brauchen wir nicht viel, nur x- und y-Positionen sowie ein Spielobjekt an dieser Position der Karte.


Wir haben also grundlegende Kachelinformationen. Lassen Sie uns aus dieser Kachel eine Karte erstellen. Es ist einfach, wir brauchen nur eine zweidimensionale Anordnung von Kachelobjekten. Es klingt kompliziert, aber es gibt nichts Besonderes. Fügen Sie einfach die Variable Tile [,] zur MapManager-Klasse hinzu:


Voila! Wir haben eine Karte!

Ja, es ist leer. Aber das ist eine Karte. Jedes Mal, wenn sich etwas auf der Karte bewegt oder den Status ändert, werden die Informationen auf dieser Karte aktualisiert. Das heißt, wenn ein Spieler beispielsweise versucht, zu einem neuen Plättchen zu wechseln, überprüft die Klasse die Adresse des Zielplättchens auf der Karte, die Anwesenheit des Feindes und seine Durchgängigkeit. Dank dessen müssen wir nicht in jeder Runde Tausende von Kollisionen überprüfen, und wir benötigen keine Kollider für jedes Spielobjekt, was die Arbeit mit dem Spiel erleichtert und vereinfacht.

Der resultierende Code sieht folgendermaßen aus:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MapManager 
{
    public static Tile[,] map; // the 2-dimensional map with the information for all the tiles
}

public class Tile { //Holds all the information for each tile on the map
    public int xPosition; // the position on the x axis
    public int yPosition; // the position on the y axis
    public GameObject baseObject; // the map game object attached to that position: a floor, a wall, etc.
}

Die erste Phase ist abgeschlossen. Fahren wir mit dem Ausfüllen der Karte fort. Jetzt werden wir beginnen, einen Dungeongenerator zu erstellen.

Stufe 2 - ein paar Worte zur Datenstruktur


Bevor Sie beginnen, möchte ich Ihnen die Tipps mitteilen, die sich aufgrund des Feedbacks ergeben haben, das Sie nach der Veröffentlichung des ersten Teils erhalten haben. Wenn Sie eine Datenstruktur erstellen, müssen Sie von Anfang an überlegen, wie Sie den Status des Spiels beibehalten. Andernfalls wird es später viel chaotischer. Der Benutzer von Discord st33d, dem Entwickler von Star Shaped Bagel (Sie können dieses Spiel hier kostenlos spielen ), sagte, dass er das Spiel zuerst erstellt habe und dachte, dass es überhaupt keine Zustände retten würde. Allmählich wurde das Spiel größer und ihr Fan bat um Unterstützung für die gespeicherte Karte. Aufgrund der gewählten Methode zum Erstellen der Datenstruktur war es jedoch sehr schwierig, die Daten zu speichern, sodass er dies nicht konnte.


Wir lernen wirklich aus unseren Fehlern. Trotz der Tatsache, dass ich den Teil zum Speichern / Laden am Ende des Tutorials eingefügt habe, denke ich von Anfang an darüber nach und habe sie nur noch nicht erklärt. In diesem Teil werde ich ein wenig darüber sprechen, aber um unerfahrene Entwickler nicht zu überlasten.

Wir werden solche Dinge als ein Array von Variablen der Tile-Klasse speichern, in der die Karte gespeichert ist. Wir speichern alle diese Daten mit Ausnahme der Variablen der GameObject-Klasse, die sich in der Tile-Klasse befinden. Warum? Nur weil GameObjects nicht mit Unity für gespeicherte Daten serialisiert werden können.

Daher müssen wir die in GameObjects gespeicherten Daten nicht speichern. Alle Daten werden in Klassen wie Tile und später auch Player, Enemy usw. gespeichert. Dann haben wir GameObjects, um die Berechnung von Dingen wie Sichtbarkeit und Bewegung zu vereinfachen und Sprites auf dem Bildschirm zu zeichnen. Daher gibt es innerhalb der Klassen GameObject-Variablen, aber der Wert dieser Variablen wird nicht gespeichert und geladen. Beim Laden wird erzwungen, GameObject erneut aus den gespeicherten Daten (Position, Sprite usw.) zu generieren.

Was müssen wir dann jetzt tun? Fügen Sie der vorhandenen Tile-Klasse einfach zwei Zeilen und eine am oberen Rand des Skripts hinzu. Zuerst fügen wir "using System" hinzu. zum Skripttitel und dann [Serializable] vor der gesamten Klasse und [NonSerialized] direkt vor der GameObject-Variablen. So:



Ich werde Ihnen mehr darüber erzählen, wenn wir zum Teil des Tutorials zum Speichern / Laden kommen. Lassen wir jetzt alles und fahren fort.

Stufe 3 - etwas mehr über die Datenstruktur


Ich habe eine weitere Überprüfung der Datenstruktur erhalten, die ich hier teilen möchte.

Tatsächlich gibt es viele Möglichkeiten, Daten in einem Spiel zu implementieren. Die erste, die ich verwende und die in diesem Tutorial implementiert wird: Alle Kacheldaten befinden sich in der Kachelklasse, und alle werden in einem Array gespeichert. Dieser Ansatz hat viele Vorteile: Es ist einfacher zu lesen, alles, was Sie benötigen, befindet sich an einem Ort, Daten lassen sich leichter bearbeiten und in eine Sicherungsdatei exportieren. Aber aus der Sicht des Gedächtnisses ist es nicht so effektiv. Sie müssen viel Speicher für Variablen reservieren, die im Spiel niemals verwendet werden. Zum Beispiel werden wir später die Enemy GameObject-Variable in die Tile-Klasse aufnehmen, damit wir direkt von der Karte auf das GameObject des Feindes zeigen können, der auf diesem Plättchen steht, um alle Berechnungen im Zusammenhang mit dem Kampf zu vereinfachen. Dies bedeutet jedoch, dass jeder Kachel im Spiel Speicherplatz für die GameObject-Variable zugewiesen wurde.auch wenn sich auf diesem Plättchen kein Feind befindet. Wenn sich auf einer Karte mit 2500 Kacheln 10 Gegner befinden, sind 2490 leere, aber zugewiesene GameObject-Variablen vorhanden. Sie können sehen, wie viel Speicher verschwendet wird.

Eine alternative Methode wäre die Verwendung von Strukturen zum Speichern der Basisdaten von Kacheln (z. B. Position und Typ), und alle anderen Daten würden in Hashmap-s gespeichert, die nur bei Bedarf generiert würden. Dies würde viel Speicher sparen, aber die Amortisation wäre eine etwas kompliziertere Implementierung. Eigentlich wäre es etwas fortgeschrittener als ich es in diesem Tutorial gerne hätte, aber wenn Sie möchten, kann ich in Zukunft einen detaillierteren Beitrag darüber schreiben.

Wenn Sie eine Diskussion zu diesem Thema lesen möchten, können Sie dies auf Reddit tun .

Stufe 4 - Dungeon-Generierungsalgorithmus


Ja, dies ist ein weiterer Abschnitt, in dem ich sprechen werde, und wir werden nichts programmieren. Dies ist jedoch wichtig. Eine sorgfältige Planung der Algorithmen spart uns in Zukunft viel Arbeitszeit.

Es gibt verschiedene Möglichkeiten, einen Dungeongenerator zu erstellen. Diejenige, die wir gemeinsam implementieren werden, ist nicht die beste und nicht die effektivste ... es ist nur ein einfacher, anfänglicher Weg. Es ist sehr einfach, aber die Ergebnisse werden ziemlich gut sein. Das Hauptproblem werden viele Sackgassen sein. Wenn Sie möchten, kann ich später ein weiteres Tutorial über bessere Algorithmen veröffentlichen.

Im Allgemeinen funktioniert der von uns verwendete Algorithmus wie folgt: Angenommen, wir haben eine ganze Karte mit Nullwerten - eine Ebene, die aus einem Stein besteht. Zu Beginn haben wir einen Raum in der Mitte ausgeschnitten. Von diesem Raum aus durchbrechen wir den Korridor in eine Richtung und fügen dann weitere Korridore und Räume hinzu, die immer zufällig von einem vorhandenen Raum oder Korridor ausgehen, bis wir die maximale Anzahl von Korridoren / Räumen erreichen, die zu Beginn angegeben wurden. Oder bis der Algorithmus einen neuen Ort zum Hinzufügen eines neuen Raums / Korridors finden kann, je nachdem, was zuerst eintritt. Und so bekommen wir einen Kerker.

Lassen Sie uns dies Schritt für Schritt algorithmischer beschreiben. Der Einfachheit halber werde ich jedes Detail der Karte (Korridor oder Raum) als Element bezeichnen, damit ich nicht jedes Mal „Raum / Korridor“ sagen muss.

  1. Schneiden Sie den Raum in der Mitte der Karte aus
  2. Wähle zufällig eine der Wände aus
  3. Wir durchbrechen den Korridor in dieser Wand
  4. Wählen Sie zufällig eines der vorhandenen Elemente aus.
  5. Wählen Sie zufällig eine der Wände dieses Elements aus
  6. Wenn das zuletzt ausgewählte Element ein Raum ist, generieren wir einen Korridor. Wenn der Korridor, dann wählen Sie zufällig, ob das nächste Element ein Raum oder ein anderer Korridor sein wird
  7. Überprüfen Sie, ob in der ausgewählten Richtung genügend Platz vorhanden ist, um das gewünschte Element zu erstellen
  8. Wenn es einen Ort gibt, erstellen Sie ein Element. Wenn nicht, kehren Sie zu Schritt 4 zurück
  9. Wiederholen Sie ab Schritt 4

Das ist alles. Wir werden eine einfache Karte des Verlieses bekommen, in dem es nur Räume und Korridore gibt, ohne Türen und spezielle Elemente, aber dies wird unser Anfang sein. Später werden wir es mit Truhen, Feinden und Fallen füllen. Und Sie können es sogar anpassen: Wir lernen, wie Sie interessante Elemente hinzufügen, die Sie benötigen.

Stufe 5 - Raum ausschneiden


Fahren Sie abschließend mit der Codierung fort! Lassen Sie uns unser erstes Zimmer schneiden.

Erstellen Sie zunächst ein neues Skript und nennen Sie es DungeonGenerator. Es wird von Monobehaviour geerbt, sodass Sie es später an GameObject anhängen müssen. Dann müssen wir mehrere öffentliche Variablen in der Klasse deklarieren, damit wir die Parameter des Dungeons vom Inspektor aus festlegen können. Diese Variablen sind die Breite und Höhe der Karte, die minimale und maximale Höhe und Breite der Räume, die maximale Länge der Korridore und die Anzahl der Elemente, die sich auf der Karte befinden sollen.


Als nächstes müssen wir den Dungeongenerator initialisieren. Wir tun dies, um die Variablen zu initialisieren, die von der Generierung ausgefüllt werden. Im Moment ist dies nur eine Karte. Und und löschen Sie auch die Funktionen Start () und Update (), die Unity für das neue Skript generiert. Wir werden sie nicht benötigen.



Hier haben wir die Map-Variable der MapManager-Klasse (die wir im vorherigen Schritt erstellt haben) initialisiert und dabei die Breite und Höhe der Map übergeben, die durch die obigen Variablen als Parameter der beiden Dimensionen des Arrays definiert wurden. Dank dessen haben wir eine Karte mit horizontaler x-Größe (Breite) und vertikaler y-Größe (Höhe) und können auf jede Zelle in der Karte zugreifen, indem Sie MapManager.map [x, y] eingeben. Dies ist sehr nützlich, wenn Sie die Position von Objekten ändern.

Jetzt erstellen wir eine Funktion zum Rendern des ersten Raums. Wir werden es FirstRoom () nennen. Wir haben InitializeDungeon () zu einer öffentlichen Funktion gemacht, da es von einem anderen Skript gestartet wird (Game Manager, den wir bald erstellen werden; es wird die Verwaltung des gesamten Spielstartprozesses zentralisieren). Wir benötigen keine externen Skripte, um auf FirstRoom () zugreifen zu können, daher machen wir es nicht öffentlich.

Um fortzufahren, erstellen wir drei neue Klassen im MapManager-Skript, damit Sie einen Raum erstellen können. Dies sind die Klassen Feature, Wall und Position. Die Positionsklasse enthält die x- und y-Positionen, damit wir verfolgen können, wo sich alles befindet. Die Wand hat eine Liste von Positionen, die Richtung, in die sie relativ zur Mitte des Raums (Norden, Süden, Osten oder Westen) „schaut“, die Länge und das Vorhandensein eines neuen Elements, das daraus erstellt wurde. Das Element enthält eine Liste aller Positionen, aus denen es besteht, den Typ des Elements (Raum oder Korridor), ein Array von Wandvariablen sowie seine Breite und Höhe.



Kommen wir nun zur FirstRoom () -Funktion. Kehren wir zum DungeonGenerator-Skript zurück und erstellen eine Funktion direkt unter InitializeDungeon. Sie muss keine Parameter erhalten, daher lassen wir es einfach (). Als nächstes müssen wir innerhalb der Funktion zuerst die Raumvariable und ihre Liste der Positionsvariablen erstellen und initialisieren. Wir machen es so:


Stellen wir nun die Größe des Raums ein. Es wird ein zufälliger Wert zwischen der am Anfang des Skripts angegebenen minimalen und maximalen Höhe und Breite angezeigt. Sie sind zwar leer, da wir im Inspektor keinen Wert für sie festgelegt haben, aber keine Sorge, wir werden es bald tun. Wir setzen zufällige Werte wie folgt:


Als nächstes müssen wir angeben, wo sich der Startpunkt des Raums befindet, dh wo sich im Kartenraster der Punkt von Raum 0.0 befindet. Wir möchten, dass es in der Mitte der Karte beginnt (halbe Breite und halbe Höhe), aber vielleicht nicht genau in der Mitte. Es könnte sich lohnen, einen kleinen Randomizer hinzuzufügen, damit er sich leicht nach links und unten bewegt. Daher setzen wir xStartingPoint als halbe Breite der Karte und yStartingPoint als halbe Höhe der Karte und nehmen dann die gerade angegebene Raumbreite und Raumhöhe. Wir erhalten einen zufälligen Wert von 0 bis zu dieser Breite / Höhe und subtrahieren ihn von den anfänglichen x und y. So:



Als nächstes werden wir in derselben Funktion Wände hinzufügen. Wir müssen das Array von Wänden initialisieren, die sich in der neu erstellten Raumvariablen befinden, und dann jede Wandvariable in diesem Array initialisieren. Initialisieren Sie dann jede Liste von Positionen, setzen Sie die Länge der Wand auf 0 und geben Sie die Richtung ein, in die jede Wand „aussehen“ soll.

Nachdem das Array initialisiert wurde, durchlaufen wir jedes Element des Arrays in der for () - Schleife, initialisieren die Variablen jeder Wand und verwenden dann den Schalter, der die Richtung jeder Wand benennt. Es wird willkürlich gewählt, wir müssen uns nur daran erinnern, was sie bedeuten werden.


Jetzt werden wir zwei verschachtelte for-Schleifen unmittelbar nach dem Platzieren der Wände ausführen. In der äußeren Schleife gehen wir um alle y-Werte im Raum und in der verschachtelten Schleife um alle x-Werte. Auf diese Weise überprüfen wir jede Zelle x in Zeile y, damit wir sie implementieren können.


Dann müssen Sie zunächst den tatsächlichen Wert der Zellenposition auf dem Kartenmaßstab anhand der Raumposition ermitteln. Das ist ziemlich einfach: Wir haben die Startpunkte x und y. Sie befinden sich auf Position 0,0 im Raster des Raums. Wenn wir dann den realen Wert von x, y von einem lokalen x, y erhalten müssen, addieren wir das lokale x und y mit den Anfangspositionen x und y. Anschließend speichern wir diese realen x, y-Werte in der Positionsvariablen (aus einer zuvor erstellten Klasse) und fügen sie dann der Liste <> der Raumpositionen hinzu.


Der nächste Schritt besteht darin, diese Informationen zur Karte hinzuzufügen. Denken Sie vor dem Ändern der Werte daran, die Kachelvariable zu initialisieren.


Jetzt werden wir die Tile-Klasse ändern. Gehen wir zum MapManager-Skript und fügen der Definition der Tile-Klasse eine Zeile hinzu: "public string type;". Auf diese Weise können wir eine Kachelklasse hinzufügen, indem wir deklarieren, dass die Kachel in x, y eine Wand, ein Boden oder etwas anderes ist. Als nächstes kehren wir zu dem Zyklus zurück, in dem wir die Arbeit ausgeführt haben, und fügen ein großes if-else-Konstrukt hinzu, mit dem wir nicht nur jede Wand, ihre Länge und alle Positionen in dieser Wand bestimmen, sondern auch auf der globalen Karte definieren können, was eine bestimmte Kachel ist - eine Wand oder Geschlecht.


Und wir haben schon etwas getan. Wenn die Variable y (Steuerung der Variablen in der äußeren Schleife) 0 ist, gehört die Kachel zur untersten Zellenreihe im Raum, dh zur Südwand. Wenn x (Steuerung der Variablen der inneren Schleife) 0 ist, gehört die Kachel zur äußersten linken Spalte der Zellen, dh zur westlichen Wand. Und wenn es sich ganz oben befindet, gehört es zur Nordwand und ganz rechts zur Ostwand. Wir subtrahieren 1 von den Variablen roomWidth und roomHeight, da diese Werte ab 1 berechnet wurden und die x- und y-Variablen des Zyklus ab 0 beginnen. Daher muss diese Differenz berücksichtigt werden. Und alle Zellen, die die Bedingungen nicht erfüllen, sind keine Wände, das heißt, sie sind der Boden.


Großartig, wir sind fast fertig mit dem ersten Raum. Es ist fast fertig, wir müssen nur die letzten Werte in die von uns erstellte Variable Feature einfügen. Wir verlassen die Schleife und beenden die Funktion wie folgt:


Fein! Wir haben ein Zimmer!

Aber wie verstehen wir, dass alles funktioniert? Müssen testen. Aber wie teste ich? Wir können Zeit damit verbringen und Assets hinzufügen, aber es wird Zeitverschwendung sein und uns zu sehr davon abhalten, den Algorithmus zu vervollständigen. Hmm, aber das kann mit ASCII gemacht werden! Ja, tolle Idee! ASCII ist eine einfache und kostengünstige Möglichkeit, eine Karte zu zeichnen, damit sie getestet werden kann. Wenn Sie möchten, können Sie den Teil auch mit Sprites und visuellen Effekten überspringen, die wir später untersuchen werden, und Ihr gesamtes Spiel in ASCII erstellen. Mal sehen, wie das gemacht wird.

Stufe 6 - Zeichnen des ersten Raums


Bei der Implementierung einer ASCII-Karte müssen Sie zunächst überlegen, welche Schriftart Sie auswählen müssen. Der Hauptfaktor bei der Auswahl einer Schriftart für ASCII ist, ob sie proportional (variable Breite) oder monospaced (feste Breite) ist. Wir benötigen eine Monospace-Schriftart, damit die Karten nach Bedarf aussehen (siehe Beispiel unten). Standardmäßig verwendet jedes neue Unity-Projekt die Arial-Schriftart und ist nicht monospaced, daher müssen wir eine andere finden. Windows 10 verfügt normalerweise über monospaced Schriftarten wie Courier New, Consolas und Lucida Console. Wählen Sie eine dieser drei Optionen aus oder laden Sie eine andere an der gewünschten Stelle herunter und legen Sie sie im Ordner "Schriftarten" im Ordner "Assets" des Projekts ab.


Bereiten wir die Szene für die ASCII-Ausgabe vor. Machen Sie zunächst die Hintergrundfarbe der Hauptkamera der Szene schwarz. Dann fügen wir der Szene das Canvas-Objekt hinzu und fügen ihm das Text-Objekt hinzu. Setzen Sie die Transformation des Textrechtecks ​​auf die mittlere Mitte und auf die Position 0,0,0. Stellen Sie das Textobjekt so ein, dass es die von Ihnen ausgewählte Schriftart und die weiße Farbe sowie den horizontalen und vertikalen Überlauf (horizontaler / vertikaler Überlauf) verwendet, wählen Sie Überlauf und zentrieren Sie die vertikale und horizontale Ausrichtung. Benennen Sie dann das Textobjekt in "ASCIITest" oder ähnliches um.

Nun zurück zum Code. Erstellen Sie im DungeonGenerator-Skript eine neue Funktion namens DrawMap. Wir möchten, dass sie einen Parameter erhält, der angibt, welche Karte generiert werden soll - ASCII oder Sprite. Erstellen Sie also einen booleschen Parameter und nennen Sie ihn isASCII.


Dann werden wir prüfen, ob die gerenderte Karte ASCII ist. Wenn ja (im Moment betrachten wir nur diesen Fall), suchen wir nach einem Textobjekt in der Szene, übergeben den ihm als Parameter angegebenen Namen und erhalten dessen Textkomponente. Aber zuerst müssen wir Unity mitteilen, dass wir mit der Benutzeroberfläche arbeiten möchten. Fügen Sie die Zeile mit UnityEngine.UI zum Skript-Header hinzu:


Fein. Jetzt können wir die Textkomponente des Objekts erhalten. Die Karte ist eine große Linie, die als Text auf dem Bildschirm angezeigt wird. Deshalb ist es so einfach einzurichten. Erstellen wir also einen String und initialisieren ihn mit dem Wert "".


Fein. Jedes Mal, wenn DrawMap aufgerufen wird, müssen wir mitteilen, ob es sich bei der Karte um eine ASCII-Karte handelt. Wenn dies so ist (und wir werden es immer so haben, wir werden später damit arbeiten), durchsucht die Funktion die Szenenhierarchie auf der Suche nach einem Spielobjekt namens "ASCIITest". Wenn dies der Fall ist, erhält es seine Textkomponente und speichert sie in der Bildschirmvariablen, in die wir dann die Karte einfach schreiben können. Anschließend wird eine Zeichenfolge erstellt, deren Wert anfangs leer ist. Wir werden diese Zeile mit unserer Karte füllen, die mit Symbolen markiert ist.

Normalerweise gehen wir in einer Schleife um die Karte herum, beginnend bei 0 und bis zum Ende ihrer Länge. Um die Zeile zu füllen, beginnen wir mit der ersten Textzeile, dh der obersten Zeile. Daher müssen wir uns auf der y-Achse in einer Schleife in die entgegengesetzte Richtung bewegen, vom Ende bis zum Anfang des Arrays. Aber die x-Achse des Arrays verläuft genau wie der Text von links nach rechts, daher passt dies zu uns.


In diesem Zyklus überprüfen wir jede Zelle der Karte, um herauszufinden, was darin enthalten ist. Bisher haben wir die Zellen nur als neue Kachel () initialisiert, die wir für den Raum ausgeschnitten haben, sodass alle anderen beim Versuch, darauf zuzugreifen, einen Fehler zurückgeben. Also müssen wir zuerst prüfen , ob es irgendetwas in dieser Zelle ist, und wir tun dies , indem die Zelle für null zu überprüfen. Wenn es nicht null ist, arbeiten wir weiter, aber wenn es null ist, ist nichts drin, sodass wir der Karte leeren Raum hinzufügen können.


Daher überprüfen wir für jede nicht leere Zelle ihren Typ und fügen dann das entsprechende Symbol hinzu. Wir möchten, dass die Wände mit dem Symbol "#" und die Böden mit dem Symbol "." Angezeigt werden. Und während wir nur diese beiden Typen haben. Wenn wir später den Spieler, die Monster und die Fallen hinzufügen, wird alles etwas komplizierter.


Außerdem müssen Zeilenumbrüche ausgeführt werden, wenn das Ende der Array-Zeile erreicht ist, damit sich Zellen mit derselben x-Position direkt untereinander befinden. Wir werden bei jeder Iteration der Schleife prüfen, ob die Zelle die letzte in der Zeile ist, und dann einen Zeilenumbruch mit dem Sonderzeichen "\ n" hinzufügen.


Das ist alles. Dann verlassen wir die Schleife, damit wir diese Zeile nach Abschluss zum Textobjekt in der Szene hinzufügen können.



Herzliche Glückwünsche! Sie haben das Skript abgeschlossen, mit dem der Raum erstellt und auf dem Bildschirm angezeigt wird. Jetzt müssen wir nur noch diese Zeilen in die Tat umsetzen. Wir verwenden Start () nicht im DungeonGenerator-Skript, da wir ein separates Skript haben möchten, um alles zu steuern, was zu Beginn des Spiels ausgeführt wird, einschließlich der Erstellung der Karte, aber auch der Einrichtung des Spielers, der Feinde usw. Daher enthält dieses andere Skript die Funktion Start () und ruft bei Bedarf die Funktionen unseres Skripts auf. Das DungeonGenerator-Skript verfügt über eine Initialize-Funktion, die öffentlich ist, und FirstRoom und DrawMap sind nicht öffentlich. Initialize initialisiert einfach die Variablen, um den Dungeon-Generierungsprozess zu konfigurieren. Daher benötigen wir eine weitere Funktion, die den Generierungsprozess aufruft. Diese muss öffentlich sein, damit sie aus anderen Skripten aufgerufen werden kann.Im Moment ruft sie nur die Funktion FirstRoom () und dann die Funktion DrawMap () auf und übergibt ihr einen wahren Wert, damit sie eine ASCII-Karte zeichnet. Oh, oder nicht, es ist sogar noch besser - erstellen wir eine öffentliche Variable isASCII, die in den Inspektor aufgenommen werden kann, und übergeben diese Variable einfach als Parameter an die Funktion. Fein.


Nun erstellen wir ein GameManager-Skript. Es wird genau das Skript sein, das alle übergeordneten Elemente des Spiels steuert, z. B. das Erstellen einer Karte und den Verlauf der Züge. Entfernen wir die Funktion Update (), fügen eine Variable vom Typ DungeonGenerator mit dem Namen dungeonGenerator hinzu und erstellen eine Instanz dieser Variablen in der Funktion Start ().


Danach rufen wir einfach die Funktionen InitializeDungeon () und GenerateDungeon () vom dungeonGenerator in dieser Reihenfolge auf . Dies ist wichtig - zuerst müssen Sie die Variablen initialisieren und erst danach beginnen Sie mit dem Aufbau auf ihrer Basis.


In diesem Teil ist der Code abgeschlossen. Wir müssen ein leeres Spielobjekt im Hierarchiefenster erstellen, es in GameManager umbenennen und die Skripte GameManager und DungeonGenerator daran anhängen. Stellen Sie dann die Werte des Dungeon-Generators im Inspektor ein. Sie können verschiedene Schemata für den Generator ausprobieren, und ich habe mich dazu entschlossen:


Klicken Sie jetzt einfach auf "Spielen" und beobachten Sie die Magie! Sie sollten etwas Ähnliches auf dem Spielbildschirm sehen:


Herzlichen Glückwunsch, wir haben jetzt ein Zimmer!

Ich wollte, dass wir den Charakter des Spielers dort platzieren und ihn bewegen, aber der Posten war schon ziemlich lang. Daher können wir im nächsten Teil direkt mit der Implementierung des restlichen Dungeon-Algorithmus fortfahren oder den Spieler darin platzieren und ihm das Bewegen beibringen. Stimmen Sie in den Kommentaren zum Originalartikel ab, was Ihnen am besten gefällt.

MapManager.cs:

using System.Collections;
using System; // So the script can use the serialization commands
using System.Collections.Generic;
using UnityEngine;

public class MapManager {
    public static Tile[,] map; // the 2-dimensional map with the information for all the tiles
}

[Serializable] // Makes the class serializable so it can be saved out to a file
public class Tile { // Holds all the information for each tile on the map
    public int xPosition; // the position on the x axis
    public int yPosition; // the position on the y axis
    [NonSerialized]
    public GameObject baseObject; // the map game object attached to that position: a floor, a wall, etc.
    public string type; // The type of the tile, if it is wall, floor, etc
}

[Serializable]
public class Position { //A class that saves the position of any cell
    public int x;
    public int y;
}

[Serializable]
public class Wall { // A class for saving the wall information, for the dungeon generation algorithm
    public List<Position> positions;
    public string direction;
    public int length;
    public bool hasFeature = false;
}

[Serializable]
public class Feature { // A class for saving the feature (corridor or room) information, for the dungeon generation algorithm
    public List<Position> positions;
    public Wall[] walls;
    public string type;
    public int width;
    public int height;
}

DungeonGenerator.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class DungeonGenerator : MonoBehaviour
{
    public int mapWidth;
    public int mapHeight;

    public int widthMinRoom;
    public int widthMaxRoom;
    public int heightMinRoom;
    public int heightMaxRoom;

    public int maxCorridorLength;
    public int maxFeatures;

    public bool isASCII;

    public void InitializeDungeon() {
        MapManager.map = new Tile[mapWidth, mapHeight];
    }

    public void GenerateDungeon() {
        FirstRoom();
        DrawMap(isASCII);
    }

    void FirstRoom() {
        Feature room = new Feature();
        room.positions = new List<Position>();

        int roomWidth = Random.Range(widthMinRoom, widthMaxRoom);
        int roomHeight = Random.Range(heightMinRoom, heightMaxRoom);

        int xStartingPoint = mapWidth / 2;
        int yStartingPoint = mapHeight / 2;

        xStartingPoint -= Random.Range(0, roomWidth);
        yStartingPoint -= Random.Range(0, roomHeight);

        room.walls = new Wall[4];

        for (int i = 0; i < room.walls.Length; i++) {
            room.walls[i] = new Wall();
            room.walls[i].positions = new List<Position>();
            room.walls[i].length = 0;

            switch (i) {
                case 0:
                    room.walls[i].direction = "South";
                    break;
                case 1:
                    room.walls[i].direction = "North";
                    break;
                case 2:
                    room.walls[i].direction = "West";
                    break;
                case 3:
                    room.walls[i].direction = "East";
                    break;
            }
        }

        for (int y = 0; y < roomHeight; y++) {
            for (int x = 0; x < roomWidth; x++) {
                Position position = new Position();
                position.x = xStartingPoint + x;
                position.y = yStartingPoint + y;

                room.positions.Add(position);

                MapManager.map[position.x, position.y] = new Tile();
                MapManager.map[position.x, position.y].xPosition = position.x;
                MapManager.map[position.x, position.y].yPosition = position.y;

                if (y == 0) {
                    room.walls[0].positions.Add(position);
                    room.walls[0].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else if (y == (roomHeight - 1)) {
                    room.walls[1].positions.Add(position);
                    room.walls[1].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else if (x == 0) {
                    room.walls[2].positions.Add(position);
                    room.walls[2].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else if (x == (roomWidth - 1)) {
                    room.walls[3].positions.Add(position);
                    room.walls[3].length++;
                    MapManager.map[position.x, position.y].type = "Wall";
                }
                else {
                    MapManager.map[position.x, position.y].type = "Floor";
                }
            }
        }

        room.width = roomWidth;
        room.height = roomHeight;
        room.type = "Room";
    }

    void DrawMap(bool isASCII) {
        if (isASCII) {
            Text screen = GameObject.Find("ASCIITest").GetComponent<Text>();

            string asciiMap = "";

            for (int y = (mapHeight - 1); y >= 0; y--) {
                for (int x = 0; x < mapWidth; x++) {
                    if (MapManager.map[x,y] != null) {
                        switch (MapManager.map[x, y].type) {
                            case "Wall":
                                asciiMap += "#";
                                break;
                            case "Floor":
                                asciiMap += ".";
                                break;
                        }
                    } else {
                        asciiMap += " ";
                    }

                    if (x == (mapWidth - 1)) {
                        asciiMap += "\n";
                    }
                }
            }

            screen.text = asciiMap;
        }
    }
}

GameManager.cs:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    DungeonGenerator dungeonGenerator;
    
    void Start() {
        dungeonGenerator = GetComponent<DungeonGenerator>();

        dungeonGenerator.InitializeDungeon();
        dungeonGenerator.GenerateDungeon();
    }
}

All Articles