Howto play AntMe! 2.0

Veröffentlicht: 31 März 2014 in AntMe!, Development

tomEine der wichtigsten Fragen, wenn es um AntMe! 2.0 geht, ist sicher die Frage nach dem Gameplay. Ich kann alle beruhigen, die mir gesagt haben, dass es weiterhin so simpel bleiben muss wie bisher. „Bitte kein Neustart“, „bitte nicht alles anders“ haben mir viele Lehrer und Trainer gesagt, als ich ihnen von der neuen Version erzählt habe. Das habe ich mir sehr zu Herzen genommen. Daher wird einem die neue Ameisen-API sehr, sehr vertraut vorkommen. Die Ameisen werden weiterhin mit Events funktionieren. Allerdings bekommt der Spieler wesentlich mehr Informationen über seine Umgebung, sodass die aktuelle Situation wesentlich besser analysiert werden kann. Allerdings kommt mit AntMe! 2.0 auch die Möglichkeit hinzu, die Wanzen zu programmieren. Damit das nicht langweilig wird, arbeiten diese Kollegen nach einem komplett anderen Programmier-Konzept. Man darf gespannt sein. In diesem Artikel gehts nämlich erstmal nur um die Ameisen.

Templates

Besonders wichtig bei der Neuumsetzung des Spiels war die Überarbeitung des Erstellungsprozesses von neuen Ameisen. Bisher wars ja so, dass der Spieler händisch das Ameisen-Projekt kopieren musste. Das ist recht heikel, weil der Spieler im Normalfall ja nicht wirklich weiß, was er da tut. Mal ganz abgesehen davon, dass die Runtime diverse technische Schwierigkeiten bekommt, da alle Spieler-Dateien ja scheinbar aus dem selben Projekt stammen, aber unterschiedliche Inhalte aufweisen. Diese kleine Hürde wird dem Spieler in Zukunft genommen. Ein Generator für diese Ameisen-Templates erstellt dem Spieler erstmal ein Grundgerüst für jegliche unterstützte Kombination an Entwicklungsumgebung, Sprache und Programmiersprache. Der Spieler wählt also „Visual Studio 2013“, „C#“, dann „Deutsch“ und am Ende fällt ein fertiges Visual Studio Projekt heraus mit dem es dann los gehen kann. Realisiert wird das über Code-Generatoren. Darüber schreibe ich aber mal, wenn es um die Erweiterbarkeit von AntMe! 2.0 geht.

Lokalisiert Spielen

Bekannt aus vorherigen AntMe! Versionen spielt der Spieler wieder lokalisiert in seiner Muttersprache. Das ist und bleibt eines der Grundkonzepte der Ameisen, weil wir extem positive Erfahrungen damit gemacht haben, den Spieler nicht gleichzeitig mit der Programmier-Herausforderung auch noch mit einer Sprachbarriere zu konfrontieren. Die Ameisen reagieren also weiterhin auf ein GeheGeradeaus() oder Nimm(zucker) und verfügt über Events wie Sieht(Zucker zucker). Kontrollstrukturen und Schlüsselworte bleiben natürlich weiter C#-üblich.

Ameisen produzieren

Eine kleine Änderung gibts dann aber doch – zu Gunsten einer korrekten Umsetzung der OOP. Der Generator erzeugt nämlich direkt zwei Klassen im Projekt. Neben der Klasse, die die Ameise repräsentiert, gibt es noch eine übergeordnete Rolle, die die komplette Kolonie darstellt. Diese zweite Klasse übernimmt die Aufgabe der bisher statischen BestimmeTyp()-Methode der alten Version. Sie gliedert die Aufgabe der Erzeugung neuer Ameisen in eigenen Aufgabenbereich aus, damit der Spieler bei der Erstellung seiner Ameisen etwas freier ist. Eine ganze Reihe von Informationen über das aktuelle Volk (Punktestand, Ameisen-Typ Verteilung, Kampf- und Ressourcen-Statistiken) unterstützen den Spieler bei der Entscheidung, welche Ameise als nächstes erzeugt werden soll. Ganz nebenbei erfüllt diese Klasse auch direkt die Funktion eines Fabric-Patterns, wodurch hier auch schon die ersten Informatik-Pattern äußerst plastisch dargestellt werden.

Ameisen spezialisieren

Die Auslagerung der Erzeugungsklasse hat aber nicht nur positive Auswirkungen auf die Code-Struktur, sondern erlaubt es auch, dem Spieler etwas mehr Freiheit bei der Spezialisierung seiner Ameisen zu machen. Ameisen konnten ja bislang mit Hilfe der Kasten-Attribute in Gruppen unterteilt werden. Der Code, der alles steuert, befand sich aber weiterhin in einer einzigen Datei. Wollte man den „Sammler“ anders agieren lassen, als den „Kämpfer“, so musste man als Programmierer in jeder Methode eine Weiche einbauen. Das ist nicht ganz im Sinne der objektorientierten Programmierung. AntMe! 2.0 löst dieses Problem weitaus besser: Die zentrale Klasse des Volkes wird in Zukunft nicht mehr die Ameise, sondern die Ameisen-Fabrik, die Colony-Klasse, sein. Die Implementierung der Ameise verläuft dann vollkommen frei und kann beliebig vererbt werden. Das Kaste-Attribut gibt es zwar weiterhin, aber nur um eine konkrete Implementierung mit der Fertigkeitsverteilung zu belegen – also nutzen wir nur noch die deklarative Funktionsweise eines Attributs. Es wird also möglich sein für sein Volk eine Basisameise zu implementieren, die Grundfunktionen wie die Wegfindung, Dekodierung von Markierungen,… übernimmt. Die Spezialisierung zum Kämpfer oder Sammler kann dann in einer spezialisierten Klasse passieren – ganz im Sinne der OOP.

Wahrnehmung der Welt

Wesentliche Verbesserungen sind bei der Wahrnehmung der Umgebung vorgenommen worden. Wo die alte Version pro Event immer nur das erstbeste Objekt geliefert hat, kann die Ameise in Zukunft ihre Umwelt vollkommen frei analysieren. Richtig kritisch ist dieses Problem bei der Verfolgung einer Markierungsspur. Die alte API liefert bei Riecht(Markierung) lediglich die Markierung, die zuerst gesichtet wurde – und das auch nur ein einziges mal bei Sichtung. Es ist also wirklich schwer eine Ameisenstraße zu analysieren um die Herkunft zu ermitteln. Ein paar Hilfsvariablen wie AmeisenInSichtweite, die die Menge der benachbarten Ameisen zurück geliefert hat, war leider nur selten hilfreich. In Zukunft wird dem Spieler eine Liste von sichtbaren Elementen zur Verfügung gestellt. Eine Variable RiechbareMarkierungen liefert alle wahrgenommenen Markierungen. Das ist der Moment, in dem Schleifen und Lambdas in Spiel kommen. So lässt sich mit RiechbareMarkierungen.OrderBy((m) => (m.Entfernung)). FirstOrDefault() die Markierung ermitteln, die der Ameise am nächsten steht.

So funktionieren die Ameisen im Hintergrund

Alles in allem hat sich die Architektur von Version 2.0 wesentlich generischer entwickelt, um wirklich sehr flexibel bei zukünftigen Entwicklungen zu bleiben und ein Höchstmaß an Erweiterbarkeit zu sichern. Ich will mich bei der Beschreibung hier aber erstmal auf den Ablauf bei den Ameisen beschränken. Im Übrigen funktioniert bei den Wanzen auch wieder alles ganz anders ;) Alles fängt damit an, dass es eine generische Implementierung von Factions, also Fraktionen, Parteien, Rassen,… gibt die von beiden Rassen (Ameisen und Wanzen) gleichermaßen verwendet werden. Die konkrete Implementierung bestimmt dann, wie der Ablauf dieser Rasse im Spiel abläuft (Wann neue Einheiten erstellt werden, wie neue Einheiten erstellt werden,…). Im Ameisenfall ist festgelegt, dass es eine Colony-Klasse geben muss – mehr erstmal nicht. Die Colony-Klasse verfügt in Zukunft auch über das Spieler-Attribut, das Auskunft über den Kolonie-Namen und die Autoren-Referenz gibt. Geht eine Simulation los, wird beim erzeugen einer Ameise diese Colony gefragt, welche neue Ameise erzeugt werden soll. Der Rückgabewert ist hier ein schlichter Type der von der AntFaction untersucht wird. Handelt es sich hierbei um eine gültige Ameisen-Klasse (erbt sie von einer Basisklasse, verfügt sie über ein Kasten-Attribut), wird eine Instanz davon erzeugt und die Simulation erhält einen Repräsentaten in Form einer Ameise. Erlebt die Ameise innerhalb der Simulation irgendetwas spannendes (sieht Zucker oder so), wird dem Programmierer mit Hilfe eines Interop-Objektes ein Ereignis geworfen, das letztendlich zur Ausführung der entsprechenden Methode führt.

Basis API

Das eben erwähnte Interop-Objekt ist – im Gegensatz zur vorherigen Version der Lokalisierung – die zentrale Schnittstelle zwischen dem Ameisen-Objekt der Simulation und der lokalisierten API für den Spieler. Dieses Interop-Objekt verfügt über wesentlich rohere Daten als die übersetzte API. Was bei der deutschen Lokalisierung noch SichtbareAmeisen, SichtbareWanzen,… heißt, ist im Interop-Objekt noch in VisibleItems zusammengefasst. Das kann in manchen Situationen sehr, sehr nützlich sein. Ebenso sind die Events, die eintreffen, etwas generischer. So gibt es nur ein Spots(Something)-Event – ohne Differenzierung des Element-Typs. Ameisenvererbung Profis können das für sich nutzen. Einerseits steht dem Programmierer bereits in den Standard-Templates ein Interop-Objekt zur Verfügung, auf das frei zugegriffen werden kann. Der Spieler kann aber auch von Anfang an über das Erben einer anderen, wesentlich niedrigeren Version der Basisameise Zugriff darauf verschaffen und seine API nach eigenem Ermessen basteln.

Willst du die Alpha testen?

So! Die erste spielbare Alpha steht bereits fast vor der Tür. Leider lässt hier der Komfort und Unterstützung durch Dokumentation und Hilfe-Dialoge noch etwas zu wünschen übrig, weshalb das hier sicher nur was für Leute ist, denen der Prozess des Erstellens eigener Projekte, Referenzierung von Assemblies und Einbinden von Namespaces nicht unbekannt ist. Da ich aber gerade jetzt, wenn es um die Gestaltung des Spiel-Interfaces geht, gaaaanz, ganz viel Feedback brauche, würde ich gerne ein paar Leute zum geschlossenen Test einladen. Fühlst du dich angesprochen, dann schreib mir doch bitte direkt eine Email (tom@antme.net) mit ein paar Worten dazu, was du so machst und in welchem Zusammenhang du zum Ameisen-Projekt stehst. Ich würde mir wünschen, wenn sich auf diesem Weg ein paar Lehrer, Trainer und Pädagogen bei mir melden, damit ich hier möglichst früh noch euer Feedback einbeziehen kann. Danke für euer Unterstützung!

Das Projekt AntMe! 2.0

Veröffentlicht: 12 März 2014 in AntMe!, Development
Was bisher geschah

AntvatarWer AntMe! nicht kennt: Es handelt sich dabei um ein bereits 6 Jahre altes Projekt aus meiner Studien-Zeit. Zusammen mit Wolfgang Gallo stand ich damals auf der Games Convention in Leipzig am Stand der Universität Leipzig, um umherziehenden Gamer-Kids die technischen Hintergründe von Spielen zu zeigen. Gemeinsam entwickelten wir deshalb ein Spiel, das dies vernünftig veranschaulichen sollte.

Das Ergebnis war eine sehr, sehr einfache Art von Programmierspiel. In Phase 1 programmiert der Spieler „das Gehirn“ von Ameisen (Im Prinzip eine simple KI), die in der anschließenden Simulation (Phase 2) ihr Leben bestreiten muss, indem Sie Nahrungsmittel wie Zucker und Äpfel einsammeln, aber auch gegen den natürlichen Feind, die Wanze, kämpfen müssen.

Den Rest des Beitrags lesen »

XNA-Sample: MoonTaxi (Teil 3/3)

Veröffentlicht: 30 Dezember 2012 in Microsoft, Visual Studio, XNA
Schlagworte:

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

XNA-Sample: MoonTaxi (Teil 2/3)

Veröffentlicht: 30 Dezember 2012 in Development, Visual Studio, XNA
Schlagworte:

Dies hier ist Teil 2 einer Reihe von Blog-Posts, die ich vor Jahren zum Lernen von Abläufen innerhalb von Spielen geschrieben habe. Natürlich gibts auch Teil 1 zu lesen.

Im ersten Teil haben wir zusammen angeschaut, wie man mit Texturen umgeht, und diese auf der Szene zeichnet. Zudem haben wir für das Taxispiel ein kleines Berechnungsmodell entwickelt, indem wir auch bereits verschiedene Kräfte (Gravitation und Schubkräfte der Taxi-Düsen) berechnet und die Positionen des Taxis während der Simulation ermittelt haben. Dazu haben wir auch die Eingabe eines Users abgegriffen und für die Berechnungen verwendet.

In diesem Teil des Tutorials möchte ich gerne weitere wichtige Aspekte ansprechen. Ich würde gerne Hindernisse (Plattformen) in die aktuelle Szene einbauen und die Kollision des Taxis mit diesen Teilen berechnen.

Auch der Quellcode zu diesem Teil liegt wieder bei mir auf Skydrive zur Verfügung.

Schritt 1: Level-Modell

Das Spiel ist im Moment noch etwas spaßfrei, solange es keine Plattformen und Fahrgäste gibt, mit denen man interagieren kann. Einfach nur umher fliegen wird schnell langweilig.

Da wir im ersten Tutorial bereits festgelegt haben, dass wir mit einem festen Raster von größeren Blöcken arbeiten, sollten wir zuerst mal festlegen, wie groß dieses Raster aussieht, wie groß die Levels werden und mit welcher Blockgröße wir dann arbeiten.

Ich dachte mir, dass wir vielleicht auf einer relativ kleinen Auflösung arbeiten sollten, die auf möglichst vielen Geräten problemlos arbeiten wird. Eine Standard-Auflösung von 640 auf 480 bietet sich da an. Wenn wir mit Blocks arbeiten, die eine Auflösung von 20 auf 20 verwenden, ergeben sich ein Raster von 24 Reihen und 32 Spalten.

Die Rahmenbedingungen sollten wir auf jeden Fall per Konstanten in der Game-Klasse festhalten, damit dies auch nachträglich nochmal verändert werden kann.

public const int SCREENWIDTH = 640; 
public const int SCREENHEIGHT = 480; 
public const int BLOCKSIZE = 20;

Standard-Auflösungen lassen sich recht einfach zu Beginn des Programm-Ablaufes im Konstruktor verstecken. Dort wird in der ersten Zeile das GraphicsDevice erstellt. Kurz danach kann man die Einstellungen verändern, um entsprechende Wünsche zu äußern. Man muss hier aber beachten, dass die Einstellungen, die man hier trifft nur “Wünschäußerungen” sind. XNA prüft vorher, ob Auflösungs- oder Farbtiefe-Einstellungen von der Hardware überhaupt unterstützt werden und passt dann erst alles an.

graphics.PreferredBackBufferHeight = SCREENHEIGHT; 
graphics.PreferredBackBufferWidth = SCREENWIDTH;

Was uns aber außerdem fehlt ist eine Klasse, die in unserem Modell das Spiel-Level repräsentiert. Wir erstellen also eine Klasse Level, die genau diese Aufgabe übernimmt. Dort hinein kopieren wir folgende Felder, die ab dann dort verfügbar sein werden.

public const int ROWCOUNT = 24; 
public const int COLUMNCOUNT = 32; 

public FieldType[,] Fields { get; private set; } 
public Vector2 StartPoint { get; private set; } 
public Vector2 GuestPoint { get; private set; } 
public Vector2 GoalPoint { get; private set; }

2 Konstanten halten die Menge an Spalten und Zeilen bereit. In einem 2-dimensionalen Array wird das Raster gespeichert. Es existiert also auch ein Eintrag für leere Zellen. Um diese zu kennzeichnen, besteht der Array nicht aus normalen Flags oder Integern, sondern aus einem selbst definierten Enum, der aus einer Liste aller möglichen Blocktypen besteht. Für spätere Berechnungen werden aber parallel auch nochmal die genaue Position des Fahrgastes, des Fahrziels und der Startposition des Taxis festgehalten.

enum FieldType 
{ 
    Nothing, 
    Bridge, 
    Startpoint, 
    Guest, 
    Goal 
}

Damit lässt sich das komplette Level sehr bequem abbilden und bei den Berechnungen auch sehr schnell für Prüfungen durchlaufen. Fragt sich nur noch, woher so ein Level kommt. Am einfachsten ist es hier, erstmal auf ein vorhandenes Format zurück zu greifen. Ich habe mich hier für eine ganz normale Text-Datei entschieden, die man mit dem Notepad editieren kann. Dabei sollte jedes Zeichen eine Zelle in unserem Raster entsprechen.

Das Level zur SpielzeitDas Level im Editor

Abb. 1: Gegenüberstellung “Level-Designer” Notepad zum Spiel

Punkte repräsentieren leeren Raum, die Raute stellt eine Plattform dar, die Zahlen 1 – 3 geben die Positionen für das Taxi, den Fahrgast und das Fahrziel an. Das ist eine einfache Art einen ordentlichen Level-Editor bereit zu stellen. Leider unterstützt die Content-Pipeline von Haus aus keine Text-Dateien, weshalb wir uns einen Workaround basteln müssen. Im Solution Explorer erstellen wir deshalb einen Ordner namens “Levels” und kopieren dort per Drag&Drop die vorgefertigten Levels hinein. Du musst nur darauf achten, dass bei den Dateien in den Eigenschaften (kurz anklicken und rechts unten schauen) das Feld “Copy to output-Directory” auf “always” oder “copy if newer” steht. Diese Einstellung sorgt dafür, dass beim Erstellen des Spiels auch die Levels in das Zielverzeichnis kopiert werden, damit wir diese dann aus dem Spiel laden können.

Hinweis: Diese Herangehensweise ist zwar recht pragmatisch, entspricht aber nicht gerade dem vorbildlichsten Weg dies zu tun. Für das Laden eines solchen Levels müsste nun noch ein Content-Loader gebaut werden, damit auch die Levels gemeinsam beim Content liegen. Für dieses Tutorial habe ich allerdings darauf verzichtet – also bitte nicht zu Hause nachmachen :)

Die Level-Klasse braucht noch einen Platz in der Hauptanwendung und sollte noch aus der Datei geladen werden. Kopier dazu einfach mal den Quelltext aus dem Konstruktor des Democodes heraus. Dort wird im Grunde nur die Text-Datei eingelesen und der mehrdimensionale Array mit den richtigen Daten gefüllt. Dies passiert im Konstruktor der Level-Klasse und wird im Reset der Game-Klasse aufgerufen. Die Reset-Methode wird dann entsprechend angepasst, weil ja nun auch anschließend aus dem Level bekannt ist, wo denn nun wirklich der Ausgangspunkt des Taxis ist. Die Startposition kann jetzt also Level-spezifisch ermittelt werden. Man darf aber nicht vergessen, dass die Zellen und Positionsangaben im Level in Zellen angegeben wird. Die Position des Taxis sind aber Pixel! Übernimmt man die Startposition aus dem Level, muss da auch noch die Block-Größe hinzu multipliziert werden, damit die Position auch wieder stimmt.

level = new Level(@"Levels\level1.txt"); 
taxi.Position = level.StartPoint * BLOCKSIZE; 
taxi.Speed = new Vector2();

Startet man das Spiel, so startet zwar das Taxi an der richtigen Stelle, vom Level zu sehen ist aber noch nichts.

Schritt 2: Das Level zeichnen

Alles ist geladen, als könnte man jetzt doch auch das Level ordentlich zeichnen. Richtig! das machen wir jetzt auch. Was wir zum zeichnen als allererstes brauchen sind Texturen. Eine für die Plattform-Blocks (block.png), eine für den Fahrgast (guest.png) und natürlich auch eine für das Fahrziel (goal.png). Alle 3 befinden sich bereits im fertigen Projekt und können direkt verwendet werden.

Auch diese Texturen brauchen Variablen vom Typ Texture2D in der Game-Klasse und müssen bei LoadContent geladen werden. Wie das funktioniert, weißt du ja schon aus dem ersten Tutorial.

Nun wird gezeichnet! Das passiert wieder in Draw. Blöcke zeichnen wir vor dem Taxi, damit das auch ganz sicher im Vordergrund ist. Wir schreiben unseren folgenden Code also zwischen spriteBatch.Begin() und unsere Zeile die das Taxi malt.

Damit wir alle Blöcke des Levels auch ordentlich zeichnen, durchlaufen wir die Zellen Zeilenweise und Zelle für Zelle. Je nach Inhalt der Zelle malen wir die entsprechende Textur auf den Bildschirm und an die richtige Stelle.

// Blocks zeichnen 
for (int y = 0; y < level.Fields.GetLength(1); y++) 
{ 
    for (int x = 0; x < level.Fields.GetLength(0); x++) 
    { 
        switch (level.Fields[x, y]) 
        { 
            case FieldType.Bridge: 
                spriteBatch.Draw( 
                    blockTexture, 
                    new Vector2(x * BLOCKSIZE, y * BLOCKSIZE), 
                    Color.White); 
                break; 
            case FieldType.Guest: 
                spriteBatch.Draw( 
                    guestTexture, 
                    new Vector2(x * BLOCKSIZE, y * BLOCKSIZE), 
                    Color.White); 
                break; 
            case FieldType.Goal: 
                spriteBatch.Draw( 
                    goalTexture, 
                    new Vector2(x * BLOCKSIZE, y * BLOCKSIZE), 
                    Color.White); 
                break; 
        } 
    } 
}

Im Grunde nichts Neues. In zwei verschachtelten Schleifen (eine über die Reihen, die zweite über die einzelnen Spalten einer Reihe) werden alle Zellen durchlaufen und je nach Inhalt entweder ein Block, ein Gast, das Ziel oder garnichts gezeichnet, wenn die Zelle leer ist. Wichtig ist hier auch unbedingt auf den Zellen-Index (x und y) wieder die Block-Größe zu multiplizieren, damit die Textur auch an der richtigen Stelle am Bildschirm gezeichnet wird.

Wenn du das Projekt jetzt startest, kannst du schon das Level erkennen und der Fahrgast wartet auch schon auf dich. Leider interagiert das Taxi bisher nicht mit diesem Level. Man kann einfach durch die Blocks hindurch fliegen und dem Fahrgast ist das Taxi bislang auch noch egal. Das wird sich aber gleich ändern.

Schritt 3: Einfache Kollision

Als erstes wollen wir verhindern, dass das Taxi weiterhin aus dem Bildschirmrand fliegen kann. Wenn der Spieler an den Rand fliegt, soll es zu einer Kollision kommen und das Spiel soll von vorne beginnen.

Diese Art der Kollision ist besonders leicht zu ermitteln, weil man in diesem Fall, dank des sauberen Rechtecks des Fensters, beide Achsen problemlos getrennt betrachten kann und es genau genommen um eine Grenzüberschreitung auf einem eindimensionalen Zahlenstrahl handelt. Aber zum Aufwärmen kommt uns diese Berechnung gerade recht.

Koordinaten und Grenzen

Abb. 2: Taxi im Koordinatensystem bei der Grenzüberschreitung

Die Kollision mit der linken Wand ist besonders einfach. Sobald nämlich die Position des Taxis kleiner 0 ist, befindet sich das Taxi ganz klar mit der Spitze bereits in der Wand und ist kollidiert. Das Gleiche auch mit der oberen Kante. Befindet sich das Taxi im negativen Koordinatenbereich der Y-Achse, so ist das Taxi nach oben aus dem Bildschirm geflogen.

Ein kleines bisschen komplizierter wird es mit den anderen beiden Bildschirm-Kanten. Dort kommt dann noch die Größe des Taxis (es darf ja nicht mehr die linke, obere Ecke als Referenz genommen werden) und die Größe des Anzeigefensters ins Spiel. Dafür bekommt die Taxi-Klasse zwei weitere Konstanten, die für diese Prüfung verwendet werden können: WIDTH und HEIGHT vom Typ Integer und mit den Ausmaßen der Taxi-Textur (50/25). Kollidiert das Taxi, so wird einfach die Reset-Methode aufgerufen und das Spiel beginnt von vorne.

// Randkollision 
if (taxi.Position.X < 0) 
    Reset(); 
if (taxi.Position.X > SCREENWIDTH - Taxi.WIDTH) 
    Reset(); 
if (taxi.Position.Y < 0) 
    Reset(); 
if (taxi.Position.Y > SCREENHEIGHT - Taxi.HEIGHT) 
    Reset();

Das verhindert nun den Ausflug des Taxis außerhalb des Spielfensters. Sollte das doch passieren, startet das Spiel automatisch von vorne.

Schritt 4: Komplexere Kollisionen

Etwas komplizierter werden die Kollisionen mit den Blocks. Diese befinden sich ja überall auf der Spielfläche, können von allen Seiten und über mehrere Achsen angeflogen werden. Ihre Form (rechteckig) macht eine Kollisionsprüfung aber dann doch noch relativ einfach. Um genau zu sein hilft uns da eine Konstruktion aus dem XNA-Framework: Das Rectangle. Dieses Struct verfügt über eine Methode “Intersect”, die eine Kollisionsprüfung sehr bequem erlaubt und uns keinen weiteren Ärger macht.

Wir müssen jetzt nur noch jeden Block auf die Kollision mit dem Taxi prüfen. In dieser Beispielanwendung mache ich das recht naiv, indem ich wieder über alle Blöcke laufe und auf Kollision prüfe.

Kleiner Hinweis: Diese Herangehensweise an die Kollisionsprüfung ist extrem naiv und prüft im Grunde nur jedes Objekt mit jedem anderen Objekt. Das können wir uns in diesem Fall leisten, weil es im Spiel nur relativ wenig Spielobjekte gibt und sich davon auch nur ein einziges Objekt bewegt. Man muss also nur ein Objekt gegen alle anderen Objekte prüfen. Der Rechenaufwand steigt aber quadratisch mit jedem weiteren beweglichen Objekt das zum Spiel hinzugefügt wird und bringt den Rechner schnell an die Leistungslimits. Es gibt für solche Situationen etwas intelligentere Ansätze (Stichwort “Quad trees” oder “Oct trees”) die vorher grob ermitteln, welche Objekte überhaupt für Kollisionen in Frage kommen und erst dann exakt prüfen.

// Block-Kollision 
Rectangle taxiCollision = new Rectangle( 
    (int)taxi.Position.X, (int)taxi.Position.Y, 
    Taxi.WIDTH, Taxi.HEIGHT); 
for (int y = 0; y < level.Fields.GetLength(1); y++) 
{ 
    for (int x = 0; x < level.Fields.GetLength(0); x++) 
    { 
        // Skip Non-Blocks 
        if (level.Fields[x, y] != FieldType.Bridge) 
            continue;

        Rectangle blockCollision = new Rectangle( 
            x * BLOCKSIZE, y * BLOCKSIZE, BLOCKSIZE, BLOCKSIZE); 
        if (blockCollision.Intersects(taxiCollision)) 
        { 
            // Kollision! 
            Reset(); 
        } 
    } 
}

Dieser Code folgt einfach in der Update-Methode direkt unter dem Code für die Kollision mit der Wand. Erst mal interessiert uns nur die tatsächliche Kollision mit dem Block. Später ist uns auch noch wichtig von welcher Seite man den Block berührt (Thema Taxi landen) und mit welcher Geschwindigkeit das passiert. Jetzt wird es schon etwas schwieriger sich im Level zurecht zu finden und nicht gegen irgendwelche Hindernisse zu stoßen.

Schritt 5: Das Taxi Landen

Wir können fliegen, wir können kollidieren und unser Fahrgast winkt und schon ungeduldig zu. Leider können wir das Taxi noch nicht landen. Das soll sich nun ändern.

Fassen wir mal zusammen, was so einen Landevorgang ausmacht: Das Taxi landet dann, wenn es von oben auf die Plattform herunter sinkt. Das Taxi darf also doch mit der Plattform kollidieren, sobald bestimmte Bedingungen erfüllt sind. Eine weitere Bedingung wäre zum Beispiel, dass die Geschwindigkeit des Taxis zum Kollisionszeitpunkt ein bestimmtes Limit nicht überschreitet. Was heißt das für den Code? Wir müssen bei einer Kollision jedes mal noch zusätzlich prüfen, von welcher Seite das Taxi an die Plattform gestoßen ist. Vorher sollten wir allerdings noch die etwas einfachere Prüfung auf die Geschwindigkeit durchführen.

In Tutorial 1 hatten wir ja schon festgestellt, dass die Geschwindigkeit in 2 verschiedenen Achsen angegeben wird. x und y. Wenn wir aber von der Geschwindigkeit eines Taxis sprechen, brauchen wir die länge des 2-dimensionalen Geschwindigkeitsvektor, den wir vorhin noch getrennt behandelt haben. Wir haben aber Glück. Die beiden Achsen des Koordinaten-Systems zeichnet aus, dass sie orthogonal, im rechten Winkel zueinander stehen. Dadurch können wir den Satz des Phytagoras anwenden. Er besagt, dass das Quadrat aus einem Schenkel eines rechtwinkligen Dreiecks plus das Quadrat des anderen Schenkels das Quadrat der Hypotenuse, also der längsten Kante gegenüber dem rechten Winkel, ergibt. a²+b² = c². Sicher schon mal in Mathe gehört.

Geschwindigkeiten

Abb. 3: Geschwindigkeiten und Phytagoras.

Wir müssen das aber nicht von Hand ausrechnen. Dafür stellt uns Vector2 eine Methode “Length” zur Verfügung, die das schon für uns übernimmt. Ich schreibe das nur deshalb ausführlich hin, um auf eine kleine Performance-Falle hinzuweisen. Die Formel enthält ein c². Das bedeutet, dass bei der Berechnung von c eine Wurzel gezogen werden muss. Dies kostet den Rechner wesentlich mehr Rechenzeit, als das Quadrat zu berechnen. Manchmal ist die Berechnung von c unvermeidlich, da man die exakte Geschwindigkeit des Taxis ermitteln will. Will man allerdings nur Vergleiche zwischen zwei Vektoren machen, so reicht es aus, die Quadrate der beiden Längen zu vergleichen.

In unserem konkreten Beispiel können wir jetzt, anstatt die Wurzel aus c², der Länge unseres Geschwindigkeitsvektors, zu ziehen einfach die Richtgeschwindigkeit quadrieren und anschließend mit c² anstatt mit c zu vergleichen. Das Ergebnis wäre das Selbe. Vektor liefert uns das Quadrat von c übrigens mit der Methode “LengthSquared”.

Fügen wir am besten noch die Konstante, für die maximale Geschwindigkeit mit der gelandet werden kann, zu unseren Konstanten hinzu

public const float MAXLANDINGSPEED = 20f;

und bauen dann noch eine zusätzliche Prüfung in die Blockschleife ein, die wir im vorherigen Kapitel in die Update-Methode eingefügt haben. Aus einem einfachen, einsamen Reset() wird nun eine etwas umfangreichere Prüfung, weil ja auch eine Landung möglich sein kann. An der Stelle des TODOs müssen wir jetzt aber noch rausfinden, von welcher Seite wir an den Block stoßen. Von oben wär ok.

// Auf Landungsbedingungen prüfen 
if (taxi.Speed.LengthSquared() < MAXLANDINGSPEED * MAXLANDINGSPEED) 
{ 
    // Chance auf Landevorgang 
    // TODO: Kollisionsrichtung prüfen 
} 
else 
{ 
    // Kollision! 
    Reset(); 
}

Aber wie machen wir das? Die Kollision verrät uns ja nur, DASS wir kollidieren, aber nicht so recht von wo. Ein Beispiel dazu zu machen ist hier vielleicht sinnvoll. Schauen wir uns das erste Bildchen in Abbildung 4 mal an. Im ersten Frame ist noch alles ok, im zweiten Frame kollidiert das Taxi dann mit dem Block. Um jetzt zu sehen von welcher Seite das passiert, betrachtet man einfach die beiden Geschwindigkeitsachsen getrennt voneinander. das zweite Bild wendet nur die Bewegung in X-Richtung an –> keine Kollision. Das bedeutet, dass das Taxi in diesem Beispiel von der Y-Achse aus (von oben oder unten) an den Block gedonnert ist. Aus welcher Richtung das Taxi angeschossen kam lässt sich leicht am Vorzeichen des Y-Anteils der Geschwindigkeit auslesen.

Kollisionen mit einem Block

Abb. 4: Kollision mit einem Block, aufgesplittet in X- und Y-Achse.

Für diesen speziellen Fall erzeugen wir eine neue Kollisionsbox für das Taxi, bei dem der X-Anteil nicht berücksichtigt wurde. Dazu müssen wir die X-Position des Taxis wieder auf den vorherigen Wert zurück rechnen oder gleich bei der Positionsberechnung weiter oben die alten Werte zwischen speichern.

// Chance auf Landevorgang 
float oldPosX = taxi.Position.X - 
    taxi.Speed.X * (float)gameTime.ElapsedGameTime.TotalSeconds;

Rectangle taxiWithoutXCollision = new Rectangle( 
     (int)oldPosX, (int)taxi.Position.Y, 
    Taxi.WIDTH, Taxi.HEIGHT);

if (taxiWithoutXCollision.Intersects(blockCollision) && ) 
{ 
    // Landeanflug 
    // TODO: Landung durchführen 
} 
else 
{ 
    // Kollision von der Seite 
    Reset(); 
}

Großartig! Wir wissen jetzt also wann das Taxi zur Landung ansetzt. Letzt müssen wir das nur noch in das Spiel einbauen. Landen ist aber nicht ganz unkompliziert. Solange das Taxi gelandet ist, soll nämlich keine Gravitation wirken und auch die Steuerdüsen links und rechts sollen keine Wirkung haben. Abheben kann das Taxi hier nur noch mit der Steuerdüse unten. Da dies Einfluss auf das Verhalten des Taxis hat, speichern wir das auch in der Taxi-Klasse. dort kannst du ein zusätzliches Feld “public bool Landed { get; set; }” ein.

Das muss jetzt natürlich an vielen Stellen berücksichtigt werden.

  • In der Reset-Klasse muss der Wert wieder auf false gesetzt werden.
  • Gravitationsberechnung in der Update-Methode muss unter der Bedingung passieren, dass das Taxi nicht gelandet wurde. (If-Bedingung vor die Berechnung einfügen)
  • Das selbe gilt für die vertikalen Antriebsdüsen
  • Wir brauchen eine Landelogik an der Stelle, an der wir noch das TODO-Kommentar haben.
  • Im Gegenzug brauchen wir auch eine Abhebelogik.

In dem Moment, wo wir feststellen, dass das Taxi landet, setzen wir den Landed-Flag des Taxis auf true. Außerdem setzen wir die Geschwindigkeit des Taxis auf 0 und platzieren es exakt am Landeplatz, damit es dort stabil stehen bleibt. Wir nehmen dazu einfach die Y-Position des Blocks und setzen das Taxi genau auf die richtige Höhe. Das machen wir, weil durch die dynamische Berechnung der Position das Taxi gerade mitten im Block steht, wir aber obenauf landen wollen. Um nun Seiteneffekte durch die Kollision auszuschließen die in späteren Runden passieren können, platzieren wir das Taxi exakt auf dem Block.

// Landeanflug 
taxiCollision.Y = blockCollision.Y - Taxi.HEIGHT; 
taxi.Position = new Vector2( 
    taxi.Position.X, blockCollision.Y - Taxi.HEIGHT); 
taxi.Speed = new Vector2(); 
taxi.Landed = true;

Um wieder starten zu können implementieren wir am besten direkt unter der Verarbeitung der Usereingaben auch gleich eine Sektion, die den Start des Schiffes auch wieder erlaubt. Ich schlage die Position direkt unter der Deklaration der delta-Variablen vor.

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

Wir prüfen hier einfach nur, sobald das Taxi gelandet ist, ob das Triebwerk unten gezündet wurde und geben damit das Taxi wieder frei.

Nach diesen Änderungen sollte es nun möglich sein frei im Level herum zu fliegen, mit den Wänden und den Plattformen zu kollidieren und bei angepasster Geschwindigkeit auf ihnen zu landen.

Source zum Tutorial & Weiter zu Teil 3

XNA-Sample: MoonTaxi (Teil 1/3)

Veröffentlicht: 30 Dezember 2012 in Development, Visual Studio, XNA
Schlagworte:

Dies hier ist ein Blogpost, den ich vor fast drei Jahren mal geschrieben habe, um die Funktionalität von XNA zu beschreiben – damals noch für Microsoft. Da sich dieser Artikel bis heute mit enormen Besucherzahlen schmücken kann und ich auch weiterhin auf Kommentare und Verbesserungsvorschläge reagieren will, habe ich den Eintrag entsprechend umgezogen und hoffe auch weiterhin auf rege Rückmeldung. Viel Spaß damit.

Worum geht es im diesem Spiel? Grundsätzlich handelt es sich um ein Spiel im 2D-Modus, bei dem die Camera eine seitliche Sicht auf die Szene ermöglicht. Ähnlich einem klassischen Jump&Run.

Der Spielbildschirm

Der Spieler steuert im Spiel ein kleines Taxi, das sich unter Einfluss von Trägheit und Gravitation durch ein verkzwicktes Netz aus Plattformen schlängeln muss, um einen Taxi-Fahrgast einzusammeln und an einen bestimmten Zielort zu bringen. Gelingt ihm das, bevor der Treibstoff ausgeht, gelangt er in das nächste Level. Bei diesem Taxi handelt es sich nicht im ein Taxi auf Rädern, sondern um ein, mit Steuerdüsen ausgestattetes Fluggerät.

Interessante Aspekte dieses Samples liegt in den Bereichen

  • Allgemeine Gameloop
  • Fenster-Setup
  • Parameterisierung
  • Einfaches 2D rendern
  • Einfache Headsup Displays (HUD)
  • Physikalische Bewegung
  • einfache Kollisionen
  • Spieler-Eingaben

Am besten arbeitet man sich in das Sample ein, indem man sich das vollständige Projekt herunter lädt, parallel öffnet und selbst Schritt für Schritt das Spiel nachentwickelt.

Man braucht dafür im Grunde nur eine Sache: Eine Installation des aktuellen XNA Game Studio in Version 3.1. Das Sample, soweit  es im ersten Teil des Tutorials erstellt wird, steht auf meinem Skydrive zur Verfügung.

Edit (30.12.2012): Inzwischen gibt es eine neuere Version von XNA und Visual Studio (4.0), aber ich bin mir auf die Schnelle nicht sicher, welche System-Voraussetzungen das Ganze hat. Der Code des Samples sollte aber gleich aussehen.

Schritt 1: Ein neues Projekt erzeugen

Startet man Visual Studio neu, so kann man direkt über das Menü ein neues Projekt erzeugen. Im Folgenden Assistenten wählt man einfach ein neues XNA 3.1 Windows Spiel aus, gibt einen sinnvollen Projektnamen an (in unserem Fall “MoonTaxi”), kann optional noch den Speicherort wählen und bestätigt mit OK.

Der "Neues Projekt" Dialog

Abb.1: Der Projekt-Template Browser

Anschließend verfügt man auch bereits über ein funktionierendes Spiel, das leider aber noch nichts anzeigt, wenn man es startet. Gestartet werden können XNA-Spiele direkt aus der Entwicklungsumgebung mit der Taste F5 oder dem grünen Pfeilchen in der Toolbar. Will man das Spiel aber an andere weiter geben, muss man noch ein paar andere Sachen erledigen. Dazu später mehr.

Im Projekt Explorer finden sich Dinge, die bei normalen C#-Projekten üblicherweise nicht vorhanden sind. Einerseits ist das ein Ordner namens “Content”. Er soll später ALLE (!!!) zusätzlichen Spiele-Assets wie Bilder/Texturen, Musik, Sound, Schriftarten,… enthalten, die im Spiel später benötigt werden. Andererseits ist auch bereits eine vorgefertigte Klasse “Game1” vorhanden. Ein Blick dort rein verrät einem, dass der Assistent bereits etwas Code hinterlegt hat. Den schauen wir uns auch gleich mal an.

Inhalt der Game1.cs

Abb. 2: Vorlage für Game1.cs

Das wichtigste an dieser Klasse ist, dass sie von der Basisklasse “Game” erbt. Diese Klasse stammt aus dem Namespace “Microsoft.Xna.Framework” ist ist damit Teil von XNA. Diese Klasse übernimmt die vollständige Initialisierungsarbeit der Grafikkarte und erzeugt bereits ein ordentliches Fenster für unser Spiel. Das ist auch der Grund, warum das Projekt bereits jetzt läuft, ohne, dass wir da eine Zeile Code geschrieben haben.

Außerdem scheint es schon fertige Methoden für bestimmte Aufgaben zu geben. Man findet beispielsweise eine Methode “Initialize” oder “LoadContent”. Diese Methoden werden verständlicher, wenn man sich man den groben Ablauf eines Spiels so anschaut.

Game Loop

Abb. 3: Schematischer Ablauf eines Spiels

In Abbildung 3 lässt sich sehen, wie der Ablauf eines Spiels grundsätzlich immer abläuft. Die Anwendung beginnt mit der Inistialisierung von Spieldaten. Das kann z.B. die Grundeinstellung eines Levels sein. Danach folgt der Ladevorgang der ganzen benötigten Assets wie Grafiken, Sounds,… Darauf startet das Spiel ins läuft in die sogenannte Gameloop. Eine ständige Schleife, die solange läuft, wie das Spiel gespielt wird. Diese Schleife wird im Idealfall sooft wie möglich durchlaufen und stellt die einzelnen Spielrunden dar. Bei Echtzeit-Spielen wie 3D-Shootern beispielsweise ist ein Schleifendurchlauf etwa mit einem Frame zu vergleichen. Innerhalb dieser Schleife werden zuerst die Spielereingaben abgeprüft (drückt der Spieler vielleicht gerade den Schuss-Knopf) um im darauffolgenden Schritt entsprechend darauf zu reagieren. Wenn die Szene dann neu berechnet wird, kommen gewöhnlich physikalische Einflüsse und die Usereingaben zum tragen, um die Szene auf den aktuellen Stand zu bringen. Gegenstände fallen, Waffe wird beim Betätigen eines Schussknopfes abgefeuert, der Munitionscounter muss eins nach unten gezählt werden,… solche Dinge. Sobald die Szene dann wieder auf dem aktuellen Stand ist, kann sie am Bildschirm im lezten Schritt auch wieder ausgegeben werden. Danach gehts wieder von vorne los. Beendet der Spieler das Spiel, so verlässt das Programm diese Schleife und räumt noch die Inhalte auf.

Diese Schritte lassen sich nun 1:1 im vorgenerierten Code wieder finden. “Initialize” wird zum initialisieren verwenden, “LoadContent” und “UnloadContent” werden vor und nach dem Start der Gameloop bemüht, um sich um die Assets zu kümmern und Loop selbst wird durch “Update” und “Draw” abgebildet. Wobei Update die Schritte der Eingabeabfrage und der Neuberechnung in sich vereint.

Schritt 2: Planung des Spielmodells

Damit so ein Spiel auch ordentlich funktioniert, muss man sich vorher ein kleines bisschen Gedanken über die Struktur, den Ablauf und die benötigten Rahmeninformationen machen. Im Konkreten heißt das, dass man sich Gedanken darüber machen muss, welche Dinge überhaupt relevant sind, wie groß das Level sein wird, in welcher Auflösung das Ganze dargestellt werden soll. Man macht sich auch Gedanken darüber, welche Objekte im Spiel vorhanden sind und über welche Informationen diese Objekte verfügen müssen.

Der Grundgedanke dabei ist, dass man während des Spiels ein Modell der Szene in objektorientierter Form vorliegen hat, damit die Berechnungen und der Rendervorgang strukturierter ablaufen kann. Für einfache, sehr einfache Spiele reichen naütlich einfache Variablen innerhalb der Game-Klasse, aber das führt schon sehr schnell zu einem großen Durcheinander, weshalb man nicht früh genug mit einer Modellplanung anfangen kann. Aber auch der umgekehrte Weg ist möglic. Zu viele Klassen und Objekte machen das Projekt auch sehr schnell wieder zu komplex. Ein guter Mittelweg ist da gefragt. Wie sieht das aus? Bei meiner ersten Analyse des Modells kam folgende Klassenstruktur heraus.

Klassenstruktur

Abb. 4: Erste Klassenstruktur

Die Gameklasse sollte also ein Level-Objekt halten, in dem zum einen ein Fahrgast- und ein Fahrziel-Objekt enthalten sein sollte. Ebenso ein Array aus Plattform-objekten, die die Landeplattformen repräsentierten. Game sollte außerdem ein Taxi-Objekt haben, das optional, je nachdem, ob das Taxi über einen Gast verfügt, auch die Referenz auf einen solchen enthalten. Natürlich sind innerhalb jeder dieser Klassen Rahmeninfos für das Objekt gespeichert.

Auf den ersten Blick scheint das sinnvoll, wird aber etwas absurder, wenn man sich mal das Level-Layout genauer überlegt und schaut, welche Infos denn nun wirklich in diesen Objekten stecken.

Wie könnte denn beispielsweise das Level-Layout so aussehen? Bei Spielen bedient man sich oft einem Konzept, das das ganze Level-Layout in ein Raster passt – quasi alles aus Blöcken gebaut ist. Das kann man bei Klassikern wie Super Mario sehen, zieht sich aber bis in die modernen Spiele. Man unterteilt die verfügbare Fläche in gleichgroße Blöcke und verwendet diese Einteilung als Grundlage für das Level-Design. Das bringt unglaublich viele Vorteile: Texturen können in festen Größen erstellt werden, die Berechnung der Kollisionen ist etwas einfacher und das Spielmodell wird überschaubar.

Raster der Spielfläche

Abb. 5: Rastern der Spielfläche

Das Spiel-Modell muss dann Plattformen, Fahrgäste oder Fahrziele nur noch als Flags in einem 2-dimensionalen Array aus Zellen speichern und keine eigenen Objekte mehr dafür erstellen. Das macht die Berechnung von Kollision und den Zeichenvorgang sehr, sehr einfach. Sehen wir später. Auf jeden Fall benötigen wir nun nur noch 2 zusätzliche Klassen: Das Taxi, um die aktuelle Position und den aktuellen Zustand zu speichern. Das Level, damit wir das aktuelle Level halten können.

Schritt 3: Die erste Zeichnung

Es wird nach dieser Theorie auch endlich mal Zeit was zu zeichnen. Dazu brauchen wir aber erstmal ein paar Texturen. Am einfachsten ist es wohl, diese Texturen aus dem Demo-Projekt zu übernehmen. Du findest die entsprechenden Dateien im Projektordner unter “MoonTaxi/Content”. Suche dort nach der Datei “Taxi.png” und ziehe die Datei per Drag&Drop einfach in dein Projekt zum Content-Ordner des Solution Explorers.

Assets importieren

Abb. 6: Assets importieren

Nachdem das Bild im Content-Ordner liegt, kann man in den Eigenschaften sehen, unter welchem Namen man das Bild im Code ansprechen kann (Asset Name). Um das Bild im Code jetzt aber wirklich verwenden zu können, muss im Code selbst noch eine Variable erstellt werden. Im oberen Teil der Game-Klasse wird dazu einfach eine Variable vom Typ “Texture2D” und benennt diese nach Wahl – vorzugsweise taxiTexture. Gefüllt wird diese dann innerhalb der Methde “LoadContent” mit der Zeile

taxiTexture = Content.Load<Texture2D>("Taxi");

geladen.  Soeben hast du eine der großen Vorteile von XNA verwendet: Die Content Pipeline. Ein großes Problem der Spieleentwicklung sind die Formate von Bildern, Sounds und sonstigen externen Daten. Üblicherweise müssen diese Daten vor Verwendung in einem Spiel noch entsprechend vorformatiert werden. Dabei gibt es sogar Unterschiede auf welchem System das Spiel später laufen soll. Die XBox erwartet beispielsweise ein anderes Sound-Format als das beim PC der Fall ist. Mit dem Verfahren über den Content-Ordner braucht man sich mit XNA nicht weiter darum kümmern, in welchem Format die Daten vorliegen. Die entsprechende Datei wird beim kompilieren von XNA passend aufbereitet und liegt dann für den Ladevorgang bereit.

Die Textur zu zeichen ist nun auch keine große Sache mehr. Wo zeichnen wir? natürlich in der Methode “Draw”. Da steht auch schon was drin. Das hier verwendete Objekt “GraphicsDevice” repräsentiert sozusagen unsere Grafikkarte. Sie verfügt über eine Methode Clear, die den gesamten Bildschirm wieder mit der angegebenen Farbe überschreibt. CornflowerBlue. Wer will, kann damit gerne mal etwas rum spielen.

Wir brauchen allerdings ein Hilfsobjekt, das Dank Game-Vorlage bereits existiert: Ein SpriteBatch. Ein SpriteBatch erlaubt die vollkommen unkomplizierte Zeichnung von 2D-Dingen direkt auf dem Bildschirm. Man muss das Ding aber erstmal starten und am Ende des Zeichenvorgangs auch wieder beenden, damit die Sachen auch wirklich gezeichnet werden. Zwischen diesen beiden Aufrufen werden dann die eigentlichen Texturen gezeichnet. Zum Test können wir da gerne mal das Taxi hin zeichen, damit man schonmal was sieht.

spriteBatch.Begin(); 
spriteBatch.Draw(taxiTexture, new Vector2(100, 100), Color.White); 
spriteBatch.End();

Wenn alles glatt gelaufen ist, kannst du jetzt beim Starten des Projektes (F5 oder den grünen Pfeil oben) schon eine blaue Fläche mit unserem Taxi sehen. Was haben wir da denn gemacht? In Draw werden 3 Parameter erwartet. Der erste ist die Textur, die gezeichnet werden soll. Parameter zwei ist ein zweidimensionaler Vector, der die Position der Textur angibt. Dabei ist die Position der linken, oberen Ecke der Textur, betrachtet von der linken, oberen Ecke des Zeichenfensters gemeint. Der dritte Parameter nimmt eine Farbe entgegen. Damit lässt sich die Textur nachträglich einfärben. Möchte man aber die original Farben der Textur beibehalten, gibt man hier einfach die Farbe Weiß (Color.White) an.

Das Resultat

Abb. 7: Erste Render-Ergebnisse

Schritt 4: Gravitation

Wir zeichnen! Hurra! Leider lässt die Interaktion mit dem Spieler zur Zeit etwas zu wünschen übrig. Es passiert nämlich noch garnichts. Es fehlen uns noch die äußeren Einflüsse und der Spieler-Input.

Damit wir das Taxi nun ordentlich bewegen können, müssen ein paar variable Informationen in unser Spielmodell einfließen. Das ist also der richtige Moment, um die Taxi-Klasse zu implementieren. Wir fügen also eine neue Klasse mit dem Namen “Taxi” zum Projekt hinzu und fügen den folgenden Code in die Klasse ein.

public Vector2 Position { get; set; } 
public Vector2 Speed { get; set; }

Für Vector2, einem Datentypen aus dem XNA-Framework, muss noch die Using-Direktive für das Framework eingefügt werden.

using Microsoft.Xna.Framework;

Wir speichern also in unserem Taximodell einerseits die Position – die soll ja in Zukunft variabel sein – und die Geschwindigkeit. Die Geschwindigkeit brauchen wir, weil sich das Taxi ja selbständig in einem physikalischen Raum bewegt. Es handelt sich um Masse die auf Gravitation reagiert und eine gewisse Trägheit besitzt. Wenn es sich einmal in Bewegung gesetzt hat, muss es sich im Laufe der Simulation auch weiterhin in diese Richtung bewegen, bis der User was unternimmt. Damit wir das ordentlich ausrechnen können, brauchen wir die Information der Geschwindigkeit.

Bewegung

Abb. 8: Bewegung des Taxis in Bezug auf die Runde

Jetzt brauchen wir noch eine Variable für das Taxi, die wir oben in der Game-Klasse deklarieren. Am besten in der Nähe der Texture von eben. Die passende Instanz dazu erzeugen wir natürlich in der Methode Initialize. Damit wir später keine Probleme kriegen, wenn wir das Level neu resetten wollen, führen wir an dieser Stelle schon eine zusätzliche Methode “Reset” in der Klasse Game ein, die uns das Level und das Taxi wieder in die Startposition versetzen sollen. Zu beginn legen wir die Startposition am besten mal auf P(100/100) und die Start-Geschwindigkeit natürlich auf 0/0.

private void Reset() 
{ 
    taxi.Position = new Vector2(100, 100); 
    taxi.Speed = new Vector2(); 
}

Aufgerufen wird diese Methode erstmal nur am Ende von Initialize. Später soll der Aufruf dieser Methode das Spiel zurück setzen. z.B. wenn der Spieler gegen eine Wand stößt.

Endlich können wir mit der Berechnung der Bewegung beginnen. Das passiert – na? – natürlich in Update. Die Bewegungberechnung besteht aus 2 Teilen. Im ersten Teil wird die Geschwindigkeitsangabe des Taxis neu berechnet. Wir müssen hier die Gravitation und später auch die Usereingabe berücksichtigen, bevor wir das Taxi dann tatsächlich in Schritt zwei bewegen und aus der Geschwindigkeitsangabe die neue Position des Taxis berechnen und setzen.

Im Schritt 1 schauen wir uns erstmal alle Kräfte an, die auf das Taxi wirken. Erstmal ist das nur die Gravitation. Diese wird mit ihrem Beschleunigungswert angegeben. Beschleunigung gibt man in der Regel mit der Geschwindigkeitsänderung pro Zeiteinheit an. Die Physik macht das üblicherweise in Meter pro Sekunde im Quadrat (m/s²). Dabei ist Meter pro Sekunde die Geschwindigkeitsangabe (m/s). Die Zeiteinheit ist auch wieder die Sekunde – daher das Quadrat (m/(s * s)).

Die Kräfte, die auf den Körper wirken müssen also nur addiert werden, um die Gesamtänderung der Geschwindigkeit zu ermitteln. Es gibt, wenn man diese Berechnung mit der Realität vergleicht, zwei wichtige Dinge zu beachten: Die Geschwindigkeit eines Fahrzeuges gibt man gerne in km/h an (um Grunde das selbe wie m/s, nur eben mit anderen Multiplikatoren), aber die Richtung zu kennen ist in diesem Zusammenhang sehr wichtig. Bei einem Auto würde man vielleicht von der Himmelsrichtung sprechen. Im Spiel verwenden wir aber eher ein 2-dimensionales Koordinatensystem, in dem die beiden Achsen X und Y existieren. Kräfte, Geschwindigkeiten und Beschleunigungen können dort für jede Achse getrennt betrachtet werden. Kommt eine dritte Dimension hinzu, kann das System einfach um diese Achse erweitert werden und funktioniert weiterhin. Deshalb besteht unsere Geschwindigkeitsangabe auch aus einem 2-dimensionalen Vektor.

Wir arbeiten hier übrigens nicht mit Metern, sondern mit Pixeln. Unsere Geschwindigkeit wird also in Pixel/Sekunde und die Beschleunigung mit Pixel pro Sekunde im Quadrat angegeben.

Geschwindigkeitsberechnung

Abb. 9: Geschwindigkeitsberechnung mit einwirkenden Kräften

Die zweite wichtige Sache, die man bei der rundenbasierten Berechnung beachten muss ist die Zeiteinheit. Natürliche Vorgänge wie die Beschleunigung eines Fahrzeugs passieren kontinuierlich. Leider sind wir im Spiel aber darauf angewiesen diese Berechnungen in den Simulationsrunden, also Schrittweise zu berechnen. Gerne werden Beschleunigungswerte so angegeben, dass sie pro Runde einfach zur Geschwindigkeit addiert werden können. Das ist aber physikalisch nicht korrekt, da der Zeitaspekt nicht korrekt berücksichtigt wurde. Man beachte, dass diese Simulationsrunden nicht immer in der selben Geschwindigkeit abgearbeitet werden. Es kann also sein, dass man im ersten Moment die Schleife 200 mal pro Sekunde durchläuft, im zweiten Augenblick aber mehr Berechnungen notwendig sind und deshalb nur 100 Runden zustande kommen. Würde man nun in jeder Runde den selben Wert addieren, bekäme man unregelmäßige Bewegungen des Taxis heraus. Lange Story, kurzer Sinn: Man muss bei der Berechnung von Bewegungen und Geschwidigkeitsänderungen immer darauf achten wie viel Zeit seit dem letzten Durchlauf vergangen ist. XNA übergibt den Methoden Update und Draw deshalb einen Parameter gameTime, aus dem sich diese Zeitdifferenz ermitteln lässt.

In der Update-Methode erzeugen wir zwei Variablen vom Typ float, die die Geschwindigkeitsänderung für die beiden Achsen X und Y enthalten sollen. Wir machen das, weil die Gravitation nur eine von vielen Kräften darstellt, die auf das Taxi wirken. Im Laufe des Tutorials werden noch weitere Kräfte hinzu kommen, die wir dann immer auf die beiden Variablen addieren können, um anschließend alles zusammengefasst auf das Taxi anzuwenden.

// Geschwindigkeitsänderungen 
float deltaY = 0; 
float deltaX = 0;

// Gravitation wirken 
deltaY = GRAVITY * (float)gameTime.ElapsedGameTime.TotalSeconds;

// Geschwindigkeitsänderung auf das Taxi anwenden 
taxi.Speed = new Vector2(taxi.Speed.X + deltaX, taxi.Speed.Y + deltaY);

// Neue Position des Taxis ermitteln 
taxi.Position += taxi.Speed * (float)gameTime.ElapsedGameTime.TotalSeconds;

Anstatt die Gravitationskraft hier direkt einzutragen, arbeite ich hier mit einer Konstante “GRAVITY”, die wir ganz oben in der Klasse noch deklarieren müssen. Diese Art der Konstanten erlauben einem Programmierer noch nachträglich an den Parametern des Spiels zu drehen. Später kommen noch weitere dieser Werte hinzu, um die Rahmenbedingungen des Spiels noch zu balancieren.

public const float GRAVITY = 9.81f;

Wir verändern jetzt zwar schon den Positionswert des Taxis, wir zeichnen die Textur aber immernoch an der festen Position. Das müssen wir noch ändern. In der Draw-Methode müssen wir dazu nur noch den Draw-Befehl durch die folgende Zeile ersetzen und ersetzen damit die feste Texturposition durch die berechneten Position des Taxis.

spriteBatch.Draw(taxiTexture, taxi.Position, Color.White);

Wenn wir starten, sehen wir bereits ein abstürzendes Taxi :)

Schritt 4: Spieler-Eingabe

Abstürzende Taxis kommen sicher nicht allzu gut an auf dem Spielemarkt, weshalb wir das Taxi nun mit Raketendüsen ausstatten, die der User steuern kann. XNA bietet Unterstützung für unterschiedliche Eingabegeräte. Zum einen der klassische XBox360-Controller, dann noch die Tastatur und auch eine Maus. Das Konzept ist aber für alle 3 Eingabegeräte gleich: Man fordert vom Framework zu einem bestimmten Zeitpunkt eine Momentaufnahme des aktuellen Gerätezustandes an und kann daraus dann ermitteln, welche Knöpfe gerade gedrückt sind, in welche Richtung sich die Maus bewegt oder wie stark einzelne Controller-Sticks gerade vom Spieler betätigt werden. Die passenden Klassen dazu finden sich im Namespace “Microsoft.Xna.Framework.Input”.

Es gibt dort statische Klassen Keyboard, GamePad und Mouse, die jeweils über eine statische GetState-Methode verfügen. Rückgabewert ist dann eine State des entsprechenden Gerätes. GamePad liefert ein struct vom Typ GamePadState.

Das Spiel soll später sowohl mit der Tastatur, als auch mit dem Gamepad spielbar sein, weshalb wir die Eingaben erst einmal auf sammeln und in eigene Variablen packen müssen. Dazu macht eine Sammlung der Möglichkeiten eines Spielers Sinn. Dieser kann 4 Steuerdüsen an den Seiten des Taxis aktivieren. Beim Gamepad soll der Schub auch entsprechend der Stärke, mit dem der Spieler den Stick betätigt, wirken. Verpackt wird das ganze in die beiden floats “forceX” und “forceY”. Der Wertebereich reicht von –1 (voller Gegenschub) bis 1 (voller Schub).

Das Schöne ist, dass das Gamepad genau diese Werte für die analog-Sticks zurück liefert. Wir können diese also direkt zuweisen. Die Tastatur hat aber leider keine analogen Eingabemöglichkeiten, weswegen wir direkt immer auf maximum Schub gehen müssen.

// Gerätestatus abfragen 
GamePadState gamePad = GamePad.GetState(PlayerIndex.One); 
KeyboardState keyboard = Keyboard.GetState();

// Zwischenvariablen für Schub 
float forceX = 0f; 
float forceY = 0f;

// Tastatur anwenden 
if (keyboard.IsKeyDown(Keys.Left))  forceX += -1f; 
if (keyboard.IsKeyDown(Keys.Right)) forceX += 1f; 
if (keyboard.IsKeyDown(Keys.Up))    forceY += 1f; 
if (keyboard.IsKeyDown(Keys.Down))  forceY += -1f;

// Gamepad anwenden 
forceX += gamePad.ThumbSticks.Left.X; 
forceY += gamePad.ThumbSticks.Left.Y;

// Limitierung auf den Wertebereich -1 bis 1 
forceX = Math.Min(Math.Max(-1f, forceX), 1f); 
forceY = Math.Min(Math.Max(-1f, forceY), 1f);

Fügst du den Code in deine Update-Methode ein (am besten an den Anfang, da wir die Variablen gleich bei der Bewegung brauchen), dann verfügst du ab hier in den force-Variablen über die Usereingabe und kannst darauf basierend die Krafteinwirkung auf das Taxi berechnen.

Was heißt das eigentlich? Mit den Raketen-Düsen am Taxi kann der Spieler jetzt Kräfte auf das Fluggerät wirken lassen. Diese verhalten sich recht ähnlich der Gravitationskraft, nur eben auf unterschiedlichen Achsen. Die linke Düse beispielsweise beweigt das Taxi auf der X-Achse nach rechts. Je stärker die Düse (das ist der Wert aus forceX), umso stärker auch die Kraft. Welchen Beschleunigungswert eine solche Düse hat, müssen wir dan wieder als Konstante weiter oben definieren. Ich habe diesen Wert jetzt einfach mal auf 30 Pixel pro Sekunde Quadrat festgelegt. bei vollem Schub ist die Düse also etwa 3 mal so stark wie die Gravitation.

public const float JETPOWER = 30f;

Die Kraft nun auch tatsächlich wirken zu lassen ist mit ein paar Zeilen Code in der Update-Methode erledigt. Wir hatten ja vorhin schon die beiden Variablen deltaX und deltaY erstellt, damit wir jetzt noch weitere Kräfte einfügen können. Folgende Zeilen, unter die Gravitationsberechnung eingefügt, sorgen für die notwendigen Berechnungen zur Taxi-Geschwindigkeit. Dass forceY hier mit einem Minus invertiert wird, liegt daran, dass die Y-Achse eines normalen Koordinatensystems nach oben größer wird. Im Zeichenfenster ist das aber umgekehrt, weshalb Y erstmal umgedreht werden muss, damit das Taxi nicht plötzlich in die falsche Richtung fliegt.

// Antriebsdüsen wirken lassen
deltaX += forceX * (float)gameTime.ElapsedGameTime.TotalSeconds * JETPOWER;
deltaY += -forceY *  (float)gameTime.ElapsedGameTime.TotalSeconds * JETPOWER;

Das Taxi lässt sich jetzt beim Start direkt steuern und man kann mit dem Taxi bereits etwas umher fliegen. Leider noch etwas langweilig, da man noch nichts machen kann, aber immerhin bewegt sich das Taxi jetzt nach realistischen, physikalischen Bedingungen. Levels, Düsen und einen Taxi-Gast gibts in Teil 2 des Tutorials.

Source zum Tutorialweiter zu Teil 2