Inhalt
- Inspiration
- Spielidee
- Verwendete Tools
- Spielwelt
- Blockinteraktionen
- Dynamische Elemente
- Speichern und Laden
- Gameplay
- Ausblick und Ideen
- Fazit
Inspiration
Wenn es um zelluläre Automaten geht ist Conways Game of Life sicherlich eines der meistverwendeten und beliebtesten Beispiele. Das Suchen komplexer Verhaltensweisen und interessanter Muster und Experimentieren mit verschiedenen Anfangszuständen der Spielwelt birgt dabei für viele eine gewisse Faszination. Das zeigen zum Beispiel Seiten wie diese, auf denen Game-Of-Life-Begeisterte weltweit interessante Entdeckungen dokumentiert haben. Auch wir haben schon einige Zeit mit diesem so simplen und doch spannenden Spiel verbracht.
Beispiel einer Glider-Konfiguration (Quelle: Wikipedia).
Das Game of Life ist jedoch auf nur zwei Dimensionen beschränkt. Aber wie würde das Ganze denn in 3D aussehen? Diese Frage gab uns die erste Idee für das Projekt, mit dem wir uns das letzte halbe Jahr beschäftigt haben: Wir wollten ein kleines Spiel entwickeln, bei dem der Spieler eine Welt aus Blöcken aufbauen kann, die sich dann anschließend durch das Anwenden vorgegebener Regeln stetig verändert. Ausgehend von dieser Grundidee haben wir im Laufe der Entwicklung noch weitere Ideen und Gameplay-Elemente eingeführt, auf die wir nun im Folgenden näher eingehen möchten.
Spielidee
Das Genre unseres Spieles sollte ein so genantes Sandbox-Spiel sein. In solche art von Spielen gibt es keine festen Ziele zu erreichen, der Spielspaß entsteht dadurch mit der Welt zu interagieren, sie selbst aufzubauen und zuzuschauen wie sie sich von alleine weiterentwickelt ähnlich wie bei Conways Game of Life. Der Spieler selbst soll hier eine Art “Gott” verkörpern der in der Lage ist verschiedene Blockarten zu platzieren. Im Gegensatz zu Conways Game of Life sind die Zustände der Blöcke nicht auf “lebend” und “tot” beschränkt, sondern stellen Elemente wie Wasser und Stein dar. Zusätzlich hat der Spieler die Möglichkeit die Tageszeit und das Wetter zu beeinflussen. Das ermöglicht ihn aus einer leeren Welt leben einzuhauchen. Zum Beispiel wird aus Steinblöcken neben Wasserblöcken Sand. Stein wird zu Erde wenn es regnet und die Erde wird zu Gras wenn die Sonne scheint. Auf dem Grass können nun Pflanzen wachsen und höher entwickeltes Leben, wie Hasen, entstehen.
Verwendete Tools
Unity Engine
Der Unity Editor.
Da wir bereits vor unserem Praktikum Erfahrung im Umgang mit diesem Tool gesammelt hatten, haben wir zur Verwirklichung unserer Projektidee die Unity-Engine auserkoren. Diese wird von zahlreichen großen und kleinen Entwicklerteams weltweit verwendet und ist heute eine der größten und verbreitetsten frei erhältlichen 3D- und 2D-Engines. Sie liefert ein umfangreiches Tool-Set zur Entwicklung interaktiver Software im mitgelieferten Editor. Der Spielablauf und sämtliche Funktionalität wird dabei durch eine Mischung aus Unity-eigener Funktionen und eigens erstellten C#-Skripten verwirklicht. Für letztere beinhaltet Unity eine Umfangreiche Sammlung hilfreicher Funktionen und Pakete.
MagicaVoxel
MagicaVoxel mit einem unserer Modelle.
Für die Erstellung unserer 3D-Modelle haben wir das free-to-use Tool MagicaVoxel verwendet. Damit lassen sich Modelle aus einzelnen Würfeln (Voxeln) zusammensetzen, was sich gut in unseren geplanten Stil einfügt. MagicaVoxel bietet außerdem viele nützliche Funktionen wie Mirroring oder Copy/Paste und erlaubt darüber hinaus auch den Export von Modellen in gängige Standard-Formate.
Spielwelt
Aussehen
Wir haben uns für dieses Projekt für einen simpel gehaltenen Grafikstil entschieden. In unserem Fall heißt das, dass unsere Blöcke ohne eigene Texturen auskommen, sondern nur anhand ihrer Farbe und des verwendeten Materials erkennbar sein sollten. Auch unsere 3D-Modelle sind einfach und “blockig” gehalten, um in Kombination mit Stop-Motion-artigen Animationen und einigen Post-Processing-Effekten wie Chromatic Aberration und Bloom das Gefühl eines interaktiven Miniatur-Dioramas zu erzeugen.
Beispieldesign für die Welt, die Skybox, das Abbauen von Blöcken, der Modelle der Pflanzen und Tiere und ihre Animationen.
Designgrundlagen
Eigenschaften
Die Spielwelt besitzt eine feste Größe und somit auch unveränderliche Block-Positionen. Der Zugriff auf Block-Daten kann durch eine dreidimensionale Position oder eine eindeutige BlockId
erfolgen. Der Zustand der Welt wird durch eine Liste von TypeIds
definiert, wobei jeder Index der jeweiligen BlockId
entspricht. Sämtliche schreibende und lesende Zugriffe auf die Welt erfolgen bei uns durch ein WorldManager
Objekt. Da das Ändern des Weltzustands häufig einige teure Operationen nach sich zieht, werden diese in der Regel erst am Ende eines Frames zusammengefasst ausgeführt. Bei Bedarf ist es aber auch möglich, eine sofortige Umsetzung der geplanten Änderung zu erzwingen. Der WorldManager
bietet außerdem Funktionen, mit deren Hilfe auf benachbarte Blöcke zugegriffen werden kann, was wir an einigen Stellen in unserem Projekt benötigen.
Events
Um unseren Code möglichst modular zu halten, verwenden wir für die Kommunikation zwischen den Haupt-Modulen unseres Spiels UnityEvent
s. Diese implementieren dem Observer Pattern folgend ein simples Event-System, bei dem Methoden der Beobachter eines Objekts automatisiert nach dem Auslösen eines bestimmten Events ausgeführt werden können. Um andere Komponenten über Zustandsänderungen der Welt zu informieren verwenden wir ein worldChangedEvent
, welches nach jeder Änderung ausgelöst wird. Außerdem steht ein worldInitializedEvent
bereit, um über ein Neu-Laden der Welt zu benachrichtigen.
Block-Typen
Die Basis unserer Spielwelt bilden verschiedene Block-Typen. Jeder Typ ist dabei durch ein simples struct
definiert, welches die grundlegenden Infos dazu beinhaltet:
public struct BlockType
{
public string name;
public bool isTransparent;
public bool hasCollision;
public bool castsShadow;
public bool renderBlock;
public Material material;
public GameObject model;
public BlockUpdateRule[] rules;
...
}
Die Typen werden für den Zugriff zur Laufzeit und die Vergabe der int
-wertigen eindeutigen TypeIds
in einem BlockTypeDatabase
Asset abgespeichert. Die TypeIds
stellen dabei die primäre Repräsentation der verschiedenen Typen dar, wobei die ID -1
für den 'Air'
Type reserviert ist. Dieser stellt den Standard-Typ dar, mit dem die Welt initialisiert wird. Die Vergabe der IDs erfolgt zu beginn des Spiels automatisch durch die BlockTypeDatabase
. Die Typen selbst werden ebenfalls beim Starten des Spiels aus BlockTypeAsset
s erzeugt, welche zuvor bequem im Unity-Editor erstellt und bearbeitet werden können.
Ein Beispiel eines BlockTypeAsset
s, in diesem Fall für Grasblöcke.
Weltgeometrie
Meshing und Rendering
Um den Zustand der Welt auch visuell repräsentieren zu können, mussten wir natürlich auch eine Möglichkeit implementieren, aus den gespeicherten Daten Geometrie zu generieren, die dann im nächsten Schritt von Unity gerendert werden kann. Diesen Schritt übernimmt der GeometryManager
, welcher auf Änderungen der Welt reagiert und aus dem Zustand der Welt geeignete Meshes baut.
High-Level Ablauf des Meshing Vorgangs.
Ursprünglich war unser Plan, ein einziges großes Mesh für die gesamte Welt-Geometrie zu erzeugen. Auf diese Weise hätten wir die Zahl an Draw-Calls möglichst gering halten und damit die Render-Performance verbessern können. Das war jedoch nicht umzusetzen, da Unity pro Mesh leider nur die Verwendung eines einzelnen Materials unterstützt und wir aber für unterschiedliche Blöcke auch gerne verschiedene Materialien verwenden wollten. Aus diesem Grund erzeugt der GeometryManager
stattdessen für jeden Block-Typen ein eigenes Mesh, dem das jeweilige Material zugewiesen wird. Wir fügen dabei jeweils nur die Seiten eines Blockes zu den Meshes hinzu, die auch wirklich sichtbar sind. Das heißt, Blöcke, die keinen transparenten oder nicht gerenderten Block als Nachbarn haben, werden nicht berücksichtigt. Dadurch reduziert sich die Zahl an gerenderten Blöcken im Average-Case enorm, was sich natürlich positiv auf die Render-Performance auswirkt.
Beispielhafter Vergleich des naiven Meshings und einem Ansatz, bei welchem nur die sichtbaren Block-Seiten berücksichtigt werden. Die Anzahl an Dreiecken im Welt-Mesh reduziert sich hier auf etwa gerade einmal 10% der ursprünglichen Zahl!
Es gibt selbstverständlich noch weiter optimierte Algorithmen. Ein Beispiel hierfür wäre etwa Greedy Meshing. Für unsere Zwecke, und vor allem Welt-Größen, reicht diese einfache Optimierung unserer Meinung nach aber erst einmal aus. Für die Interaktion mit Unitys Physik-System lassen wir für einige Block-Typen nach der Generierung der Meshes außerdem noch einen Collider
erzeugen. Dieser wird zum Beispiel für Raycasting benötigt. Die Berechnung davon übernimmt Unity selbst.
Optimierung
Trotz der Einschränkungen durch die Verwendung mehrerer Meshes und unseres nicht perfekten Meshing-Algorithmus ist die Render-Performance unseres Spiels zufriedenstellend. Ein deutlich größeres Problem stellte zu Beginn jedoch die sehr große Zahl an Array-Zugriffen dar, welche sich aus dem Komplett-Scan der Welt und der benötigten Zugriffe auf alle Nachbarn eines jeden Blocks beim Generieren der Spielwelt ergibt. Dadurch steigt die Zeit, welche für das Erzeugen unserer Meshes benötigt wird, stark mit der Weltgröße an. In einer von uns erstellten Benchmark-Welt ergaben sich mit unserer ersten Variante des Algorithmus etwa folgende Zeiten:
Weltgröße | 32x32x32 Blöcke | 64x64x64 Blöcke | 128x128x128 Blöcke |
---|---|---|---|
Zeit | 7.0 ms | 23.0 ms | 95.0 ms |
Framerate | 142 fps | 43 fps | 10 fps |
Wie man sieht eignet sich der naive Ansatz die Welt bei Änderungen komplett neu zu generieren nur für sehr kleine Welten. Ein erster Ansatz zur Verbesserung der Performance wäre die Auslagerung des Meshings vom Main-Thread auf einen weiteren Prozess. Dadurch könnte das Blocken des Main-Threads durch den Meshing-Prozess verhindert und damit eine stabilere Framerate erzielt werden. Allerdings würde dies dazu führen, dass Welt-Änderungen je nach Weltgröße erst mit einer deutlichen und den Spielfluss störenden Verzögerung sichtbar würden. Parallelisieren allein hilft hier also nur sehr begrenzt. Ein weiterer und in unseren Augen effektiverer Ansatz zur Optimierung ist es deshalb, die Welt-Geometrie zusätzlich in kleinere logische Teil-Stücke, sogenannte Chunks
, aufzuteilen, für welche die Generierung in vertretbarer Zeit abgeschlossen werden kann. Wir haben uns für eine Chunk
-Größe von 16x16x16
Blöcken entschieden, da diese in unseren Tests die beste Balance zwischen Render- und Meshing-Performance bot. Mit diesen Änderungen konnten wir in unserem Test-Case ein ansehnliches Performance-Plus erzielen:
Weltgröße | 32x32x32 Blöcke | 64x64x64 Blöcke | 128x128x128 Blöcke |
---|---|---|---|
Zeit | 0.52 ms | 0.63 ms | 3.80 ms |
Framerate | 1923 fps | 1587 fps | 263 fps |
Speedup | ~13x | ~36x | ~25x |
Das entspricht einem durchschnittlich 25-fachem Speedup verglichen mit der vorherigen Implementierung was eine Reduktion der Berechnungszeit um knapp 97% bedeutet! Natürlich ist unsere Benchmark-Szene bestehend aus einer vorgefertigten Benchmark-Welt und bewölktem Wetter ein Paradebeispiel für die Vorteile von Render-Chunks. Das heißt je nach Situation wird die reale Verbesserung wohl deutlich geringer ausfallen, da diese vor allem von der Anzahl an neu zu berechnenden Chunk-Meshes abhängt. Dennoch macht diese Verbesserung große Welten technisch überhaupt erst denkbar!
Weltgenerierung
2D Perlin Noise. Durch zufälliges Wählen der Startposition und der Scale (Größe des Zooms), sowie der Eingabe der Höhe des Meeresspiegels und der maximalen Höhe der Berge können quasi unendlich viele Welten generiert werde (Quelle: Wikipedia).
Für das zufällige Generieren der Welt benutzen wir Perlin-Noise. Diese erzeugt einen Wert zwischen 0 und 1 für eine zweidimensionale Positionsangabe. Eine bedeutende Eigenheit der so entstehenden Textur aus Grauwerten ist, dass der Übergang zwischen benachbarten Koordinaten fließend ist. Das heißt, nahe gelegene Positionen auf der Textur haben auch ähnliche Grauwerte.
Eine unserer mit Perlin Noise generierten Welten aus der Vogelperspektive.
Wir nutzen diese Eigenschaft, um für jeden Punkt auf der (x,z)-Ebene in unserer Welt den zugehörigen y-Wert zu bestimmen. Wir verschieben die Noise-Textur dabei zusätzlich um einen zufällig gewählten Offset-Wert, um bei jedem Laden eine andere Welt zu generieren. Der berechnete y-Wert wird anschließend mit einem Scaling-Faktor multipliziert, der die maximal erlaubte Bodenhöhe festlegt. Auf diese Weise liegen die resultierenden Werte nicht mehr nur zwischen 0 und 1, sondern repräsentieren die Höhe der Welt an der jeweiligen (x,z)-Position. Der verwendete Faktor wird vom Spieler beim Erzeugen der Welt aus einer Reihe von Presets (flach, eben, hügelig, bergig) gewählt.
Die Seitenansicht der oben gezeigten Welt.
Den Block (x,y,z) setzen wir nun auf einen Grasblock oder einen Sandblock, je nachdem ob er unter oder über dem Meeresspiegel liegt. Jeden darunterliegenden Block setzten wir auf Erde. Darunter befindet sich eine Steinschicht, deren Höhe analog zur Erdschicht durch eine zweite Perlin Noise berechnet wird. Wenn die Steinschicht die Oberfläche durchdringt, werden nur die obersten Blöcke ersetzt, wodurch sichtbare Steinadern entstehen können. Am Ende des Generationsprozesses füllen wir alle übrigbleibenden Positionen unterhalb des Meeresspiegels mit Wasserblöcken auf.
Blockinteraktionen
Bisher sind wir nur auf die visuellen Bestandteile unserer Block-Typen eingegangen. Damit wir aber auch unsere Idee von einer sich ständig verändernden Welt umsetzen konnten, mussten wir außerdem ein geeignetes System für Interaktionen zwischen benachbarten Blöcken entwickeln. Wie wir unser System letzten Endes aufgebaut haben, möchten wir in diesem Abschnitt beleuchten.
Erstes Design
Jeder Block-Typ verfügt, wie weiter oben im Code-Ausschnitt zu unseren BlockType
s zu sehen, über eine Liste an BlockUpdateRule
Objekten. Diese sehen in Code verkürzt folgendermaßen aus:
public struct BlockUpdateRule
{
public const int anyTypeId = -2; // -2 is reserved for 'any' type
public const int notAirTypeId = -3; // -3 is reserved for 'not air' type
public enum ToggleMode
{
IGNORE = 0,
IF,
IF_NOT
}
public struct ToggleRequirement<T>
{
public ToggleMode toggleMode;
public T requirement;
}
// Increment settings
public int incrementedTypeId;
public int increment;
public float maxDeviationFactor;
public bool doAutoRotate;
// Requirements
public ToggleRequirement<WeatherManager.Weather> weather;
public ToggleRequirement<Clock.DayTime> dayTime;
public NeighbourData<int> neededNeighbours;
public BlockUpdateRule(
int incrementedTypeId,
int increment,
float maxDeviationFactor,
bool doAutoRotate,
ToggleRequirement<WeatherManager.Weather> weather,
ToggleRequirement<Clock.DayTime> dayTime,
NeighbourData<int> neededNeighbours
) { /*...*/ }
public bool CanBeApplied(
NeighbourData<int> neighbourTypeIds,
WeatherManager.Weather currentWeather,
Clock.DayTime currentDayTime
) { /*...*/ }
}
Die CanBeApplied
Methode überprüft hierbei, ob eine gegebene Umgebung einer der vordefinierten Bedingungen genügt. Die Umgebung eines Blocks wird dazu durch ein NeighbourData<int>
Objekt modelliert, in dem für jeden Nachbarn deren jeweilige TypeIds
gespeichert sind.
Spezielle Blocktypen
Da wir schnell bemerkten, dass das Definieren von Regeln nur mit den existierenden TypeIds
, besonders wenn nicht alle Nachbarn relevant für das gewünschte Verhalten sind, häufig in exzessiv vielen Varianten ausarten würde, führten wir außerdem die Typen 'Any'
und 'Not Air'
ein. Diese besitzen standardmäßig die IDs -2
sowie -3
und werden ausschließlich in den Update-Regeln selbst verwendet. Das bedeutet sie haben keine visuelle Repräsentation und können auch nicht in die Welt platziert werden. Darüber hinaus bietet unsere Implementierung mit dem doAutoRotate
-Toggle eine bequeme Möglichkeit, eine Regel-Konfiguration automatisch in allen möglichen Rotationen um die y-Achse zu testen, anstatt nur die Basis-Konfiguration zu berücksichtigen. Dadurch reduziert sich die Zahl an benötigten Regel-Definitionen noch weiter, da somit nicht alle dieser Rotationen manuell erstellt werden müssen.
Zusätzliche Bedingungen
Bis hierhin orientiert sich unser Update-System stark an dem aus zellulären Automaten bekannten Vorgehen, bei dem lediglich die Umgebung einer einzelnen Zelle zum Zeitpunkt des Updates eine Rolle spielt. Um ein wenig mehr Spielraum für interessante Regeln und auch Gameplay-Möglichkeiten zu gewähren, haben wir uns jedoch dazu entschieden, dieses System zu erweitern und noch weitere Kriterien in unsere Update-Regeln einfließen zu lassen. So kann für jede Regel außerdem eine Reihe an zusätzlichen Bedingungen festgelegt werden, die positionsunabhängig sein können. Beispielsweise haben wir Regeln erstellt, die neben einer passenden Block-Umgebung auch auf das aktuelle Wetter und die Tageszeit miteinbeziehen. Die zusätzlichen Bedingungen werden dabei durch ToggleRequirement
s repräsentiert. Auf diese Weise lässt sich im Editor durch ein Drop-Down-Menü einfach festlegen, ob die gegebene Bedingung erfüllt sein soll, wenn der angegeben Wert gegeben oder nicht gegeben ist. Außerdem kann die Bedingung bei Bedarf auch ignoriert werden.
Optimierung
Um die Überprüfung einer Regel auf ihre Anwendbarkeit möglichst effizient zu gestalten, werden zunächst die globalen Bedingungen betrachtet. Erst dann beginnt die Untersuchung der Block-Umgebung. Und auch dort versuchen wir, eine Inkompatibilität möglichst frühzeitig zu erkennen, indem wir die gängigsten Richtungen in sinnvoller Reihenfolge (oben, unten, vorne, hinten, links, rechts) zuerst begutachten. Auf diese Weise können wir es häufig vermeiden, alle Rotationen zu prüfen, wenn diese ohnehin nicht anwendbar sind.
Progress
Eine beispielhafte Abfolge von Zustandsänderungen eines Blockes.
Wenn eine Regel dann tatsächlich anwendbar ist, inkrementiert diese den Update-Fortschritt eines Blocks zu demjenigen Block-Typen hin, welcher der incrementedTypeId
entspricht. Beim Erreichen eines bestimmten Wertes wird dieser dann zum jeweiligen Type aktualisiert. Wenn mehrere Regeln gleichzeitig auf einen Block angewendet werden könnten, so wird nur die erste angewendet, auf die dies zutrifft, um Konflikte bei der Ausführung zu vermeiden.
Überarbeitetes Regelsystem
Trotz der Erweiterung unseres Systems um die oben beschriebenen ToggleRequirement
s hatte unser erstes System einige Schwachstellen:
- Das Definieren von Regeln im Editor war oft sehr umständlich, da die Bedingungen für jede Richtung einzeln gesetzt werden mussten.
- Auch wenn es theoretisch möglich war, mit unserem System Regeln im Stil von Conways Game of Life zu kreieren, war deren Definition trotz der Option zum automatischen Rotieren der Umgebungsbedingungen extrem aufwändig (bereits im Fall des bekannten 2D-Regelsatzes hätten wir bereits über ein dutzend verschiedene Konfigurationen berücksichtigen müssen!).
- Besonders problematisch war das Erzeugen von Regeln, die bestimmte Block-Typen in einer spezifischen Richtung nicht erlauben sollten. Denn das würde in diesem Fall bedeuten, dass jede andere erlaubte Konfiguration manuell definiert werden müsste, da wir hier keinen Nutzen aus den für den gegenteiligen Fall definierten Zusatztypen ziehen können.
- Eine weitere Schwäche des Systems war die überflüssige, aber notwendige Definition eines benötigten Nachbar-Typs in jede mögliche Richtung für jede einzelne Update-Regel. Neben dem dadurch entstehenden Zusatzaufwand und Speicherbedarf machte das die Regeln im Editor außerdem zuweilen sehr unübersichtlich.
Natürlich hätten wir die meisten dieser Probleme mit einer mehr oder weniger simplen Anpassung oder Erweiterung unseres Update-Systems beheben oder zumindest reduzieren können. Da wir jedoch schnell feststellten, dass dies in der Summe ohnehin einige Zeit in Anspruch nehmen würde, entschlossen wir uns stattdessen, unseren Implementierungsansatz für die Regeln als Ganzes zu überdenken.
Der Kerngedanke des neuen Systems ist es, die Regeln in ihre logischen Bestandteile herunterzubrechen. Zur Umsetzung dieser verwenden wir nun anstelle der zuvor eingesetzten, für alle Update-Regeln gleich aufgebauten CanBeApplied
Methode dynamisch erzeugte Lambda-Funktionen, welche wir zur Laufzeit mithilfe eines RuleBuilder
-Objekts anhand der im Editor bestimmten Einstellungen generieren. Dadurch vereinfacht sich die BlockUpdateRule
-Klasse ein wenig, da die verwendeten Bedingungen nun bereits Teil des verwendeten Callbacks sind:
public class BlockUpdateRule
{
public const int notAirTypeId = -2;
public const int anyTypeId = -3;
public int incrementedTypeId;
public int increment;
public float tickChance;
public Func<NeighbourData<int>, WeatherManager.Weather, Clock.DayTime, bool> canBeAppliedCallback;
public BlockUpdateRule(
int incrementedTypeId,
int increment,
float tickChance,
Func<NeighbourData<int>, WeatherManager.Weather, Clock.DayTime, bool> canBeAppliedCallback
)
{
this.incrementedTypeId = incrementedTypeId;
this.increment = increment;
this.tickChance = tickChance;
this.canBeAppliedCallback = canBeAppliedCallback;
}
}
Zwei Beispiel-Regeln in unserem neuen Regel-Editor. Die Regeln werden beim Starten des Spiels dynamisch aus den aneinandergereihten Logikbausteinen erzeugt, welche bequem im Editor hinzugefügt und angeordnet werden können.
Die Verknüpfung zwischen einzelnen Bedingungen erfolgt über einfache Logik-Operatoren (AND
, OR
, AND NOT
, OR NOT
). Auf diese Weise können wir unsere Regeln vollkommen modular und damit extrem flexibel gestalten. Gleichzeitig ist das Definieren neuer Regeln durch die neugestalteten Bedingungstypen so einfach wie nie.
Update Loop
Diagramm zum Ablauf unseres zentralen Update-Loops.
Bei unserem Update-Prozess selbst haben wir versucht, uns an einen eher Daten-orientierten Ansatz zu halten. Konkret bedeutet das in unserem Fall, dass Updates nicht von den Blöcken selbst, sondern in einem zentralen Update-Loop ausgeführt werden. Auf diese Weise konnten wir die Berechnung der neuen Block-Zustände in einen eigenstehenden UnityJob auslagern, der parallel zum Main-Thread ausgeführt werden kann. So blockiert dieser Vorgang die Ausführung des übrigen Codes (sofern auf dem System noch ausreichende CPU-Ressourcen zur Verfügung stehen) nicht, wodurch wiederum die Framerate der Anwendung nahezu konstant bleiben sollte. Allerdings müssen die benötigten Informationen vor jedem Starten des Jobs zunächst einmal im Main-Thread in die dafür benötigten nativen Container kopiert werden. Zunächst haben wir hierzu einfach die aktuellen TypeIds
aller Blöcke und deren jeweilige Regeln ausgelesen und daraus die benötigten Datenstrukturen gebaut. Für große Welten kann das jedoch mehrere Millisekunden dauern, was im Spiel zu unschönem Stottern führt. Um das zu umgehen verwenden wir nun ein WorldChangeCache
, welches die Änderungen in der Welt seit dem letzten Starten des Update-Jobs aufzeichnet, sodass vor dem nächsten lediglich die betroffenen Daten aktualisiert werden müssen.
Dynamische Elemente
Pflanzen
Pflanzen funktionieren intern genau wie alle anderen Block-Typen. Da sie jedoch ein komplexeres Modell als einfach nur einen Würfel besitzen sollten, mussten wir uns für die visuelle Repräsentation einen anderen Ansatz ausdenken. Dafür haben wir den PlantManager
geschaffen. Dieser speichert und verwaltet die GameObject
s der Pflanzen und ihre dazugehörigen BlockId
s in einer Map. Wird ein Pflanzenblock erstellt, instantiiert der PlantManager
ein passendes GameObject
und setzt es an die richtige Position in der Welt. Wird hingegen ein Pflanzenblock gelöscht, so löscht auch der PlantManager
das GameObject
, welches die jeweilige Pflanze zuvor repräsentiert hat. Außerdem bietet er die Funktion, Pflanzenobjekte anhand ihrer BlockId
auszugeben. Was das Gameplay angeht dienen einige Pflanzen als Nahrungsquelle für Tiere oder auch nur der Ästhetik.
Tiere
Block World Agent
Um unserer Spielwelt ein wenig Leben einzuhauchen, haben wir einige Arten von Tieren implementiert, die sich frei darin bewegen können. Allerdings werden unsere Welten in der Regel zufällig während des Spielens generiert und sind somit natürlich zur Compile-Time noch nicht bekannt. Außerdem verändert sich der Zustand der Welt (und damit auch deren Meshes
) häufig, was das Generieren von NavMeshes
sehr teuer machen würde. Aufgrund dieser Umstände konnten wir für die Bewegung der Tiere nicht auf Unitys eigenen NavMeshAgent
zurückgreifen. Stattdessen haben wir mit dem BlockWorldAgent
unseren eigenen Agenten implementiert. Dieser ist in der Lage, sich kontinuierlich, oder auch sprunghaft, durch unsere Welten zu bewegen.
Eine Demonstration unserer Welt-basierten Path-Finding-Implementierung. In diesem Beispiel dürfen nur Gras- und Sandblöcke betreten werden.
Für das Path-Finding des Agents verwenden wir eine leicht abgeänderte Variante des A*-Algorithmus. Diesen führen wir wieder in einem eigenstehenden UnityJob
aus, um den Main-Thread zu entlasten. Indem wir für das Finden der Wege direkt auf den aktuellen Zustand der Welt zugreifen, können wir die zuvor genannte Problematik im Zusammenhang mit NavMeshes
umgehen. Welche Blöcke begehbar sind können für jeden Agenten einzeln spezifiziert werden.
Verhalten
Ein BlockWorldAgent
in Aktion. Die grüne Sphäre repräsentiert das aktuelle Ziel, der weiße Bereich stellt seine logische Position dar. Wie man sieht kann der Agent unabhängig vom damit verbundenen Modell bewegt werden, um dessen Animation mehr Freiheit zu gewähren.
Zum Überleben benötigen Tiere Nahrung. Diese wird von bestimmten Pflanzen bereitgestellt. Wird ein Tier hungrig, beginnt es beim Umherstreifen sich innerhalb seines Sichtfelds nach diesen umzuschauen. Wurde eine geeignete Futterquelle gefunden, so wird diese das neue Ziel des BlockWorldAgents
des Tiers. Wenn das Tier diese aber nicht rechtzeitig erreicht, oder keine passende Nahrung findet, verhungert es. Um das Verhalten der Tiere noch ein wenig dynamischer zu gestalten, kehren die meisten Arten außerdem bei Einbruch der Nacht zu ihrem Nest zurück. Am Morgen verlassen sie dieses dann wieder.
Tag-Nacht-Zyklus und Wetter
Wetter Presets
Unsere vier verschiedenen Wetter-Assets wie sie im Unity-Editor zu sehen sind.
Um für visuelle Abwechslung zu sorgen und die Atmosphäre unseres Spiels weiter zu verbessern wollten wir einen Tag-Nacht-Zyklus sowie ein simples Wetter-System implementieren. Beides basiert bei uns auf verschiedenen Wetter-Presets, welche einige Informationen zu Parametern zur Darstellung des Himmels, der Sonne und der Beleuchtung in Abhängigkeit von der Tageszeit festhalten. Außerdem lassen sich hier einige Einstellung zur Wolkenbildung anpassen. Die Presets werden als Assets abgespeichert und können bequem in Unitys Editor bearbeitet werden. Besonders praktisch erwiesen sich hier die von Unity bereitgestellten Farbverlauf- und Sample-Curve-Klassen, da diese es ermöglichen flüssig zwischen manuell gesetzten Farbwerten oder Zahlen zu interpolieren.
Skybox
Der Hintergrund unserer Spielwelt besteht aus einer sogenannten Skybox, also einer texturierten Kugel, welche die Szene umschließt. Für das Aussehen dieser Kugel ist ein von uns in Unitys Shader-Graph-Editor erstelltes Material verantwortlich. Die einzelnen Komponenten davon sind im Folgenden aufgeführt:
UV-Map
Der für die Berechnung der UV-Koordinaten zuständige Ausschnitt aus dem Shader-Graph des Skybox-Materials.
Damit die Textur auch richtig in die Szene projiziert wird, muss für die Skybox zunächst eine geeignete UV-Koordinaten berechnet werden. Diese lassen sich im Shader-Graph über die Welt-Position bestimmen.
Farbverlauf
Der Shader-Sub-Graph unseres Skybox-Materials welcher den Farbverlauf erzeugt.
Die Farbe unseres Himmels ist durch die Horizont- und Himmels-Farbe bestimmt, zwischen denen entlang der y-Achse interpoliert wird. Das Offenlegen dieser Farben als im Editor bearbeitbare Farb-Parameter macht es dabei leicht, schnelle Änderungen vorzunehmen und zum Beispiel auch Presets für verschiedene Tageszeiten zu erstellen. Die Höhe des virtuellen Horizonts kann außerdem über einen weiteren Parameter skaliert werden.
Sterne
Die Erzeugung des Sternenhimmels in unserem Shader.
Um nachts für ein wenig mehr Stimmung zu sorgen, beinhaltet unsere Skybox auch prozedural erzeugte Sterne, die langsam über den Himmel hinwegziehen. Die Position der Sterne berechnet sich hierbei aus einer auf die UV-Koordinaten der Skybox projizierte Voronoi Noise Textur, deren genaues Aussehen ebenfalls über verschiedene Parameter beeinflusst werden kann. So variiert die Sichtbarkeit der Gestirne etwa abhängig von der Tageszeit.
Wolken
Um die Wolkendecke zu erzeugen, verwenden wir Perlin-Noise. Wir lesen dazu für jede mögliche Wolkenposition den xy-Wert der Textur aus, der sich stets im Intervall [0,1] befindet. Falls der Wert den im jeweiligen Wetter-Preset festgelegten Threshold erreicht oder übersteigt, so wird danach an dieser Position ein Wolkenblock platziert oder ansonsten, falls einer vorhanden ist, entfernt. Die Threshold-Werte steuern deshalb indirekt die Dichte der entstehenden Wolkendecke und sind darum bei bewölktem Wetter niedriger als bei anderen Wetter-Presets gewählt. Die Geschwindigkeit der Wolkenbewegung ergibt sich aus der, mit welcher die Sample-Punkte über die Textur verschoben werden. Außerdem kann die Menge an großflächigen Wolken über die Skalierung der Noise-Textur bestimmt werden.
Randomisierung
Eine Demonstration unseres Tag-Nacht- und Wetter-Systems in 30-facher Geschwindigkeit.
Zur Laufzeit wird das aktive Wetter-Preset in gegebenen Intervallen zufällig gewechselt. Die Übergänge dazwischen erfolgen dabei ebenfalls flüssig, indem wir zwischen den Verschiedenen Werten beider Presets linear interpolieren. Kombiniert sieht das dann in etwa folgendermaßen aus:
Speichern und Laden
Ablauf
Damit laufende Spiele auch nach einem Neustart der Anwendung weitergeführt werden, mussten wir ein Speichersystem implementieren. Unser System schreibt dazu mehrere Dateien auf die Festplatte des Users:
- Ein
.wrld
file, welches den Zustand des Welt-Rasters in serialisierter Form beinhaltet - Ein
.state
file, in dem der Fortschritt des Spielers wie freigeschaltete Blöcke festgehalten wird - Ein
.config
file, das die generellen Spieleinstellungen wie den Game-Mode und damit verbundene Settings abspeichert - Ein
.worldconfig
file, welches den Namen einer Welt sowie einige Eigenschaften wie ihre Größe und die Höhe des Meeresspielgels enthält - Ein
.png
Thumbnail-Bild, das wir in unserem UI verwenden können.
Zusammen decken diese Files die wichtigsten Teile des Zustands des Spiels ab. Die darin enthaltenen Daten werden mithilfe der SaveLoadUtils
serialisiert und im JSON-Format formatiert in Unitys Standard-Directory für persistente Daten abgespeichert. Das Laden bereits erstellter Spielstände erfolgt über den GameManager
, welcher anhand der geladenen Informationen den Zustand des alten Spiels wiederherstellt.
Save Game Presets
Damit auch ohne einen bereits bestehenden Spielstand ein neues Spiel begonnen werden kann, ohne dafür ein zusätzliches System zu benötigen, haben wir außerdem ein ScriptableObject
namens SaveGamePreset
erstellt. Mit dessen Hilfe können Zusammenstellungen von Basis-Einstellungen, die auch in einem SaveGame
vorzufinden sind, als Assets im Editor bearbeitet und gespeichert werden. Zur Laufzeit können diese Presets automatisch in vollwertige SaveGames
umgewandelt werden, welche dann auf dem selben Weg wie ein von der Festplatte gelesener Spielstand geladen werden können.
Übersicht
Insgesamt lässt sich unsere Speicher/Lade-Pipeline grafisch in Etwa folgendermaßen verbildlichen:
Diagramm zum Ablauf des Speichern und Ladens und der daran beteiligten Komponenten. Unten rechts im Bild wird die Struktur der Spielstände auf der Festplatte gezeigt. Die gestrichelten Linien zeigen an, woher die jeweiligen Daten stammen.
Gameplay
Hauptmenü
Unser Hauptmenü.
Im Hauptmenü unseres Spiels können zunächst einige Einstellungen wie die Auflösung oder der verwendete Fenstermodus angepasst werden. Die Einstellungen werden dabei selbstverständlich über die Sitzung hinaus gespeichert und beim nächsten Spielstart automatisch geladen. Den Hintergrund des Menüs ziert eine zufällig generierte Welt, die sich wie die normale Spielwelt verhält. Auch der Tag-Nach-Zyklus und unser Wetter-System sind im Menü aktiv. Der Startzustand beider Systeme wird ebenfalls zufällig bestimmt, wodurch der Hintergrund bei jedem Start ein wenig anders aussieht. Über den Start-Button gelangt der Spieler anschließend in die Weltauswahl.
Weltauswahl
Die Szene in der eine Welt erstellt oder geladen werden kann. Welten werden als 3D-Karten präsentiert, welche den Namen der Welt und ein Vorschaubild beinhalten.
Um einen Spielstand zu laden oder einen neuen zu generieren, wird der Spieler zur Weltauswahl weitergeleitet. Hier werden in Form von Karten die gespeicherten Spielstände präsentiert. Der Spieler kann mithilfe der Pfeiltasten oder durch Anklicken der gewünschten Karte navigieren. An einer ausgewählten Karte kann der Spieler das Spiel starten oder Einstellungen der dazugehörigen Welt vornehmen. Dazu gehören Löschen, Kopieren oder das Ändern des Welt-Namens. Außerdem gibt es eine Karte zum Erstellen einer neuen Welt. Dort kann man den Namen, den Spielmodus, sowie die Größe der Welt und die Höhe der Berge einstellen.
Interaktivität
Kamera
Die Kamera ermöglicht es dem Spieler, sich in der Spielwelt zu bewegen. Ziel dabei war es, die Welt ähnlich wie ein Panorama in einer Schneekugel aussehen zu lassen und den Spieler als eine Art “Gott” in einer Ansicht von oben Entscheidungen fällen zu lassen oder einfach nur der Welt bei ihrer Veränderung zuzusehen. Für den “Gott”-Modus gibt es die Möglichkeit, sich in einer First-Person Ansicht mit den Tasten W, A, S, D und runter und hoch tasten dreidimensional im Raum zu bewegen und sich durch Bewegen der Maus bei gedrückter rechter Maustaste umzuschauen. Im Schneekugelmodus kann der Spieler ein Objekt oder die Welt anvisieren und dann nur durch Mausbewegen sich um dieses Objekt rotieren. Außerdem gibt es die Möglichkeit, sich in Tiere hineinzuversetzen und die Welt aus ihren Augen zu betrachten. Der Übergang zwischen diesen Steuerungsmethoden ist flüssig und der Wechsel passiert automatisch in dem Moment, in welchem ein Steuerungsbefehl aus dem jeweiligen Modus ausgeführt wird.
UI
Das Ingame-UI. Links sieht man die Knöpfe zum Speichern, Beenden und einen zum Zurückkehren in das Hauptmenü. Unten befindet sich die Anzeige für die verfügbaren Blöcke, die gerade ausgewählt sind.
Das UI ist größtenteils recht minimalistisch gehalten. Die Knöpfe sind schlicht weiß und der Textfont ist simpel ohne Verzierungen.
Terraforming
Der Spieler hat die Möglichkeit, Blöcke abzubauen und an einer anderen Stelle wieder zu platzieren. Dies geschieht durch einen Rechtsklick auf den gewünschten Block, der zuvor durch Hovern mit dem Mauszeiger hervorgehoben wird. Der abgebaute Block wird dem Inventar des Spielers hinzugefügt. Blöcke im Inventar können danach durch einen Linksklick an einen hervorgehobenen Block angebaut werden. Ein Platzieren von Blöcken außerhalb der Spielwelt ist nicht möglich.
Netz und Schaufel
Mit dem Netz und der Schaufel kann der Spieler Tiere oder Pflanzen aufnehmen, bewegen, und an einer beliebigen neuen Stelle platzieren. Dies ermöglicht ihm Tiere zu retten und, wenn gewünscht, die Welt entgegen der zufällig generierten Grundversion neu zu gestalten.
Spielmodi
Aktuell bietet unser Spiel drei verschiedene Spielmodi, die jeweils ein etwas unterschiedliches Spielerlebnis bieten:
Conway
Demonstration unseres Conway-Modus.
Mit diesem Spielmodus wollen wir einen kleinen Spielplatz zum Austesten der ursprünglichen Regeln Conways bieten. Standardmäßig ist dieser Modus somit auf nur eine Block-Schicht begrenzt, da diese Regeln natürlich nur für den 2D-Fall definiert wurden. Der Spielablauf besteht hier aus einer Bau- und einer daran anschließenden Simulations-Phase, in welcher die platzierten Blöcke miteinander interagieren und sich vermehren oder auslöschen. Einzelne Simulations-Schritte können außerdem rückgängig gemacht oder schrittweise ausgeführt werden.
Adventure
Demonstration unseres Adventure-Modus in 2-facher Geschwindigkeit.
Im Normalen Modus hat der Spieler eine begrenzte Menge an Ressourcen. Er kann nur Blöcke abbauen die er vorher abgebaut hat.
Creative
Demonstration unseres Creative-Modus.
Diese Einschränkung gibt es im Creative Modus nicht. Der Spieler kann unendlich viele Blöcke platzieren ohne sie vorher abgebaut zu haben. Außerdem kann ein Creative Spieler das Wetter und die Tageszeit ändern.
Ausblick und Ideen
Natürlich ist unser Spiel zur Zeit des Abschlusses des Praktikums noch lange nicht perfekt. Das Gameplay ist bisher sehr einfach und besitzt nicht viel Tiefe und auch an der Präsentation des Spiels könnte man sicherlich noch einiges verbessern. Und auch was den Inhalt des Spiels angeht, haben wir noch einige Ideen, die man in der Zukunft noch umsetzen könnte.
Sound
Aktuell ist unser Spiel noch ziemlich stumm. Das liegt vor allem daran, dass wir uns zunächst auf die visuellen Aspekte und vor allem die Kern-Mechaniken des Spiels konzentrieren wollten und den akustischen Bereich somit bisher vollkommen vernachlässigt haben. Eine stimmige Sound-Kulisse würde dem Spielerlebnis aber natürlich sehr gut tun. Hier gibt es also noch einiges an Verbesserungspotential!
Mehr Block-Typen und Regeln
Für noch mehr Abwechslung und interessante Block-Interaktionen könnten wir unserem Spiel weitere Block-Typen und Update-Regeln hinzufügen. Da unser System in diesem Punkt leicht zu erweitern ist, kann hier der Fantasie freien Lauf gelassen werden, sollten wir uns in Zukunft weiter mit diesem Projekt befassen.
Neue Tiere
Einige unserer 3D-Modelle die es bisher nicht ins Spiel geschafft haben.
Ein umfangreicherer Zoo an Tieren wäre ebenfalls eine mögliche Erweiterung des Spielerlebnisses und des Simulations-Aspektes unseres Projekts. Wir hatten zum Beispiel die Idee, Bienen hinzuzufügen, welche um Blumen herumschwirren, oder auch Raubtiere, welche Jagd auf andere Tiere machen. Ein größeres Unterfangen, das deshalb auch außerhalb des Scopes des Praktikums lag, wäre das Einfügen von Menschen, welche durch die Welt wandern, jagen und sich wohnlich niederlassen können.
Regelbaukasten
Auch eine interessante Idee im Bezug auf unser Update- und Regel-System wäre es, im Spiel einen Baukasten anzubieten, mit dem ein Spieler sich selbst eigene Regeln und Interaktionen zwischen Blöcken ausdenken kann, die dann im Spiel verwendet werden können. Unser aktuelles System müsste dafür zwar wohl noch ein wenig angepasst und erweitert werden, machbar wäre diese Funktion aber auf jeden Fall.
Detailliertere Weltgenerierung
Auch an der Art und Weise, wie unsere Welt generiert wird, könnte man noch weiter arbeiten. Zum Beispiel wäre es interessant, verschiedene Biome und die Erzeugung von Höhlen und Schluchten in die Generierung miteinzubeziehen.
Volumetrische Wolken
Aktuell ist unsere Wolkenschicht nur einen Block tief. Hier gäbe es die Möglichkeit, das System zum Beispiel um den Einsatz dreidimensionaler Noise und somit die Generierung ein wenig flauschigerer Wolken zu erweitern.
Verfeinertes Gameplay
In Sachen Gameplay hätten wir ebenfalls noch einige Ideen, die man zukünftig noch implementieren könnte, um das Spiel interessanter zu gestalten. Zum Beispiel könnte die Schwierigkeit Blöcke umzuwandeln erhöht werden und mehr User Interaktion erfordern.
Fazit
Auch wenn wir nicht alle unserer Ziele erfüllen konnten und sich die Entwicklung deutlich länger hingezogen hat als ursprünglich geplant, sind wir dennoch sehr zufrieden mit dem, was wir in diesem Praktikum erreicht haben: Wir haben mit unserer Weltgeneration, dem flexiblen Regel- und Update-System, sowie den bereits vorhandenen visuellen und Gameplay-Aspekten eine solide Grundlage für ein, zumindest in unseren Augen, interessantes Spiel geschaffen. Darüber hinaus haben wir neue Erfahrungen mit und wertvolles Wissen über den Umgang mit Unity und das Erzeugen und Darstellen prozeduraler Welten gesammelt.