XNA-Sample: MoonTaxi (Teil 3/3)

Veröffentlicht: 30 Dezember 2012 in Microsoft, Visual Studio, XNA
Schlagwörter:
Kleiner Nachtrag vom 02.03.2015: In der Zwischenzeit gibt es auch eine kleine Video-Reihe zum Spiel. Leider nicht Deckungsgleich mit dem Code und der Vorgehensweise, aber trotzdem mit dem selben Konzept.

Hohoho! An dieser Stelle startet Teil 3 des mittlerweile echt groß gewordenen Tutorials zu MoonTaxi. Die bisherigen Teile haben dir gezeigt, wie man Grafiken lädt und rendert, auf die Usereingabe reagiert und die Spielobjekte physikalisch korrekt bewegt. Außerdem wurde dir gezeigt, wie man einfache Kollisionsberechnung im 2D-Raum und rechteckigen Objekten durchführen kann. In einem kleinen Lade-Skript für Levels ist sogar das manuelle Editieren mit Notepad möglich. Am Ende der vorherigen Tutorials konnte der Spieler bereits durch das Level fliegen und an Hindernisse stoßen.

Wem die ersten beiden Tutorial-Teile noch fehlen, der kann sich der folgenden Links bedienen:

Was ich in Teil 3 zeigen will sind kleine Schönheitskorrekturen. Wir sammeln nämlich noch gar keine Fahrgäste ein. Danach möchte ich gerne mehrere Levels hinterlegen können, die bei Erfolg durchlaufen werden. Damit man dann eine Übersicht über den Spielfortschritt hat, fügen ich dann noch eine kleine Level- und Geschwindigkeitsanzeige im Spiel ein. Vielleicht schaffen wir auch noch ein paar Sounds und Musik einzufügen. Außerdem möchte ich gerne noch die Vibration des Controllers ansprechen, wenn eines der Triebwerke verwendet wird.

Schritt 1: Fahrgäste ein- und ausladen

Das Spiel macht zwar im aktuellen Stand schon recht viel Spaß, aber so ganz ohne Spielziel wird auch das bald recht öde. Das eigentliche Spielprinzip sieht ja vor, dass man Fahrgäste einsammelt und durch ein kompliziertes Level bis zum Zielort bringt.

Wie schaut die Dynamik beim ein- und ausladen von Fahrgästen aus? Ich würde das mal schnell damit zusammen fassen, dass das Taxi auf jeden Fall gelandet sein muss. Landet das Taxi in der Nähe eines Fahrgastes oder dem Ziel, so wird automatisch ein- oder ausgeladen.

Ob das Taxi den Fahrgast bereits eingeladen hat, müssen wir irgendwo speichern. Ein weiteres Feld in unserer Taxi-Klasse würde sich da anbieten. Aus diesem Grund füge doch dort einfach ein neues Feld vom Typ bool ein, das speichern soll, ob das Taxi den Fahrgast geladen hat.

public bool GuestLoaded { get; set; }

Natürlich muss auch diese Variable wieder in Reset zurück gesetzt werden, damit bei einem Neustart nicht plötzlich noch der Gast an Board bleibt.

Im nächsten Schritt ist die Interaktion mit der Umwelt zu implementieren. Am klügsten ist es, zuerst eine Prüfung darauf zu machen, ob das Taxi gelandet ist. Wenn das zutrifft, können wir die Entfernung zu dem Gast und dem Ziel berechnen (je nach Zustand des Taxis). Ist die Entfernung klein genug, dann wird der Gast ein-, bzw. ausgeladen.

Distanzberechnung

Abb. 1: Entfernungsberechnung zwischen zwei Punkten im Koordinatensystem.

Um die Entfernung der beiden Objekte zu berechnen, bedienen wir uns einfachen Vektor-Berechnungen. Abbildung 1 zeigt den schematischen Aufbau in einem Koordinatensystem. P1 repräsentiert hier das Taxi, P2 den Fahrgast und die blauen Vektoren sind gleichzusetzen mit den Positionen der beiden Objekte. Um nen den Vektor zwischen P1 und P2 zu ermitteln, muss man die beiden Vektoren einfach voneinander subtrahieren. Da uns nur die Länge des Vektors interessiert, spielt es keine Rolle welchen Vektor wir von welchem anderen Vektor subtrahieren. Legen wir also fest, dass wir immer P2 minus P1 berechnen und die Länge des resultierenden Vektors ermitteln.

Den folgenden Code fügen wir einfach dort ein, wo wir im zweiten Teil des Tutorials die Start-Sequenz platziert haben. Das war in der Update-Methode gleich unter der Deklaration der delta-Variablen.

// Abhebesequenz 
if (taxi.Landed) 
{ 
    if (forceY > 0) 
        taxi.Landed = false;

    // Gastbehandlung 
    Vector2 taxiPoint = taxi.Position + 
        new Vector2(Taxi.WIDTH / 2, Taxi.HEIGHT / 2); 
    if (taxi.GuestLoaded) 
    { 
        Vector2 goalPoint = level.GoalPoint * BLOCKSIZE + 
            new Vector2(BLOCKSIZE / 2, BLOCKSIZE / 2); 
        float distGoal = (taxiPoint - goalPoint).LengthSquared(); 
        if (distGoal < MAXPICKUPDISTANCE * MAXPICKUPDISTANCE) 
        { 
            // Nahe genug am Ziel - Level neu starten 
            Reset(); 
        } 
    } 
    else 
    { 
        Vector2 guestPoint = level.GuestPoint * BLOCKSIZE + 
            new Vector2(BLOCKSIZE / 2, BLOCKSIZE / 2); 
        float distGuest = (taxiPoint - guestPoint).LengthSquared(); 
        if (distGuest < MAXPICKUPDISTANCE * MAXPICKUPDISTANCE) 
        { 
            // Nahe genug am Gast - Gast aufnehmen 
            taxi.GuestLoaded = true; 
        } 
    } 
}

Im Code wurde hier schon wieder eine neue Konstante eingeführt (MAXPICKUPDISTANCE) die auch weiter oben noch initialisiert werden sollte. Ich habe das mit dem Wert 50f gemacht. Also 50 Pixel Entfernung zwischen Taximittelpunkt und dem Zielpunkt, bzw. Gast.

Damit wäre die Logik zum Aufsammeln von Fahrgästen geschrieben. In der Ansicht wird der Fahrgast aber immernoch gezeichnet, auch wenn er bereits im Taxi sitzt. Das lässt sich mit einer kleinen Bedingung in der Draw-Methode beheben. Vor die Zeile, die den Fahrgast malt, wird einfach die Bedingung “if (!taxi.GuestLoaded)” platziert.

Wir können jetzt also vollständig spielen. Das Taxi taucht auf, kann Fahrgäste einladen, gegen Levelgegenstände stoßen. Bei Erfolg wird das Level aber neu gestartet. Irgendwie unbefriedigend. Im nächsten Schritt führen wir einfach mal mehrere Levels ein. Die Level-Dateien dazu haben wir ja auch schon.

Schritt 2: Mehrere Levels

Um das zu realisieren gehört garnicht so viel. Das System, das ich gerne implementieren würde, basiert auf einem konsistenten Namenskonzept für Level-Dateien. Level 1 heißt “level1.txt”, Level 2 dann “level2.txt” usw.. Um diese Dateien jetzt richtig anzusprechen, verwende ich einfach einen Level-Zähler, der bei Erfolg eins nach oben zählt und damit das neue Level startet.

Die neue Variable “levelNumber” findet ihren Platz in der zentralen Game-Klasse und direkt auf 1 initialisiert. Bei Erfolgreichem Abschluss des Levels wird der Zähler einfach um eins erhöht und der Ladepfad für die Leveldatei in der Reset-Methode entsprechend modifiziert.

int levelNumber = 1;

if (distGoal < MAXPICKUPDISTANCE * MAXPICKUPDISTANCE) 
{ 
    // Nahe genug am Ziel - Level neu starten 
    levelNumber++; 
    Reset(); 
}

level = new Level(@"Levels\level" + levelNumber + ".txt");

Danach gehts rund! Nach einem erfolgreichen Level werden dynamisch neue Levels nachgeladen! jetzt liegt’s an dir auch genügend neue Levels bereit zu stellen!

Schritt 3: HUD

Da wir nun mehrere Levels haben und es auch Dinge gibt, auf die der Spieler achten muss, macht es langsam wirklich Sinn auch einen kleinen Head Up Display (kurz HUD) einzubauen. Ich verzichte dabei erstmal vollständig auf grafischen Schnickschnack. Es soll links oben einfach eine textbasierte Anzeige des aktuellen Levels und der aktuellen Taxi-Geschwindigkeit geben.

Grundsätzlich werden diese Anzeigen genauso gezeichnet wie der Rest des Spieles auch. Hintergrundgrafiken sind Texturen, die ganz am Ende über die ganze Szene gezeichnet werden. Erschreckenderweise gilt das auch für Schriftzeichen. Wer aus anderen Render-Technologien gewohnt ist einfach ein Label zu verwenden oder einen Befehl wie “print” zu verwenden, der muss etwas umdenken.

Schriften werden in Spielen genauso gezeichnet wie die restlichen Grafiken auch: aus Texturen heraus. Dazu muss die Schrift aber vorher in eine Textur umgewandelt werden. Üblicherweise stellt dazu ein Grafiker eine Textur zusammen, die alle benötigten Buchstaben enthält und dann von der Engine hübsch zu einem sinnvollen Satz/Wort zusammen gebastelt werden können. Man spricht hier von sog. Sprite Fonts.

Sprite Font

Abb. 2: Beispieltextur einer Sprite Font

Bei XNA muss man das zwar nicht mehr selbst machen, sollte aber wissen, was da im Hintergrund passiert. Möchte man für XNA Schrift verwenden, kann man das Ganze ohne Vorbereitung im Content-Ordner generieren. Ein Rechtsklick auf den Content-Ordner, Add, New Item erlaubt die Auswahl “Sprite Font”. Ich habe die daraufhin erstellte Datei einfach mal “moontaxi.spritefont” genannt. Macht man diese Datei mit einem Doppelklick auf, so stellt man fest, dass es sich dabei um eine XML-formatierte Textdatei handelt. Man stellt hier ein paar Rahmenbedingungen ein, unter denen die Textur erzeugt werden soll. Man legt Schriftart, Schriftgröße und Stil fest, anschließend, wenn man will, noch den Wertebereich der benötigt wird – das wars. Der Wertebereich reicht normalerweise über die ganzen Standard-Buchstaben. Leider fehlen noch Sonderzeichen wie z.B. das “Ä”. Ich verwende hier gerne die Schriftart “Verdana” in Größe 12.

Instanziert wird alles wieder über den Content-Loader, nachdem wir eine Variable vom Typ SpriteFont zur Verfügung gestellt haben. Wo passierts? Natürlich wieder in LoadContent.

SpriteFont font;

font = Content.Load<SpriteFont>(“moontaxi”);

Nun gehts ans Zeichnen. Interessant wäre eine Anzeige des aktuellen Levels. Außerdem möchte ich die aktuelle Taxi-Geschwindigkeit anzeigen. Diese Anzeige ist wichtig, da der Spieler vorsichtig auf der Plattform landen muss und dabei eine gewisse Geschwindigkeit nicht überschreiben darf. Aus diesem Grund soll die Geschwindigkeitsanzeige auch farbig darauf hinweisen, ob das Taxi gerade zu schnell zum landen ist. Grün soll bedeuten, dass die Geschwindigkeit passt, rot ist zu schnell.

Das HUD

Abb. 3: Simples HUD

Gezeichnet wird natürlich in der Draw-Methode. Da es sich um die Anzeige handelt, die immer sichtbar sein soll, wird sie auch ganz am Ende gezeichnet, damit sie wirklich über allen restlichen Objekten gezeichnet wird. Darum wird unser HUD-Zeichenblock auch unter den Zeichenblock für das Taxi platziert.

Der Spritebatch, den wir bislang zum Zeichnen unserer Texturen verwendet haben, verfügt auch über eine Methode zum Zeichnen von Zeichenketten: “DrawString”. Diese verwenden wir auch zum erstellen unserer HUD-Texte. Die erste Zeile soll das Label “Level:” enthalten und anschließend die Nummer des aktuellen Levels ausgeben.

Zeile 2 wird etwas komplizierter, da sie aus 2 Zeichenketten zusammengestellt wird. Die Schwierigkeit hier ist nämlich die Position des hinteren, eingefärbten Teils der Zeile zu ermitteln, da die Breite der ersten Hälfte nicht so ganz klar ist. Zu unserem Glück stellt uns die SpriteFont eine Methode zur Verfügung, die uns erlaubt, die Länge von Zeichenketten zu ermitteln. Mit font.MeasureString(“”) erhalte ich einen Vector2, der die Ausmaße der angegebenen Zeichenkette enthält und kann somit vorher schon berechnen, wo ich mit der zweiten Zeichenkette anknüpfen kann. In unserem Fall könnte man einfach durch herumexperimentieren die Position der zweiten Zeichenkette ermitteln, aber manchmal ist die Länge der Zeichenketten natürlich variabel, dann wird das schon schwieriger.

// HUD zeichnen 
spriteBatch.DrawString(font, "Level: " + levelNumber, 
    new Vector2(10, 10), Color.White); 
Vector2 speedSize = font.MeasureString("Speed:"); 
float taxiSpeed = taxi.Speed.Length(); 
spriteBatch.DrawString(font, "Speed:", new Vector2(10, 30), Color.White); 
spriteBatch.DrawString(font, taxiSpeed.ToString("0") + " px/s", 
    new Vector2(speedSize.X + 15, 30), 
    taxiSpeed > MAXLANDINGSPEED ? Color.Red : Color.Green);

Schritt 4: Sounds und Musik

Das Spiel läuft und der Spieler kann schon richtig Spaß haben. Alles in allem ist das Spiel aber noch recht ruhig, dafür, dass wir hier ein Taxi mit Raketenantrieb haben! Da fehlt ganz klar noch Sound und Musik.

XNA kennt eine Reihe von Möglichkeiten auf Sounds zuzugreifen. Glücklicherweise sieht die API dazu immer gleich aus. Zur Verarbeitung von Audio stehen die beiden Namespaces Microsoft.Xna.Framework.Media und Microsoft.Xna.Framework.Audio zur Verfügung.

Beginnen wir am besten damit, dem Taxi Raketensounds zu verpassen. Ich habe dazu ein passendes Geräusch erzeugt und dem Projekt beigelegt (jet.wav). Mit XNA kann man nun sehr abgefahrene Sound-Manipulationen machen, indem man ein mitgeliefertes Werkzeug (XACT) verwendet und die verwendeten Sounds dort importiert und nachbearbeitet. In diesem Tutorial werde ich einen sehr simplen weg ohne dieses Tool gehen. Wer sich also über komplexere Sound-Arbeiten informieren will, ist mit dem Suchwort XACT gut bedient.

In diesem Tutorial werden wir allerdings einfach nur den Raketensound laden und im Loop abspielen, sobald die Triebwerke benutzt werden. Die Handhabung unterscheidet sich von den Texturen kaum. Den Sound kann man auch einfach in den Content-Ordner legen und per Content.Load laden. Datentyp eines Sounds ist SoundEffect. Der Soundeffect selbst ist aber nur die Soundvorlage. Um das Ding abspielen zu können, benötigt man eine Instanz dieses Sounds für jede Stimme, die parallel gestartet werden soll. Da wir unseren Sound aber nur einstimmig starten, reicht uns eine einzelne Instanz, die wir auch bereits im LoadContent-Block erstellen und in einer Klassenweiten Variable “jetSoundInstance” speichern

SoundEffect jetSound; 
SoundEffectInstance jetSoundInstance;

jetSound = Content.Load<SoundEffect>("jet"); 
jetSoundInstance = jetSound.CreateInstance();

Diese eine Instanz kann jetzt beliebig gestartet, pausiert und gestoppt werden. Nachträglich lassen sich dann jederzeit Lautstärke, Balance und sogar das Pitching nachregeln. Sogar das Anwenden von 3D-Parametern ist möglich. Wir verwenden hier aber nur eine Sache: an und aus 🙂

Da der Status des Geräusches vollständig vom Status der Usereingabe abhängt, platziere ich die Steuerung direkt an die Stelle in Update, an der wir die Controller- Tastatur-Eingabe abgegriffen haben. Also kurz bevor wir mit der Berechnung der Bewegungsänderung beginnen. Ist eine der beiden Variablen “forceX” oder “forceY” ungleich 0, so bedeutet das, dass der Spieler gerade in irgend eine Richtung steuert und somit Jets benutzt. Solange also eine der beiden nicht 0 ist, wird der Sound gespielt, ansonsten gestoppt. XNA ignoriert einen “Play”-Aufruf, wenn der Sound schon abgespielt wird. Wir müssen also nicht vorher noch den Status prüfen.

// Jet-Sound 
if (forceX != 0f || forceY != 0f) 
{ 
    // Sound muss an sein 
    jetSoundInstance.Play(); 
} 
else 
{ 
    // Sound muss aus sein 
    jetSoundInstance.Stop(); 
}

Das ist auch schon der ganze Zauber. So einfach ist es, einfache Sounds abzuspielen. Mit Musik geht das grundsätzlich genau so. Einfach den Soundtrack in den Content-Ordner werfen (gerne auch im MP3-Format), den Inhalt vom Typ “Song” laden und abspielen. Etwas galanter wäre es aber doch, wenn das Spiel einen beliebigen Song aus der Media-Library des Media-Players – oder auf der XBox der Musiksammlung – abspielen würde. Oder?

Da kommt der Namespace Media ins Spiel. Dieser ist nämlich genau für solche Zwecke da: Der Zugriff auf die eigene Musik-, Video- und Bilder-Bibliothek. In diesem Beispiel möchte ich einen Zufälligen Song aus der Bibliothek abspielen. Aus diesem Grund benötigen wir zwei weitere Variablen innerhalb der Game-Klasse. Eine, um einen Verweis auf die Bibliothek zu speichern. Eine andere, um den aktuellen Song zu halten.

MediaLibrary library = new MediaLibrary(); 
Song music;

Die Bibliothek wird direkt instanziert. Der Verweis auf den Song soll sich allerdings mit jedem Level-Start neu auswählen. Das passiert am besten in der Reset-Methode, weil wir dort bei jedem Neustart eines Levels vorbei kommen – egal ob eine Kollision stattgefunden oder der Spieler das Level erfolgreich beendet hat. Dort habe ich einen Zufallsgenerator erstellt, der aus den vorhandenen Songs einen auswählen soll. die Library erlaubt uns den Zugriff auf eine Auflistung aller Songs über ihr Member “Songs”. Die Songs lassen sich über einen Index abrufen.

Sobald wir einen Song haben, kann der direkt über die statische Klasse “MediaPlayer” abgespielt werden. Songs, die nicht aus der Library kommen sondern aus dem Content-Ordner geladen werden, werden auf dem selben Weg abgespielt – einfach über den MediaPlayer abspielen.

// Musik abspielen 
Random rand = new Random(); 
music = library.Songs[rand.Next(0, library.Songs.Count)]; 
MediaPlayer.Play(music);

Und schon hat unser Spiel sowohl Sounds, als auch Hintergrund-Musik und das Spielen macht gleich doppelt so viel Spaß!

Schritt 5: Jet-Flammen und winkende Gäste (Textur-Akrobatik)

Das Taxi macht ja schon ne Menge krach, wenn es Gebrauch von seinen Jets macht. Sehen kann man aber leider noch nicht so viel. Das sollte sich doch bitteschön noch ändern. Anhand dieses kleinen Beispiels würde ich auch gleich zeigen wollen, wie man weiterhin mit Texturen umgehen kann.

Als kleine Finger-Übung fangen wir an mit einem animierten Taxi-Gast. Im Moment steht dieser nur statisch da und wartet auf das Taxi. In Zukunft soll dieser aber im Sekundentalk winken. Das realisieren wir mit einer Art Daumenkino für 2D-Flächen. Der Trick ist nämlich für jeden Animationsschritt eine eigene Textur zu haben. Damit da der Ladevorgang aber nicht zu Umfangreich wird, macht man der Einfachheit halber alle Animationsschritte in eine einzige Textur. Diese Textur habe ich unter dem Namen “guest2.png” vorbereitet.

Der Fahrgast

Abb. 4: 2 Frames für eine Animation

In dieser Textur sind nun plötzlich 2 Taxigäste zu finden. Und ersetzt man einfach die erste Textur, so tauchen auch im Spiel plötzlich 2 Gäste auf. Nicht gut. Was wir jetzt nämlich machen müssen, ist den Bereich innerhalb der Textur, der tatsächlich gezeichnet wird, weiter einzuschränken. Mit der Standard-Überladung von Draw wird die komplette Textur gezeichnet. Es existiert aber auch eine, die die Angabe eines SourceRectangles erlaubt. Das brauchen wir. Mit diesem Rectangle lässt sich ein Bereich innerhalb der Textur benennen, die anschließend auf dem Bildschirm landet. Wir ersetzen also den Codeabschnitt zum Zeichnen des Gastes durch folgenden Code.

// Code mit Animation (Ab Schritt 5) 
int frame = gameTime.TotalGameTime.Seconds % 2; 
spriteBatch.Draw(guestTexture, 
    new Vector2(x * BLOCKSIZE, y * BLOCKSIZE), 
    new Rectangle(frame * BLOCKSIZE, 0, BLOCKSIZE, BLOCKSIZE), Color.White);

in der ersten Zeile bestimmen wir den Animationsframe in Abhängigkeit der Spielzeit. Modulo ermittelt mir hier eine Zahl zwischen 0 und 1, im Sekundentakt wechselnd. Weiter unten verwende ich den Wert dann im Source Rectangle, um einmal von Position 0 oder von Position 20 jeweils ein Rechteck von 20×20 auszuschneiden und zu zeichnen.

Etwas komplizierter wird’s mit dem Triebwerksstrahl. Zur Verfügung steht uns nur ein einziges Flammenbild. Daraus sollen die animierten Triebwerksstrahlen für alle Seiten gemalt werden. Außerdem soll die Flamme in Abhängigkeit der Schubkraft ihre Größe verändern.

Als erste fange ich damit an neue Felder in die Taxi-Klasse zu bauen. Die Kraft der Triebwerke ermitteln wir nämlich in Update, zeichnen werden wir aber in Draw. Wir müssen also die Infos irgendwo zwischen speichern. Da das eine Eigenschaft des Taxis ist, ist auch dort der geeignete Platz das zu speichern. Unsere Taxi-Klasse bekommt 3 neue Member.

public float JetLeft { get; set; } 
public float JetRight { get; set; } 
public float JetBottom { get; set; }

Ermittelt werden die Werte genau da wo wir sie auch berechnen. In der Update-Methode. Dort, wo wir eben auch die Sounds entsprechend abgespielt haben.

// Jet-Settings 
taxi.JetLeft = Math.Max(forceX, 0f); 
taxi.JetRight = Math.Abs(Math.Min(forceX, 0f)); 
taxi.JetBottom = Math.Max(forceY, 0f);

Anschließend kann für jeden Jet einzeln die Stärke ermittelt werden. Mit Hilfe der Math.Min/Max-Methoden lässt sich das schnell aus den force-Floats heraus ermitteln. Auf zur Draw-Methode. Hier müssen wir jetzt irgendwie aus einem einzigen Feuerbildchen eine Animation erzeugen und gleichzeitig auch an 3 verschiedene Treibwerke malen. Ein perfektes Beispiel um die Möglichkeiten der Draw-Methode zu zeigen.

In unserem Fall besteht das Bild aus einem nahezu achsensymethrischen Bild einer Flamme. Damit es nach einer kleinen Animation aussieht, spiegeln wir das Bild einfach alle 1/10 Sekunden. Welches Bild gerade dran ist, wird auf dem selben Weg ermittelt wie wir das eben schon beim Fahrgast gemacht haben. Einziger Unterschied: Wir nehmen Millisekunden (1000 pro Sekunde) und dividieren den Wert aber nochmal durch 100. Damit ergeben sich zehntel Sekunden, die zusammen mit dem Modulo 2 wieder einen Flip pro zehntel Sekunde ergeben.

Animation durch Spiegelung

Abb. 5: Spiegeln mit negativen Werten.

Um die Textur jetzt gespiegelt darzustellen, bedienen wir uns einem mathematischen Trick. Ähnlich wie beim Fahrgast verwenden wir Source Rectangle, um den Quellbereich des Bildes auszuwählen. Indem wir aber das ausschneidende Rechteck nicht nach rechts, sondern nach links spannen, wir die Textur anschließend umgedreht dargestellt. Abbildung 5 zeigt die zwei Varianten. Oben wir der Startpunkt 0/0 verwendet und in positiver Richtung aufgespannt (10/10) und die Grafik wird wieder genau so dargestellt, wie sie in der Textur hinterlegt ist. Beispiel 2 (unten) zeigt das Resultat, wenn man den Ursprung bei 10/0 platziert und das Rechteck in den Negativen Bereich aufspannt (-10/10). Das Ergebnis ist eine achsengespiegelte Version der Textur.

Eine solche Achsenspiegelung kann der Sprite Batch auch von Haus aus mit den SpriteEffects. Aber ich wollte das gerne von Hand machen, weil man mit solchen Tricks auch später noch im 3D-Raum für interessante Effekte sorgen kann und man das unbedingt mal von Hand gemacht haben sollte.

Fehlt uns noch die Rotation der Textur. Für den Strahl unten muss sich nichts ändern. Die Strahle rechts und links müssen aber jeweils um 90 Grad rotiert werden. Dazu gibt man bei der Methode einen Rotationspunkt an (wir nehmen hier erstmal 0/0) und einen Rotationswinkel.

Rotation von Texturen

Abb. 6: Rotation von Texturen

Der Kringel in Abbildung 6 symbolisiert die Drehachse um die das Bild im angegebenen Winkel rotiert wird. Die Angabe wird nicht in Grad, sondern im Bogenmaß angegeben. Eine 90 Grad-Rotation im Uhrzeigersinn ist also Pi/2. MathHelper liefer dafür sogar die Konstante PiOver2.

Als letzte Modifikation manipulieren wir jetzt noch die Flammengröße. Je stärker der Gamepad-Knüppel gedrückt wird, umso stärker ist der Schub der Düse. Entsprechend soll auch der Feuerstrahl skaliert werden. Volle Kraft, also 1, soll die Flamme in voller Größe anzeigen, mit halber Stärke aber bitte nur mit halber Höhe. Skalierungen dieser Art werden auch über das Source Rectangle und Destination Rectangle umgesetzt. In diesem Fall wird das Ziel-Rechteck einfach etwas flacher gezeichnet als das Quell-Rechteck. Die Textur wird dabei nicht, wie vielleicht erwartet, abgeschnitten, sondern einfach getaucht.

Skalierung von Texturen

Abb. 7: Verzerrung der Flammen-Textur.

Indem man beim Quell-Rechteck eine Höhe von 20 angibt, das Ziel-Rechteck aber nur mit 10 Pixel Höhe angibt, verursacht man eine Verzerrung der Textur. Will man die Textur lieber abschneiden, muss man natürlich das Quell-Rechteck entsprechend auf den Bereich beschränken, den man ausgeschnitten haben möchte.

Alles in allem lassen sich diese Bildmanipulationen in einer einzigen Zeile Code pro Triebwerksstrahl verpacken. Hier beispielhaft aufgelistet die Zeile Code, die den linken Triebwerksstrahl zeichnet. Sie wird unmittelbar vor der Taxitextur gezeichnet, damit sie zwar hinter dem Taxi liegt, aber vor allen anderen Spielelementen gezeichnet wird. Wir benötigen noch eine neue Konstante “FLAMESIZE”, die die Texturausmaße der Flamme angibt. Außerdem muss auch hier wieder über die Game-Zeit und einem Modulo der aktuelle Animationsframe ermittelt werden.

public const int FLAMESIZE = 10;

int flameFrame = (gameTime.TotalGameTime.Milliseconds / 100) % 2;

spriteBatch.Draw(flame, 
    new Rectangle( 
        (int)(taxi.Position.X), 
        (int)(taxi.Position.Y + 10), 
        FLAMESIZE, 
        (int)(taxi.JetLeft * FLAMESIZE)), 
    new Rectangle( 
        flameFrame == 0 ? 0 : FLAMESIZE, 
        0, 
        flameFrame == 0 ? FLAMESIZE : -FLAMESIZE, 
        FLAMESIZE), 
    Color.White, 
    MathHelper.PiOver2, 
    Vector2.Zero, 
    SpriteEffects.None, 
    0f);

Die Parameter von spriteBatch.Draw sind teilweise bekannt. Als erstes erwartet diese Methode immer die Textur, die gemalt werden soll. Zweiter Parameter ist das Destination Rectangle. Das ist das Rechteck auf der Zeichenfläche, in das die Textur gepresst werden soll. Natürlich ist das die Position des Taxis plus ein paar Korrektur-Pixel, damit der Strahl nicht auf dem Taxi, sondern am Triebwerks-Ausgang gemalt wird. Die Skalierung der Flamme wird hier von JetLeft abhängig gemacht. Da dort ein Wert zwischen 0 und 1 enthalten ist, reicht hier die Multiplikation mit FLAMESIZE aus, um die richtige Größe zu malen. Dazu ist noch nicht mal eine Abfrage zu machen, ob der Triebwerksstrahl überhaupt aktiv ist. Sollte das nämlich der Fall sein, ist JetLeft = 0 und damit wird die Textur mit der Höhe 0 gezeichnet – also garnicht. Das zweite Rechteck definiert den Ausschnitt aus der Quelltextur. Hier wird in Abhängigkeit des Animationsframes entweder richtig herum oder gespiegelt gewählt. Frame 0 würde hier 0/0/FLAMESIZE/FLAMESIZE verwendet werden, Frame 1 produziert hier den Ausschnitt FLAMESIZE/0/-FLAMESIZE/FLAMESIZE. Weiter hinten ist noch die Angabe der Rotation mit MathHelper.PiOver2 interessant. Dies bedeutet eine Rotation um 90 Grad im Uhrzeigersinn. Drehpunkt ist hier Vector2.Zero – das ist die linke, obere Ecke. Unter anderen Umständen (vielleicht bei der Rotation von Raumschiffen oder so) ist eine Rotation im Ursprung sinnvoller. Der wird dann einfach als Drehpunkt “new Vector2(FLAMESIZE / 2, FLAMESIZE / 2)” angegeben und schon passiert die Drehung im Mittelpunkt der Textur.

Schritt 6: Controller-Vibration

Als letzte Spielerei in diesem Tutorial möchte ich gerne alle beglücken, die über einen XBox-Controller am Computer verfügen. Dieser Controller kann nämlich auch vibrieren um dem Spieler Feedback über Spielabläufe zu liefern. Der Controller soll vibrieren, sobald die Jets am Taxi aktiviert wurden. Kinderspiel.

Die richtige Stelle dafür ist die Update-Methode – kurz nachdem wir die Jetpower für die einzelnen Düsen festgelegt haben. Daraus generieren wir nun auch die Stärke der Vibration. GamePad bietet uns nun eine Methode zum Setzen der Vibrationsstärke. Kleiner Hinweis: Das Ding vibriert solange, bis wir über diese Methode wieder alles auf 0 setzen (oder das Spiel beendet wird). Wir setzen daher immer die Vibration, auch wenn der Controller nicht mehr vibrieren sollte. Der erste Parameter bestimmt die Controller-Nummer. Ist nur ein Controller angeschlossen, betriefft es PlayerIndex.One. Sollte dein Spiel mit mehreren Controllern arbeiten, musst du hier natürlich darauf achten auch den richtigen Controller anzusprechen.

// Rumble-Controller 
float rumble = Math.Max(0,  
    Math.Max(taxi.JetRight,  
        Math.Max(taxi.JetBottom, taxi.JetLeft))); 
GamePad.SetVibration(PlayerIndex.One, rumble, rumble);

Und schon vibriert auch alles hübsch mit den Düsen zusammen! 🙂

So! Wir sind am Ende einer langen, langen Story von Spaß, Texturen, Sounds und was weiß ich alles. Ich hoffe, ihr hattet Spaß beim Durcharbeiten und habt schon 1000 eigene Ideen, was man mit dem neuen Wissen so alles anstellen kann! Lasst mich von euren Projekten wissen, stellt mir gerne Fragen, Anregungen und üble Worte zum Tutorial. Ich versuche das dann in zukünftigen Texten zu berücksichtigen.

Source zum Tutorial

Advertisements
Kommentare
  1. […] Source zum Tutorial & Weiter zu Teil 3 […]

  2. DarkMuesli sagt:

    Hi Tom!

    Danke für das Tutorial, hat Spaß gemacht es nachzuschreiben und ein (fast) ganz eigenes Spiel mit Sounds und „Animationen“ und allem am Ende in Händen zu haben. 😉 Auch deine Erklärungen dazu find‘ ick super.

    Eine Frage hätte ich aber:
    Wieso greifst du auf feste Konstanten z.B. für die Größe der Flammen oder des Taxis zurück? Der Texture2D Datentyp bietet die Eigenschaften .Width und .Height, über die man auch bei z.B. wechselnden Texturen immer die richtige Größe hat… Hat das Performance-Vorteile, oder hast du andere Gründe hierfür?

    Viele Grüße,
    Müsli

    • tomwendel sagt:

      Hallo Müsli,

      guter Einwurf! In der Tat gibt es keinen wirklich guten Grund hier mit festen Werten zu arbeiten. Zwar ist technisch betrachtet die Konstante nochmal einen minimalen Ticken schneller (da Compiler-optimiert direkt in den resultierenden Code verwurschdelt), aber das ist kaum der Rede wert und rechtfertigt nicht die dadurch fehlende Flexibilität.

      Der schönere Weg ist tatsächlich die Verwendung der Höhen- und Breiten-Eigenschaften der Texturen. Das erlaubt den problemfreien Austausch der Texturen. Man bedenke aber, dass der Code an manchen Stellen dadurch auch etwas flexibler werden muss. Bei der Ermittlung von Textur-Mitten z.B.

      Vielen Dank für den Verbesserungsvorschlag!
      Tom

  3. […] hatte ich damals detailiert beschrieben, wie das Spiel funktionert. (Teil 1, Teil 2, Teil 3). Damals noch mit XNA. Letzte Woche hatte ich aber mal spontan Lust das Ganze nochmal schnell mit […]

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s