Spiele selbst entwickeln mit XNA, Teil 2: Grundlagen und erste Schritte
Was bisher geschah…
In Teil 1 unserer Artikelreihe zur Spieleentwicklung haben wir uns einen kurzen Überblick über das XNA Framework verschafft. Unser Ziel ist es, in den nächsten Teilen ein vollwertiges Spiel zu programmieren. Entstehen soll ein 2D Topdown Shooter mit einigen Spezialkniffen.
Eine Woche ist vergangen und es gab viele tolle Ideen und Verfeinerungsmöglichkeiten, um das Spiel aufzupeppen. Schließlich soll das Produkt keine reine Techdemo werden, es soll auch Spaß machen! Noch sind die Anforderungen an das Spiel völlig offen, wer uns auf Twitter (@INGAMEredaktion) folgt, kann Vorschläge direkt an uns schicken!
Wir gehen dabei völlig “agil” vor, was in diesem Fall bedeutet, dass wir Anforderungen jederzeit verwerfen oder aufnehmen werden! Folgende Anforderungen sollen diesesmal unser bisheriges simples Konzept “Arenashooter” aufwerten:
- Es soll eine großes Gebiet frei begehbar sein
- Das Terrain soll zufallsgeneriert und abwechslungsreich sein
- Je nachdem, wie komplex das Terrain dann wird, benötigen die Gegner vermutlich auch eine primitive Pathfinding AI
Oh, und vermutlich hat der eine oder die andere geneigte LeserIn vom mittlerweile erschienenen Spiel FEZ gehört? Ja richtig, ist auch XNA! Mit diesem Motivationsschub starten wir jetzt voll durch, doch wie jeder gute Handwerker brauchen wir zuerst noch das passende Werkzeug.
Die Tools
Wie in Teil 1 bereits erwähnt: Um XNA zu entwickeln, bietet Microsoft das kostenlose XNA Game Studio 4.0 zum Download an. Dieses erfordert die Installation der ebenfalls kostenfreien .NET Entwicklungsumgebung Visual C# 2010 Express Edition. Bei der Installation des XNA Game Studios werden Projektvorlagen für die Erstellung von Spielen und Bibliotheken für Windows, Xbox360 und Windows Phone in die Visual C# 2010 Express IDE integriert.
Achtung, Falle: Um XNA Spiele auf anderen Rechnern auszuführen, muss auf diesen die XNA Framework 4.0 Redistributable installiert werden.
Ist das Game Studio erstmal installiert und gestartet, kann ein neues Spiel erstellt werden. Erwartungsgemäß führt die Bedienung der Menüeinträge Datei / Neu / Projekt zum gewünschten Erfolg. Wir wählen ein neues Projekt mit dem Projekttyp Windows Game. Kleiner Tipp: Vor dem Bestätigen mit OK können auch noch andere Einstellungen wie der Name des Spiels und der Speicherort geändert werden.
Ein kleiner Aufruf an dieser Stelle: Wir suchen neben Anforderungen auch noch einen Namen für unser Spiel. Vorschläge nehmen wir auf Twitter und Facebook entgegen!
Nur am Rande erwähnt sei, dass es bereits zahlreiche Engines für XNA gibt. Die bekannteste davon ist wohl das SunBurn. Wer nicht ständig alles neu programmieren, aber dennoch nicht auf die Flexibilität von XNA verzichten möchte, sollte definitiv auf eine vorgefertigte Engine wie SunBurn zurückgreifen. Da uns aber die Grundlagen besonders interessieren, verzichten wir in weiterer Folge auf jegliche 3rd Party Unterstützung.
Pro Tipp: Für die SunBurn Engine gibt es sogar ein eigenes Subforum im offiziellen XNA Forum von Microsoft!
Begrifflichkeiten und Lebenszyklus
Ausgangspunkt jedes XNA Spiels ist die Klasse Game. Eine Ableitung dieser Klasse wird beim Anlegen eines neuen Projekts standardmäßig mit der Datei Game1.cs eingebunden. Bei Programmstart wird eine Instanz dieses Typs erzeugt und dessen Lebenszyklus mit dem Aufruf der Methode Run() gestartet. Dieser Teil ist ebenfalls standardmäßig bereits fertig implementiert, sodass man mit der Programmierung der Logik innerhalb der Game Klasse sofort beginnen kann.
Der Lebenszyklus der Game Komponente lässt sich in drei Phasen unterteilen: Die Initialisierung zum Beginn des Zyklus, nachfolgend der sogenannte Game Loop und schlussendlich das Aufräumen beim Beenden des Programms.
Die Phase der Initialisierung wird beim Start des Programms durchlaufen. Dabei wird die Methode Initialize() aufgerufen, welche in der abgeleiteten Klasse überschrieben werden kann. In dieser Methode sollten alle Initialisierungen durchgeführt werden. Innerhalb des Initialize() Aufrufs wird außerdem die LoadContent() Methode aufgerufen. Diese kann ebenfalls überschrieben werden und ist die empfohlene Codestelle, um Spielinhalte wie Texturen oder Sounds zu laden.
Der sogenannte Game Loop besteht aus einer Schleife, die bis zum Ende des Programms die Aufrufe Update() und Draw() nacheinander durchläuft. Beide sind überschreibbare Methoden und dienen zum Aktualisieren von Spielvariablen im Update() Aufruf sowie zur schlussendlichen Ausgabe auf dem Schirm in der Draw() Methode.
Wird das Programm beendet, durchläuft das Spiel die letzte Phase: das Aufräumen. Dabei wird die Methode UnloadContent() aufgerufen, welche etwaige Ressourcen und Spielinhalte freigibt. Es ist nicht notwendig, diese Methode zu überschreiben und Ressourcen manuell freizugeben, da dies auch automatisch geschieht.
Pro Tipp: Standardmäßig versucht XNA, die Update() Methode 60 Mal in der Sekunde aufzurufen. Gelingt das nicht, werden Draw() Aufrufe übersprungen, um mehr Update() Aufrufe unterzubringen – das führt zum Abfall der FPS (Frames per Second). Dieses Verhalten kann mit der IsFixedTimeStep Eigenschaft des Game Objekts geändert werden.
Ordnung in das Chaos: Komponenten
Schon bei etwas größeren Projekten stellt sich natürlich die Frage, wie man die Codestücke besser verwalten kann – schließlich soll nicht alles in der Game Klasse landen! Die Klasse Game verfügt über die Eigenschaft Components, über die sie eine GameComponentCollection zur Verfügung stellt. Eine GameComponentCollection ist eine manipulierbare Auflistung von Objekten, deren Typen das Interface IGameComponent implementieren. Zusätzlich können diese Objekte auch IUpdateable und IDrawable implementieren.
In jeder entsprechenden Phase werden diese Objekte iteriert und die entsprechenden Methoden Initialize(), Update() oder Draw() – je nach implementiertem Interface – aufgerufen. So lassen sich bestimmte Teile unseres Spiels in eigene logische und grafische Komponenten wie Spiellogik oder Eingaben auslagern und dann bequem in diese Auflistung einfügen.
Um das Entwickeln eigener Komponenten zu vereinfachen, stellt das XNA Framework bereits zwei Basisimplementierungen bereit: GameComponent und DrawableGameComponent. GameComponent implementiert das Interface IGameComponent und IUpdateable, DrawableGameComponent leitet von GameComponent ab und implementiert zusätzlich noch das Interface IDrawable. Jede GameComponent Instanz hält außerdem eine Referenz auf das aktuelle Game Objekt, welche dem Konstruktor übergeben werden muss.
Diese automatische Verwaltung von Komponenten ist sicherlich für kleine Projekte hilfreich, vor allem bei der grafischen Ausgabe ist aber die Reihenfolge der Draw() Aufrufe von entscheidender Bedeutung. Dies kann zwar über die Eigenschaft DrawOrder des Interfaces IDrawableComponent beeinflusst werden, ist aber nicht sehr flexibel. Vor allem in größeren Projekten empfiehlt es sich daher, verschiedene Managerklassen zu implementieren, welche Teilaspekte des Spiels abdecken, über die Reihenfolge der Aufrufe entscheiden und diese durchführen.
Eine mögliche Implementierung eines GameComponent Managers:
// Component manager base class
// <typeparam name=”T”>Type of component to manage</typeparam>
public abstract class ComponentManager<T> : DrawableGameComponent where T : IGameComponent, IUpdateable
{
// List of managed components
protected List<T> components;
// True, if T implements IDrawable
private bool isDrawable;
public ComponentManager(Game game)
: base(game)
{
components = new List<T>();
// Examine type interfaces
Type[] interfaces = typeof(T).GetInterfaces();
isDrawable = interfaces.Contains(typeof(IDrawable));
}
public override void Initialize()
{
base.Initialize();
foreach (T item in this.components)
{
item.Initialize();
}
}
public override void Update(GameTime gameTime)
{
foreach (T item in this.components)
item.Update(gameTime);
}
public override void Draw(GameTime gameTime)
{
// Only call draw if the type implements IDrawable
if (!isDrawable) return;
foreach (T item in this.components)
((IDrawable)item).Draw(gameTime);
}
}
Kurzer Exkurs: GraphicsDevice und Manager
Die Klassen GraphicsDevice und GraphicsDeviceManager sind Dreh- und Angelpunkte, wenn es um die Darstellung der Spielinhalte geht. Beide erlauben das direkte Ansteuern der Grafikhardware und deren Manipulation über bereitgestellte Eigenschaften, Methoden und Ereignisse.
Standardmäßig stellt der GraphicsDeviceManager die bestmöglichen Einstellungen für das GraphicsDevice ein. Unter anderem kann man über den GraphicsDeviceManager das AntiAliasing (Kantenglättung) anpassen, die Auflösung ändern oder zwischen Vollbild und Fenstermodus wechseln. Änderungen werden erst mit dem Aufruf von ApplyChanges() durchgeführt. Das ist auch gut so, denn der Vorgang erfordert ein neues Aufbauen des Fensters.
Auf dem GraphicsDevice gibt es zahlreiche Einstellungsmöglichkeiten für den RenderState, die die Art und Weise, wie die Grafikkarte mit den Daten umgeht und rendert, direkt beeinflussen kann. Das GraphicsDevice erlaubt außerdem noch das direkte Setzen von Vertex Daten.
Zufälliges Terrain
Eigentlich sollte man ja immer zuerst das Spielprinzip prototypen, bevor man sich an solche Details ranmacht. Wir erlauben uns aber diesen Fehltritt, und starten mit der Generierung des Terrains. Die Idee ist, dass die Welt, durch die sich unser Avatar bewegen wird, in Quadrate unterteilt ist, sogenannten Tiles. Jedes Tile entspricht einem bestimmten Terraintyp: Wasser, Sand, Gras, Stein und was uns sonst noch so einfällt. Die Tilemap soll jedesmal neu per Zufall generiert werden, aber natürlich dennoch eine “realistische” Form haben. Ein reiner Zufallsgenerator ist dabei nicht ausreichend, da dabei nur selten zusammenhängende Landmassen entstehen. Hier hilft nur eins: Perlin Noise!
Eine genaue Erläuterung des Verfahrens ist nicht im Fokus dieser Artikelreihe. Kurz zusammengefasst handelt es sich bei Perlin Noise aber um ein pseudozufälliges Rauschverfahren, das in der digitalen Technik immer wieder für diverse Effekte eingesetzt wird. Bevorzugt immer dann, wenn zufällige, aber dennoch zusammenhängende Muster benötigt werden, wie bei Wolken, Wasser oder eben auch Terraingenerierung.
Auch wir machen uns die Macht des Perlins zu nutze. Codebeispiele für C# gibt es viele, eine adaptierte Umsetzung dieser Variante findet Ihr im angehängten Demoprojekt. Damit erzeugen wir ein quadratisches Bild, das uns die Grundlage für das weitere Terrain bildet. Dazu lassen wir uns ein zweidimensionales Array mit Rauschwerten zwischen -1 und 1 erzeugen. Auf Graustufen abgebildet, ergibt sich dadurch folgendes Bild:
Die Grauwerte normalisieren wir auf Werte zwischen 0-255 und weisen den Bereichen nun verschiedene Terraintypen zu:
- Wir holen uns den größten Wert und multiplizieren ihn mit 0.4. Das ergibt unseren virtuellen Meeresspiegel. Je nachdem wieviel Wasser wir im Level haben wollen, können wir den Wert anpassen.
- Zwischen Wasserlinie und Wasserlinie – 20 ist seichtes Wasser.
- Unterhalb der Wasserline – 20 ist tiefes Wasser.
- Zwischen Wasserline und Wasserlinie + 15 ist Sand.
- Zwischen Wasserlinie + 15 und Wasserlinie + 40 ist Gras.
Auf unser Graubild angewandt ergibt das folgendes Aussehen:
Schonmal nicht schlecht, allerdings sollte das Festland von Wasser umschlossen sein. Um diesen Effekt zu erreichen, erzeugen wir eine zweite Maske mit Fließkommawerten zwischen 0.0 und 1.0. Die Maske besteht dabei aus Partikeln, die von der Mitte der Maske ausgehen und pro Schritt abgeschwächt in Richtung der Kanten der Maske wandern. Der Prozess wird dann mit ein paar tausend Partikeln wiederholt. Wieder auf Graufstufen abgebildet, entsteht folgendes Bild:
Wir kombinieren diese Maske nun mit unserem Ursprungsbild, indem wir jeden Pixel des Ursprungsbildes mit dem Wert der Maske multiplizieren.
Sieht vielversprechend aus. Wenden wir nun unsere Terrainvorschriften auf die entstandene Map an, kommen wir zum finalen Ergebnis.
Auf diese Weise können wir nun eine Tilemap beliebiger Größe erzeugen. Ein wichtiger Grundstein für unser Spiel ist damit schonmal erledigt und kann bereits integriert werden.
Sprites!
In beinahe jedem Spiel wird eine Vielzahl an Sprites verwendet. Vereinfacht erklärt sind Sprites grafische Elemente wie Bilder und Text, die im zweidimensionalen Raum gezeichnet werden. Um mit Sprites komfortabel arbeiten zu können, kann man in XNA auf die Klasse SpriteBatch zurückgreifen.
Die Bilder unserer schönen Tilemap aus dem letzten Kapitel wurden alle mit Hilfe einer Windows Applikation erzeugt. Nun müssen wir diesen Code in XNA umsetzen. Das Erzeugen eines SpriteBatch Objektes benötigt dabei allerdings einen gewissen Aufwand an Zeit und Ressourcen. Grafische Komponenten, die einen SpriteBatch benötigen, sollten diesen in der LoadContent() Methode erzeugen und in einer privaten Membervariable ablegen.
Der Ursprung des 2D Koordinatensystems in XNA befindet sich in der linken oberen Ecke des Darstellungsbereichs. Die Y-Achse beschreibt dabei die Höhen- und die X-Achse die Längenangabe.
Insgesamt bietet die Klasse SpriteBatch sieben Überladungen für die Draw() und sechs für die DrawString() Methode an. Diese erlauben feingranulare Manipulation der Darstellung von Sprites, wie Größe, Farbmodulation, Transparenz, Orientierung und Rotation. Das macht die SpriteBatch Klasse zu einem mächtigen Werkzeug für die Darstellung von 2D Elementen.
Pro Tipp: Um Sprites korrekt zeichnen zu können, werden in der Begin() Methode der SpriteBatch Klasse einige notwendige Einstellungen auf dem GraphicsDevice Objekt gesetzt. Diese sollten laut Dokumentation mit dem Aufrufen der End() Methode wieder automatisch zurückgesetzt werden. Leider funktioniert dies aber nicht immer korrekt. Die Folge davon ist, dass darauffolgende 3D Inhalte fehlerhaft dargestellt werden. Es empfiehlt sich daher, vor dem Zeichnen von 3D Inhalten den RenderState generell auf dafür gültige Werte zurückzusetzen.
Ein einfacher Spritebatch Aufruf:
/// <summary>
/// This is called when the game should draw itself.
/// </summary>
/// <param name=”gameTime”>Provides a snapshot of timing values.</param>
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
base.Draw(gameTime);
spriteBatch.Begin();
Rectangle rect = new Rectangle(0,0, 200, 200);
// zeichnet die textur an die position 0,0 mit einer Höhe von 200
// und einer breite von 200.
spriteBatch.Draw(this.texture, rect, Color.White);
spriteBatch.End();
}
Der Plan ist nun eine einfache Textur mit der entsprechenden Färbung pro Tile zu zeichnen. Wie mit Texturen umgegangen wird, wird im nächsten Teil der Artikelreihe beschrieben, daher wird eine bereits fertig geladene Textur an dieser Stelle als gegeben angenommen. Die Textur ist dabei eine einfache 5×5 Pixel große, weiße Fläche. Die Färbung wird mit Hilfe des SpriteBatches vorgenommen. Die angeführte Methode GetColor() gibt uns für den übergebenen byte Wert und dem Meeresspiegel die korrekte Farbe zurück.
Eine Hilfsklasse übernimmt die Rückgabe der korrekten Werte:
class TerrainTypes
{
public const float WaterPercentage = 0.4f;
public const float RockPercentage = 1f;
public const int ShoreDepth = 20;
public const int BeachDepth = 15;
public const int PlainsDepth = 40;
public const int RocksDepth = 15;
static readonly Color ColorDeepWater = new Color(0, 82, 198);
static readonly Color ColorShallowWater = new Color(0, 148, 255);
static readonly Color ColorSand = new Color(255, 208, 107);
static readonly Color ColorPlains = new Color(0, 160, 0);
static readonly Color ColorForrest = new Color(0, 90, 0);
public static Color GetColor(byte waterLine, byte value)
{
if (value <= waterLine – ShoreDepth)
return ColorDeepWater;
else if (value > waterLine – ShoreDepth && value <= waterLine)
return ColorShallowWater;
else if (value >= waterLine && value < waterLine + BeachDepth)
return ColorSand;
else if (value >= waterLine + BeachDepth && value < waterLine + PlainsDepth)
return ColorPlains;
else
return ColorForrest;
}
}
Nun iterieren wir zeilen- und spaltenweise durch die Tilemap und zeichnen die Tiles auf den Schirm.
Der fertige Draw() Call:
/// <summary>
/// This is called when the game should draw itself.
/// </summary>
/// <param name=”gameTime”>Provides a snapshot of timing values.</param>
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.CornflowerBlue);
base.Draw(gameTime);
spriteBatch.Begin();
for (int row = 0; row < gridSize; row++)
{
for (int col = 0; col < gridSize; col++)
{
byte terrainType = (byte)tileMap.GetMapValue(row,col);
Rectangle rect = new Rectangle(row * tileSize, col * tileSize, tileSize, tileSize);
spriteBatch.Draw(this.texture, rect, TerrainTypes.GetColor(waterLine, terrainType));
}
}
spriteBatch.End();
}
Es ist vollbracht und unsere ersten Sprites werden erfolgreich gezeichnet:
Next Up: Lights, Camera, Action!
Im nächsten Teil der Artikelreihe wird’s richtig spannend. Wie bereits angekündigt, werden wir uns eine flexible 2D Kamera implementieren und uns das erste Mal mit unserem Charakter durch unsere Welt bewegen. Dazu schauen wir uns auch an, wie wir mit Keyboard und Mouseinput umgehen. Außerdem betrachten wir eine der größten Stärken von XNA: Die Content Pipeline.
Ein eigenes Thema in XNA ist auch immer das Management unterschiedlicher “Schirme”, wie “Hauptmenüschirm”, “Optionsschirm” oder “Spielschirm”. Wir werden uns eine einfache aber wiederverwendbare Lösung programmieren, mit der wir dann problemlos verschiedene solcher sogenannten “Szenen” managen können.
1 Pingback/Trackback
- Article: XNA, Part 2 (German) « urban escapism
Hinterlasse eine Antwort