Spiele selbst entwickeln mit XNA, Teil 3: Lights, Camera, Action!
Was bisher geschah…
In Teil 1 unserer Artikelreihe zur Spieleentwicklung haben wir uns einen kurzen Überblick über das XNA Framework verschafft. Das Ziel ist die Erstellung eines 2D Arena Shooters.
In Teil 2 haben wir uns den Lebenszyklus eines XNA Games angesehen und uns außerdem mit einer Technik zur Zufallsdatengenerierung beschäftigt. Mit dieser haben wir eine Fläche erzeugt und diese mit Hilfe des SpriteBatch gezeichnet.
Gleich vorweg die gute Nachricht: Wir haben nun genug Grundlagen erlernt, um mit dem eigentlichen Projekt zu beginnen. Das bedeutet, wir werden nun ein Projekt erstellen, das wir kontinuierlich erweitern und verbessern werden. Um das Projekt anzulegen, brauchen wir nur noch einen griffigen Namen. In unserem Arenashooter wollen wir einen Magier auf einer tropischen Insel spielen, der mit seinen Fäusten Feuerbälle (und dergleichen) auf seine Gegner ballert. So soll das Spiel dann auch heißen und bekommt gleich ein schnittiges Logo dazu spendiert:
Das neue Projekt
Nachdem wir unserem neuen Windows Game Projekt einen Namen gegeben haben, wechseln wir noch schnell in die Einstellungen des Projekts mit Rechtsklick auf dem Projekt und der Auswahl “Properties”. Dort stellen wir das Game Profile auf HiDef. Standardmäßig wird dort bei neuen Projekten das Reach Profile ausgewählt, welches nur ein Subset aller Funktionalitäten anbietet, um das Portieren auf Xbox und Windows Phone zu erleichtern bzw. auch für ältere Windows Laptops ausgelegt ist. Wir bleiben aber am PC, daher bevorzugen wir HiDef. Für Interessierte, Shawn Hargreaves hat auf seinem Blog die Unterschiede zwischen HiDef und Reach Profil aufgelistet.
Nachfolgend eine kurze Beschreibung des SourceCodes.
Ein neues Game Projekt besteht aus zwei Projekten: Dem eigentlichen Programmcode und den Resourceprojekt. Das Resourcenprojekt enthält alle vom Spiel notwendigen Assets, wie Texturen und Sounds. Zu diesen Resourcen kommen wir aber noch in einem späteren Kapitel. Das eigentlich Projekt ist mit mehreren Ordnern unterteilt:
- Components enthält Klassen, die sich von DrawableGameComponent ableiten
- Screen enthält alles, was zum Screen Management notwendig ist. Dazu mehr in einem der nachfolgenden Kapitel.
- System enthält eine Komponente zum Anzeigen von System- und Debuginfos.
- Data enthält unsere Spieldaten, wie zum Beispiel den GameManager oder die Terrainerzeugung.
- Extensions enthält diverse Erweiterungsklassen mit Hilfsmethoden für XNA.
- Interfaces enthält Schnittstellenbeschreibungen, die wir für die Implementierung einiger Services brauchen. Dazu weiter unten noch eine Beschreibung.
- Scenes enthält alle unserem Spiel bekannten Szenen.
- Utils enthält den Perlin Noise Code des letzten Teils.
Bitte nicht abschrecken lassen, wir werden jeden einzelnen Teil natürlich noch genauer erklären. Im Moment noch relevant sind die Schnittstellenbeschreibungen. Diese brauchen wir zur Definition von Services. Es gibt nämlich immer wieder den Fall, dass Komponenten miteinander kommunizieren müssen oder einander benötigen. So brauchen zum Beispiel mehrere Komponenten Zugriff auf die Spiellogik oder die Eingabeverarbeitung. Wir definieren daher Interfaces und verwenden den GameServiceContainer auf der Game Klasse. Um einen Service hinzuzufügen, verwenden wir den Aufruf AddService, dem wir das Interface und die Objektinstanz übergeben. Jede andere Komponente kann nun mit GetService und der Übergabe des Interfaces die Instanz abfragen.
In aller Kürze: Matrizen und Vektoren
Wir werden in Zukunft öfter mit Matrizen und Vektoren in Berührung kommen, deshalb ist es unerlässlich, das wir uns ein paar Absätze Zeit nehmen, um entsprechende mathematische Grundlagen zu besprechen. Wir programmieren zwar ein 2D Spiel, da wir aber zum einen für unsere Kamera mit Matrizen Arbeiten müssen, benötigen wir auch entsprechendes Grundwissen, welches auch das Arbeiten in 3D mitbetrifft.
Pro Tipp: Wer Einheitsvektoren und Matrixtransformationen zum Frühstück verspeist, der kann dieses Kapitel überspringen.
XNA verwendet ein sogenanntes rechtshändiges Koordinatensystem. Dabei verläuft die X-Achse von links nach rechts, die Y-Achse von unten nach oben und die Z-Achse von vorne nach hinten. Die Analogie der rechten Hand kommt von einer gern verwendeten Eselsbrücke derer sich Grafikprogrammierer gerne bedienen, um sich die unterschiedlichen Betrachtungsweisen auf ein Koordinatensystem schnell in Erinnerung zu rufen. Bei einem rechtshändigen Koordinatensystem wird dabei die rechte Hand zur Seite ausgestreckt und die Hand so geformt als ob man eine Pistole damit halten würde. Danach wird die Handfläche Richtung Himmel gedreht. Der Zeigefinger zeigt damit nach rechts vom Körper weg und gibt somit die positive Richtung der X- Achse an, der Daumen zeigt nach hinten und gibt die positive Richtung der Z-Achse an und der Mittelfinger zeigt nach oben, der Y- Achse folgend.
Um Richtungsangaben im dreidimensionalen Raum anzugeben, verwendet man Vektoren. Diese definieren sich über ihre Richtung und Länge und sind positionslos. Positionsangaben werden in einem Koordinatensystem über Punkte getroffen, welche ausschließlich über diese Eigenschaft verfügen.
Für Verwirrung kann daher anfänglich sorgen, dass sowohl Vektoren als auch Punkte über dieselbe Art und Weise – nämlich über die Angabe von X, Y und Z- Werten – repräsentiert werden. Aus diesem Grund verwendet XNA nämlich dieselbe Vector2 bzw. Vector3 Struktur um Punkte und Vektoren zu beschreiben. Diese Strukturen bieten praktischerweise viele statische und Instanzmethoden, welche das Arbeiten mit Vektoren und Punkten wesentlich vereinfachen, z.B.:
- Length() gibt die Länge des Vektoren zurück
- Mit Distance(punkt1,punkt2) berechnet man die Distanz zweier Punkte.
- Mit Normalize() erzeugt man einen Einheitsvektor. Dieser behält zwar dieselbe Richtung, hat aber einen Betrag von 1. Sehr praktisch, wenn man nur mit Richtungen arbeiten will!
Etwas aufwändiger gestaltet sich die Arbeit mit Matrizen. Eine Matrix wird als ein zweidimensionaler Array von Daten abgebildet. Die Matrix Struktur des XNA Frameworks ist eine 4×4 große Matrix. Diese bietet ebenfalls wieder sowohl statische als auch Instanzmethoden an um Transformationen durchzuführen. So können Translation (also die Positionierung im Raum), die Skalierung und die Rotation einfach verändert werden.
Pro Tipp: Beachtenswert ist, dass Vector2, Vector3 und Matrix Strukturen sind, und daher wie “Value Types“ zu behandeln sind!
Zum Abschluß noch ein wichtiger Hinweis: Um Transformationen auf einer Matrix durchzuführen, wird diese mit einer entsprechenden anderen Matrix multipliziert. Zu beachten ist dabei, dass diese Matrixmultiplikationen immer relativ zum Koordinatenursprung und nicht kommutativ sind. Das bedeutet zum Einen, dass Matrix A * Matrix B nicht gleich Matrix B * Matrix A ist! Zum anderen, soll in einem Berechnungsschritt mehr als eine Transformation durchgeführt werden, ist die Reihenfolge der Durchführung essentiell! Beispielsweise werden Transformationen einer World Matrix (siehe nächstes Kapitel) in XNA üblicherweise mit folgender Reihenfolge durchgeführt: Skalierung, Rotation, Translation – oder kurz SRT.
Damit stellen wir sicher, dass ein Objekt zuerst skaliert und rotiert wird, bevor es in der Welt positioniert wird. Würden wir die Reihenfolge ändern, also zum Beispiel als Erstes das Objekt positionieren, dann würden nachfolgende Änderungen, wie eine Skalierung, Einfluss auf die Objektposition haben! Dies ist aber in den meisten Fällen nicht erwünscht, daher sollte man die Reihenfolge immer gut durchdenken.
Anbei ein kleines Beispiel, das auch gleich noch mehr praktische Methoden demonstriert.
float scale = 2f;
float rotateY = MathHelper.ToRadians(90);
Vector3 position = new Vector3(0, 0, 0);
Matrix newMatrix =
Matrix.CreateScale(scale) *
Matrix.CreateRotationY(rotateY) *
Matrix.CreateTranslation(position);
Eine Frage der Perspektive
Die Sicht des Spielers auf die Spielwelt wird als Kamera bezeichnet. Dabei gibt es verschiedene Arten von Kameras:
- Bei Actionspielen dominiert die Sicht über die Schulter des Protagonisten, die sogenannte Third-Person View und natürlich vor allem die Egoperspektive.
- Vogelperspektiven werden oft für Strategiespiele gewählt, um einen möglichst guten Überblick zu gewährleisten.
Doch Fists Of Fire ist ein 2D Spiel, wieso brauche ich da eine eigene Kamera? Berechtigte Frage! Wir wollen nicht immer die ganze Spielwelt sehen, sondern eigentlich stets nur einen bestimmten Ausschnitt. Das kann man theoretisch auch ohne klassische Kamera umsetzen, indem man ein Weltsystem einführt, das vom angezeigten Koordinatensystem abweicht und dann einfach zwischen Welt- und Bildschirmkoordinaten umrechnet. Will man die Spielwelt auch zoomen oder rotieren, dann bietet sich eine Kamera an.
In einer 3D Welt wird der exakte Sichtbereich des Spielers wie hier in der Abbildung ersichtlich durch einen Kegelstumpf, dem sogenannten View Frustum, beschrieben. Die Seitenflächen entsprechen dabei den Bildschirmrändern. Die vordere Ebene wird als Near Plane bezeichnet, die hintere, also der Boden der abgeschnittenen Pyramide, als Far Plane.
Neben der Front und Back Plane wird dieses Frustum auch durch die Position, der Orientierung und die Perspektive definiert. Die Perspektive der Kamera wird wiederum definiert durch das Bildschirmverhältnis (z.B. Widescreen) und dem sogenannten Field of View, welches einfach ausgedrückt die Breite des Sichtfeldes in Graden definiert.
Um ein 3D Objekt im Raum zu positionieren und mit Hilfe einer Kamera auf eine zweidimensionale Abbildung zu mappen, werden drei elementare Matrizen benötigt:
- Die erste ist die sogenannte World Matrix. Importierte Modelle bestehen vereinfacht formuliert aus Punkten, die sich an einem eigenen Koordinatensystem orientieren, üblicherweise in Bezug auf den Mittelpunkt des jeweiligen Objekts. Diese Koordinaten, der sogenannte Object Space, müssen nun in Koordinaten unseres Weltsystems, dem World Space, übersetzt werden. Hier kommt die World Matrix ins Spiel. Sie enthält die Information, wo und wie ein Objekt in der Szene dargestellt wird. Stichwort: SRT! Jedes 3D Objekt hat dabei eine eigene World Matrix, die auch veränderlich ist (zum Beispiel, wenn sich ein Objekt durch den Raum bewegt), daher ist die World Matrix nicht Teil der Kamera.
- Die View Matrix entspricht dem Auge und Sichtfeld des Spielers auf die Welt und enthält somit die Position und Orientierung der Kamera. Sie muss daher ständig aktualisiert werden. Sie ist daher wichtigster Teil jeder Kamera.
- Die Projection Matrix enthält perspektivische Informationen und wird nur einmal erzeugt. Dabei unterscheidet man zwischen perspektivisch und orthogonal, je nachdem, ob die Entfernung des Objekts von der Kamera eine Auswirkung auf die Skallierung haben soll, oder nicht. Da ein Spiel mehrere Kameras mit unterschiedlichen Projektionen haben kann, ist auch die Projection Matrix Teil der Kamera.
Für die Implementierung unserer 2D Kamera kommt uns der mächtige SpriteBatch zur Hilfe. Dieser erlaubt es in der Begin() Methode eine View Matrix mitzugeben, was für unsere Einsatzwecke auch völlig ausreicht. Nachfolgend der SpriteBatch Aufruf, dem die View Matrix unserer Kamera übergeben wird.
spriteBatchGame.Begin(
SpriteSortMode.BackToFront,
null,
null,
null,
null,
null,
camera.View);
Da wir auch zoomen und rotieren unterstützen wollen, fügen wir diese Transformationen noch in unserer Implementierung ein. Auch hier ist natürlich die Reihenfolge der Transformationen wichtig. Die View Matrix funktioniert dabei einfach gesagt genau umgekehrt wie eine World Matrix. Während diese nämlich versucht, Objekte im Raum richtig zu positionieren, wird mit der View Matrix der Raum positioniert. Sprich, anstatt die Kamera zu verschieben, verschieben wir die Welt. Dadurch dreht sich die Reihenfolge um, SRT wird zu TRS!
Wir implementieren eine View Matrix, wir müssen also folgende Schritte der Reihe nach durchführen:
- Das Sichtfeld auf die gewünschten Koordinaten verschieben (Translation)
- Danach soll die Kamera rotiert werden (Rotation)
- Der Zoom soll angewendet werden (Skalierung)
- Optional: Je nach Geschmack kann die Kamera nun noch in die Mitte des Bildes verschoben werden (Translation). Macht man das nicht, entspricht die Position der Kamera der linken, oberen Ecke, was nicht jedem intuitiv erscheint.
Zusätzlich wollen wir auch eine Methode haben, um herauszufinden, ob ein Punkt gerade im “Sichtfeld” der Kamera ist. Dazu bauen wir IsInView() in unsere Kamera ein, der wir die Koordinaten übergeben. Nachfolgend der komplette Code unserer Implementierung:
public class Camera2D
{
public float Zoom;
public float Rotation;
public Vector2 Position;
Viewport viewPort;
Vector3 viewPortCenter;
public Camera2D(Game game)
{
Zoom = 1.0f;
Rotation = 0.0f;
Position = Vector2.Zero;
viewPort = game.GraphicsDevice.Viewport;
viewPortCenter = new Vector3(viewPort.Width * 0.5f, viewPort.Height * 0.5f, 0);
}
public void Move(Vector2 amount)
{
Position += amount;
}
public Matrix View
{
get
{
var transforms = Matrix.CreateTranslation(new Vector3(-Position.X, -Position.Y, 0)) *
Matrix.CreateRotationZ(Rotation) *
Matrix.CreateScale(new Vector3(Zoom, Zoom, 1));
var focusPoint = Matrix.CreateTranslation(viewPortCenter);
return transforms * focusPoint;
}
}
public bool IsInView(int x, int y)
{
var zoomWidth = viewPort.Width * (1 / Zoom) * 1.2;
var zoomHeight = viewPort.Height * (1 / Zoom) * 1.2;
Rectangle rect = new Rectangle(
(int)(Position.X – (zoomWidth * 0.5)),
(int)(Position.Y – (zoomHeight * 0.5)),
(int)zoomWidth,
(int)zoomHeight);
return rect.Contains(x, y);
}
}
Benutzereingaben verarbeiten
Unser Spiel wird nur dann richtig Spaß machen, wenn der Benutzer auch in das Geschehen eingreifen kann. Und üblicherweise geschieht das über die Eingabe von Kommandos per Tastatur, Maus oder anderen peripheren Eingabegeräten. Yeah… No sh*t, Sherlock! Wir werden unseren Programmcode erstmal auf die Verarbeitung von Tastatur- und Mauseingaben beschränken.
Praktischerweise bietet das XNA Framework die statischen Klassen Keyboard und Mouse an. Die statische Methode GetState() beider Klassen, liefert dabei jeweils ein KeyboardState beziehungsweise ein MouseState Objekt über die die gedrückten Tasten und die Position der Maus abgefragt werden können.
Bei der Abfrage von Tasteneingaben muss dabei unterschieden werden, ob nur gedrückt oder durchgehend gehalten wird. So ist das Bewegen in eine bestimmte Richtung ein kontinuierlicher Vorgang und eine Abfrage, ob die entsprechende Taste gerade gedrückt wird, ist ausreichend. Im Gegensatz dazu sollte das Abfeuern eines Projektils nicht kontinuierlich ausgeführt werden, sondern nur einmal pro Tastendruck.
Um diese Unterscheidung zu ermöglichen, muss der letzte State der Eingabegeräte gespeichert werden. Im nachfolgenden Durchlauf wird ein Tastendruck nur dann als einmaliges Ereignis gewertet, wenn diese im gespeicherten State nicht gedrückt wurde.
Um diese Arbeit zu erledigen, schreiben wir uns eine einfache Komponente, einen InputHandler. Dieser bietet die beschriebene Funktionalität und wird allen anderen Komponenten über die GameServices zugänglich gemacht und zu den Game Komponenten hinzugefügt
public interface IInputManager
{
bool IsKeyPressed(Keys key);
bool IsKeyDown(Keys key);
}
public sealed class InputHandler : GameComponent, IInputManager
{
public InputHandler(Game game)
: base(game)
{
}
// assigned in Update() call
KeyboardState lastState;
/// <summary>
/// Überprüft ob eine Taste gedrückt worden ist.
/// </summary>
public bool IsKeyPressed(Keys key)
{
Keys[] lastKeys = this.lastState.GetPressedKeys();
return IsKeyDown(key) && !lastKeys.Contains(key);
}
/// <summary>
/// Überprüft, ob eine Taste momentan gedrückt ist.
/// </summary>
public bool IsKeyDown(Keys key)
{
KeyboardState currentState = Keyboard.GetState();
Keys[] currentKeys = currentState.GetPressedKeys();
return currentKeys.Contains(key);
}
public override void Update(GameTime gameTime)
{
lastState = Keyboard.GetState();
}
}
.
Wir wollen nun unsere Kamera mit Hilfe von Tasteneingaben steuern. Dazu fragen wir die entsprechenden States der Eingabegeräte ab, und passen die Werte im Update() Aufruf an:
public override void Update(GameTime gameTime)
{
base.Update(gameTime);
// Move Left!
if (InputManager.IsKeyDown(Keys.A))
camera.Move(new Vector2(5, 0));
// Move Right!
if (InputManager.IsKeyDown(Keys.D))
camera.Move(new Vector2(-5, 0));
// Move Down!
if (InputManager.IsKeyDown(Keys.S))
camera.Move(new Vector2(0, -5));
// Move Up!
if (InputManager.IsKeyDown(Keys.W))
camera.Move(new Vector2(0, 5));
// Zoom In
if (InputManager.IsKeyPressed(Keys.PageUp))
camera.Zoom += 0.05f;
// Zoom Out
if (InputManager.IsKeyPressed(Keys.PageDown))
camera.Zoom -= 0.05f;
// Rotate Counter Clockwise
if (InputManager.IsKeyDown(Keys.J))
camera.Rotation -= 0.01f;
// Rotate Clockwise
if (InputManager.IsKeyDown(Keys.K))
camera.Rotation += 0.01f;
}
XNA at its best: Content Pipeline
Neben dem Programmcode für die allgemeine Logik benötigt ein Spiel meistens eine sehr umfangreiche Bibliothek an zusätzlichen Ressourcen. Um mit diesen Ressourcen arbeiten zu können, müssen diese importiert und in entsprechende XNA Objekte konvertiert werden. Diese sehr oft äußerst aufwändige Aufgabe wird im XNA Framework von der mächtigen Content Pipeline übernommen, eines der großen Features von XNA.
Die Content Pipeline verfolgt eine zweistufige Vorgehensweise. Die erste Stufe wird zum Zeitpunkt des Kompilierens, also nur auf den Entwicklungsrechnern, durchgeführt. In dieser Stufe werden die Quelldateien importiert und verarbeitet. Das Ergebnis wird dann in einer neuen Datei abgelegt und mit dem fertigen Spiel mitausgeliefert. Die zweite Stufe findet dann zur Laufzeit statt. Dabei werden diese Dateien eingelesen und in die entsprechende XNA Objekte abgelegt.
Zu den out-of-the-box unterstützten Formaten gehören:
- Sounds und Musikdateien: XAP, WAV, MP3, WMA
- Texturen: DDS, BMP, JPG, PNG, TGA
- XML Dateien
- 3D Objekte: X, FBX
Ressourcen werden dabei im Content Ordner des Projekts abgelegt. Um Ressourcen in das Project einzubinden, klickt man im Solution Explorer rechts auf den Content Ordner und wählt dann Add / Existing Item. Danach wählt man die gewünschte Datei aus.
Pro Tipp: Es empfiehlt sich diesen Ordner nach Kategorien in Unterordner hierarchisch einzuteilen, um bei vielen Ressourcen den Überblick behalten zu können.
Importierte Ressourcen bekommen automatisch einen Namen zugewiesen. Über diesen Literal können diese dann im Code abgegriffen werden. Die entsprechende Möglichkeit liefert wieder die Game Instanz, welche über die Eigenschaft Content einen ContentManager zur Verfügung stellt. Nachfolgendes Codesnippet demonstriert exemplarisch das Arbeiten mit dem ContentManager innerhalb der LoadContent() Methode einer Komponente.
Texture2D texture;
SpriteFont font;
Model model;
protected override void LoadContent()
{
// Load a texture, a font and a model
this.texture = this.Game.Content.Load<Texture2D>(“wall”);
this.font = this.Game.Content.Load<SpriteFont>(“my_arial”);
this.model = this.Game.Content.Load<Model>(“big_dude”);
}
Mach mir hier (k)eine Szene!
Schlechte Puns reloaded, aber glaubt es mir: diese Kapitelüberschriften zu finden ist manchmal recht schwierig, es sei mir also verziehen Screen- und Szenenmanagement sind wichtiger Bestandteil einer Spieleengine. Ein Spiel kann aus mehreren Screens bestehen, beispielsweise einem Start Screen, Optionen, Highscore Liste, Spielschirm, Pause und so weiter. Diese wollen natürlich verwaltet werden und dazu werden wir uns nun einen ScreenManager implementieren, der solche GameScreens verwalten kann.
Für den GameScreen schreiben wir uns eine einfache Klasse, die eine Liste von Komponenten enthält, welche beim Aufruf von Draw() und Update() der Szene aktualisiert werden. Außerdem stellt diese noch den InputManager, den GameManager (der unsere Spieldaten verwaltet) und den ScreenManager zur Verfügung. Jeder Screen leitet nun von dieser Basisklasse ab und implementiert in den Draw() und Update() Aufrufen das gewünschte Verhalten.
public abstract class GameScreen : DrawableGameComponent
{
protected IScreenManager ScreenManager { get; private set; }
protected IGameManager GameManager { get; private set; }
protected IInputManager InputManager { get; private set; }
public List<GameComponent> Components { get; private set; }
public bool IsInitialized { get; private set; }
public GameScreen(Game game)
: base(game)
{
Components = new List<GameComponent>();
ScreenManager = Game.Services.GetService<IScreenManager>();
GameManager = Game.Services.GetService<IGameManager>();
InputManager = Game.Services.GetService<IInputManager>();
}
public void Show()
{
if (!IsInitialized)
Initialize();
Enabled = true;
Visible = true;
}
public void Hide()
{
Enabled = false;
Visible = false;
}
public void Pause()
{
Enabled = false;
Visible = true;
}
public override void Update(GameTime gameTime)
{
if (!Enabled) return;
// wir rufen Update() auf allen Komponenten auf, die Enabled sind.
foreach (var component in Components
.Where(x => x.Enabled))
{
component.Update(gameTime);
}
base.Update(gameTime);
}
public override void Draw(GameTime gameTime)
{
if (!Visible) return;
// wir rufen Draw() auf allen Komponenten auf, die DrawableGameComponents und Visible sind
foreach (var component in Components
.OfType<DrawableGameComponent>()
.Where(x => x.Visible))
{
component.Draw(gameTime);
}
base.Draw(gameTime);
}
}
Nun implementieren wir uns einen ScreenManager, der die aktiven Screens kennt und deren Lifecycle verwaltet. Für den Anfang verwaltet der ScreenManager immer nur einen einzigen aktiven Screen, in einer späteren Ausbaustufe ist es natürlich denkbar, hier einen Stapel an Screens zuzulassen. So könnte zum Beispiell ein Spiel pausiert werden, und auf dem Pause Schirm auch der Highscore angezeigt werden. Das wären dann eigentlich drei übereinanderliegende Screens: Play, Pause und Highscore. Auch Übergänge zwischen den Screens, wie schöne Fade Outs, wären möglich. In unserer ersten Implementierung verwirft die PushScreen<T>() Methode des ScreenManagers allerdings den aktiven Screen und zeigt den neuen sofort an.
public class ScreenManager : DrawableGameComponent, IScreenManager
{
public ScreenManager(Game game)
: base(game)
{
Screens = new List<GameScreen>();
}
/// <summary>
/// Die Auflistung aller Szenen
/// </summary>
public List<GameScreen> Screens { get; private set; }
/// <summary>
/// Fügt einen neuen Screen vom generischen Typ T hinzu
/// </summary>
public void PushScreen<T>()
where T : GameScreen
{
// Die Screenliste wird geleert
Screens.Clear();
// Eine neue Instanz vom Typ T wird erzeugt und dem Konstruktor das aktuelle Game objekt übergeben.
T screen = (T)Activator.CreateInstance(typeof(T), Game);
Screens.Add(screen);
screen.Show();
}
public override void Update(GameTime gameTime)
{
// wir iterieren über das Array, da sich die Auflistung verändern kann
foreach (var item in Screens.ToArray())
item.Update(gameTime);
}
public override void Draw(GameTime gameTime)
{
// wir iterieren über das Array, da sich die Auflistung verändern kann
foreach (var item in Screens.ToArray())
item.Draw(gameTime);
}
}
Der ScreenManager wird über das Interface IScreenManager als Service zur Verfügung gestellt und zu den Komponenten hinzugefügt. Das Interface bietet momentan nur die generische Methode PushScreen<T>() an, die den aktiven Screen durch einen neuen von diesem Typ ersetzt.
public interface IScreenManager
{
void PushScreen<T>() where T : GameScreen;
}
Der Sourcecode enthält die Implementierung von drei Screens: Start, Loading und Play. Als kleine Fingerübung könnte man andenken, den Playschirm so umzubauen, dass bei einem Druck auf Escape zurück auf den Startschirm gesprungen wird. Zur Zeit wird das Spiel bei Escape beendet.
Der Code in der Game Klasse hat sich durch die Auslagerung in Services und Komponenten also mittlerweile entscheidend reduziert.
public class FistsOfFireGame : Microsoft.Xna.Framework.Game
{
GraphicsDeviceManager graphics;
public FistsOfFireGame()
{
graphics = new GraphicsDeviceManager(this);
Content.RootDirectory = “Content”;
graphics.PreferredBackBufferHeight = 800;
graphics.PreferredBackBufferWidth = 800;
ScreenManager sceneManager = new ScreenManager(this);
GameManager gameManager = new GameManager(this);
InputHandler inputHandler = new InputHandler(this);
Components.Add(sceneManager);
Components.Add(gameManager);
// der update des inputmanagers soll als letztes aufgerufen werden!
Components.Add(inputHandler);
Services.AddService<IScreenManager>(sceneManager);
Services.AddService<IGameManager>(gameManager);
Services.AddService<IInputManager>(inputHandler);
}
protected override void Initialize()
{
base.Initialize();
var screenManager = Services.GetService<IScreenManager>();
screenManager.PushScreen<StartScreen>();
}
}
Erste Optimierung: Terrain
Dieser Teil war bis jetzt gespickt mit Services und Managern. Doch ein letzter Manager bleibt noch offen: Der GameManager. Der GameManager ist zur Zeit nur sehr rudimentär, wird aber in Zukunft wichtiger Bestandteil des Spiels. Er wird die Game Logik enthalten und relevaten Infos zum Spieler, den Gegner und der Spielwelt verwalten. Zur Zeit hat der GameManager allerdings nur eine Aufgabe: Er erzeugt das Terrain.
public class GameManager : DrawableGameComponent, IGameManager
{
Texture2D texture;
SpriteFont font;
public GameManager(Game game)
: base(game)
{
}
protected override void LoadContent()
{
base.LoadContent();
texture = Game.Content.Load<Texture2D>(“whiteQuad”);
font = Game.Content.Load<SpriteFont>(“sysFont”);
}
public Terrain Terrain { get; private set; }
public void BuildTerrain()
{
Terrain = new Terrain();
Terrain.BuildMap(Game.GraphicsDevice);
}
}
Im letzten Teil haben wir das Terrain noch Textur für Textur in mehreren for Schleifen Durchläufen gerendert. Dies in jedem Draw() Aufruf zu machen, verbraucht natürlich kostbare CPU Zeit, die wir in Zukunft woanders dringender benötigen werden. Aus diesem Grund lassen wir den GameManager einmalig eine große Textur bestehend aus den Teiltexturen des Terrains erzeugen und zurückgeben.
void BuildCompleteTexture(GraphicsDevice device)
{
int dim = gridSize * tileSize;
// Wir erzeugen eine blanke Textur in der Größe des Applikationsfensters
Texture2D map = new Texture2D(device, dim, dim);
for (int row = 0; row < gridSize; row++)
{
for (int col = 0; col < gridSize; col++)
{
// wir holen uns die Farbe des Terrains
byte terrainType = (byte)noiseMap.GetMapValue(row, col);
// definieren ein Rechteck in der richtigen Größe
Rectangle rect = new Rectangle(row * tileSize, col * tileSize, tileSize, tileSize);
Color color = TileMap[row, col];
Color[] arr = new Color[tileSize * tileSize];
for (int i = 0; i < tileSize * tileSize; i++)
arr[i] = color;
// und zeichnen die Farbe in der Größe des Rechtecks sie an die richtige Stelle auf der blanken Textur
map.SetData<Color>(0, rect, arr, 0, tileSize * tileSize);
}
}
Texture = map;
}
Pro Tipp: Für das HiDef Profil von XNA gilt eine Beschränkung auf die Texturgröße von 4096*4096 Pixeln. Natürlich spricht aber nichts dagegen, einfach mehrere Texturen zu erzeugen und danach mit kombinierten SpriteBatch Aufrufen zusammen zu stückeln.
Next Up: Needs moar Particles!!1
In den nächsten Teilen kombinieren wir das bisher gelernte und lassen unseren Magier (oder zumindest einen ersten Grobentwurf) durch die Welt marschieren. Dabei gibt es noch das eine oder andere zu beachten. Wir sehen uns außerdem noch Möglichkeiten an, wie wir das Terrain etwas ansprechender rendern können. Danach steht die Entwicklung eines sehr einfachen Partikelsystems am Plan, denn wir wissen schließlich: Partikel machen alles besser! Für die Theorie sorgt ein bisschen Baumkunde zu Octrees und Quadtrees! Exciting! Mal sehen, was sich davon nun alles im nächsten Teil ausgehen wird!
Hinterlasse eine Antwort