Die Erstellung von einem Neon-Vektor-Shooter in XNA: Mehr Gameplay
German (Deutsch) translation by Federicco Ancie (you can also view the original English article)
In dieser Reihe von Tutorials zeige ich Ihnen, wie Sie in XNA einen Neon-Twin-Stick-Shooter, wie Geometry Wars, erstellen. Das Ziel dieser Tutorials ist es nicht, Ihnen eine exakte Nachbildung von Geometry Wars zu hinterlassen, sondern die notwendigen Elemente durchzugehen, die es Ihnen ermöglichen, Ihre eigene hochwertige Variante zu erstellen.
Überblick
In diesem Teil bauen wir auf dem vorherigen Tutorial auf, indem wir Feinde, Kollisionserkennung und Wertung hinzufügen.
Folgendes werden wir am Ende haben:
Wir werden die folgenden neuen Klassen hinzufügen, um dies zu handhaben:
Enemy
-
EnemySpawner
: Verantwortlich für das Erstellen von Feinden und das schrittweise Erhöhen des Schwierigkeitsgrades des Spiels. -
PlayerStatus
: Verfolgt die Punktzahl, den Highscore und die Leben des Spielers.
Sie haben vielleicht bemerkt, dass es im Video zwei Arten von Feinden gibt, aber es gibt nur eine Enemy
-Klasse. Wir könnten für jeden Feindtyp Unterklassen von Enemy
ableiten. Ich ziehe es jedoch vor, tiefe Klassenhierarchien zu vermeiden, da sie einige Nachteile haben:
- Sie fügen mehr Boilerplate-Code hinzu.
- Sie können die Komplexität des Codes erhöhen und die Verständlichkeit erschweren. Zustand und Funktionalität eines Objekts werden über seine gesamte Vererbungskette verteilt.
- Sie sind nicht sehr flexibel. Sie können keine Funktionalitätsteile zwischen verschiedenen Zweigen des Vererbungsbaums freigeben, wenn diese Funktionalität nicht in der Basisklasse enthalten ist. Betrachten Sie beispielsweise zwei Klassen,
Mammal
undBird
, die beide vonAnimal
stammen. DieBird
-Klasse verfügt über eineFly()
-Methode. Dann entscheidest du dich, eineBat
-Klasse hinzuzufügen, die vonMammal
abgeleitet ist und auch fliegen kann. Um diese Funktionalität nur mit Vererbung zu teilen, müssten Sie dieFly()
-Methode in dieAnimal
-Klasse verschieben, in die sie nicht gehört. Darüber hinaus können Sie keine Methoden aus abgeleiteten Klassen entfernen. Wenn Sie also einePenguin
-Klasse erstellen, die vonBird
abgeleitet ist, hätte sie auch eineFly()
-Methode.
In diesem Tutorial werden wir die Komposition der Vererbung vorziehen, um die verschiedenen Arten von Feinden zu implementieren. Wir werden dies tun, indem wir verschiedene, wiederverwendbare Verhaltensweisen erstellen, die wir Feinden hinzufügen können. Wir können dann Verhaltensweisen leicht mischen und anpassen, wenn wir neue Arten von Feinden erschaffen. Wenn wir beispielsweise bereits ein FollowPlayer
-Verhalten und ein DodgeBullet
-Verhalten hatten, könnten wir einen neuen Feind erstellen, der beides tut, indem wir einfach beide Verhaltensweisen hinzufügen.
Feinde
Feinde haben einige zusätzliche Eigenschaften gegenüber Entitäten. Um dem Spieler etwas Zeit zum Reagieren zu geben, lassen wir Feinde allmählich einblenden, bevor sie aktiv und gefährlich werden.
Lassen Sie uns die grundlegende Struktur der Enemy
-Klasse codieren.
class Enemy : Entity { private int timeUntilStart = 60; public bool IsActive { get { return timeUntilStart <= 0; } } public Enemy(Texture2D image, Vector2 position) { this.image = image; Position = position; Radius = image.Width / 2f; color = Color.Transparent; } public override void Update() { if (timeUntilStart <= 0) { // enemy behaviour logic goes here. } else { timeUntilStart--; color = Color.White * (1 - timeUntilStart / 60f); } Position += Velocity; Position = Vector2.Clamp(Position, Size / 2, GameRoot.ScreenSize - Size / 2); Velocity *= 0.8f; } public void WasShot() { IsExpired = true; } }
Dieser Code lässt Feinde für 60 Frames einblenden und lässt ihre Geschwindigkeit funktionieren. Die Multiplikation der Geschwindigkeit mit 0,8 täuscht einen reibungsähnlichen Effekt vor. Wenn wir Feinde mit konstanter Geschwindigkeit beschleunigen lassen, bewirkt diese Reibung, dass sie sich reibungslos einer Höchstgeschwindigkeit nähern. Ich mag die Einfachheit und Glätte dieser Art von Reibung, aber Sie können je nach gewünschtem Effekt eine andere Formel verwenden.
Die Methode WasShot()
wird aufgerufen, wenn der Feind angeschossen wird. Wir werden später in der Serie mehr hinzufügen.
Wir wollen, dass sich verschiedene Arten von Feinden unterschiedlich verhalten. Wir erreichen dies, indem wir Verhaltensweisen zuweisen. Ein Verhalten verwendet eine benutzerdefinierte Funktion, die jeden Frame ausführt, um den Feind zu kontrollieren. Wir implementieren das Verhalten mit einem iterator.
Iteratoren (auch Generatoren genannt) in C# sind spezielle Methoden, die zwischendurch anhalten und später dort wieder aufnehmen können, wo sie aufgehört haben. Sie können einen Iterator erstellen, indem Sie eine Methode mit dem Rückgabetyp IEnumerable<>
erstellen und das yield-Schlüsselwort dort verwenden, wo es zurückgegeben und später fortgesetzt werden soll. Bei Iteratoren in C# müssen Sie etwas zurückgeben, wenn Sie nachgeben. Wir müssen nicht wirklich etwas zurückgeben, also liefern unsere Iteratoren einfach null.
Unser einfachstes Verhalten ist das unten gezeigte FollowPlayer()
-Verhalten.
IEnumerable<int> FollowPlayer(float acceleration = 1f) { while (true) { Velocity += (PlayerShip.Instance.Position - Position).ScaleTo(acceleration); if (Velocity != Vector2.Zero) Orientation = Velocity.ToAngle(); yield return 0; } }
Dadurch beschleunigt der Feind einfach mit konstanter Geschwindigkeit auf den Spieler zu. Die Reibung, die wir zuvor hinzugefügt haben, stellt sicher, dass sie schließlich mit einer maximalen Geschwindigkeit (5 Pixel pro Bild, wenn die Beschleunigung 1 ist, da \(0,8 \mal 5 + 1 = 5\)) erreicht wird. In jedem Frame wird diese Methode ausgeführt, bis sie die Yield-Anweisung trifft, und wird dann dort fortgesetzt, wo sie im nächsten Frame aufgehört hat.
Sie fragen sich vielleicht, warum wir uns überhaupt mit Iteratoren beschäftigt haben, da wir die gleiche Aufgabe mit einem einfachen Delegaten leichter hätten erledigen können. Die Verwendung von Iteratoren zahlt sich bei komplexeren Methoden aus, die andernfalls erfordern würden, dass wir den Zustand in Membervariablen in der Klasse speichern.
Unten ist beispielsweise ein Verhalten, das einen Feind in einem quadratischen Muster bewegt:
IEnumerable<int> MoveInASquare() { const int framesPerSide = 30; while (true) { // move right for 30 frames for (int i = 0; i < framesPerSide; i++) { Velocity = Vector2.UnitX; yield return 0; } // move down for (int i = 0; i < framesPerSide; i++) { Velocity = Vector2.UnitY; yield return 0; } // move left for (int i = 0; i < framesPerSide; i++) { Velocity = -Vector2.UnitX; yield return 0; } // move up for (int i = 0; i < framesPerSide; i++) { Velocity = -Vector2.UnitY; yield return 0; } } }
Das Schöne daran ist, dass es uns nicht nur einige Instanzvariablen spart, sondern den Code auch sehr logisch strukturiert. Sie können sofort sehen, dass sich der Feind nach rechts bewegt, dann nach unten, dann nach links, dann nach oben und dann wiederholt. Würden Sie diese Methode stattdessen als Zustandsmaschine implementieren, wäre der Kontrollfluss weniger offensichtlich.
Fügen wir das Gerüst hinzu, das erforderlich ist, damit Verhaltensweisen funktionieren. Feinde müssen ihr Verhalten speichern, daher fügen wir der Klasse Enemy
eine Variable hinzu.
private List<IEnumerator<int>> behaviours = new List<IEnumerator<int>>();
Beachten Sie, dass ein Verhalten den Typ IEnumerator<int>
hat, nicht IEnumerable<int>
. Sie können sich IEnumerable
als Vorlage für das Verhalten und IEnumerator
als laufende Instanz vorstellen. Der IEnumerator
merkt sich, wo wir uns im Verhalten befinden und wird dort weitermachen, wo er aufgehört hat, wenn Sie seine MoveNext()
-Methode aufrufen. In jedem Frame gehen wir alle Verhaltensweisen des Feindes durch und rufen MoveNext()
auf jedem von ihnen auf. Wenn MoveNext()
false zurückgibt, bedeutet dies, dass das Verhalten abgeschlossen ist und wir es aus der Liste entfernen sollten.
Wir fügen der Enemy
-Klasse die folgenden Methoden hinzu:
private void AddBehaviour(IEnumerable<int> behaviour) { behaviours.Add(behaviour.GetEnumerator()); } private void ApplyBehaviours() { for (int i = 0; i < behaviours.Count; i++) { if (!behaviours[i].MoveNext()) behaviours.RemoveAt(i--); } }
Und wir ändern die Update()
-Methode, um ApplyBehaviours()
aufzurufen:
if (timeUntilStart <= 0) ApplyBehaviours(); // ...
Jetzt können wir eine statische Methode erstellen, um suchende Feinde zu erstellen. Alles, was wir tun müssen, ist das gewünschte Bild auszuwählen und das Verhalten von FollowPlayer()
hinzuzufügen.
public static Enemy CreateSeeker(Vector2 position) { var enemy = new Enemy(Art.Seeker, position); enemy.AddBehaviour(enemy.FollowPlayer()); return enemy; }
Um einen Feind zu erschaffen, der sich zufällig bewegt, lassen wir ihn eine Richtung wählen und nehmen dann kleine zufällige Anpassungen an dieser Richtung vor. Wenn wir jedoch bei jedem Frame die Richtung anpassen, wird die Bewegung unruhig, sodass wir die Richtung nur in regelmäßigen Abständen anpassen. Wenn der Feind in den Rand des Bildschirms rennt, lassen wir ihn eine neue zufällige Richtung wählen, die von der Wand weg zeigt.
IEnumerable<int> MoveRandomly() { float direction = rand.NextFloat(0, MathHelper.TwoPi); while (true) { direction += rand.NextFloat(-0.1f, 0.1f); direction = MathHelper.WrapAngle(direction); for (int i = 0; i < 6; i++) { Velocity += MathUtil.FromPolar(direction, 0.4f); Orientation -= 0.05f; var bounds = GameRoot.Viewport.Bounds; bounds.Inflate(-image.Width, -image.Height); // if the enemy is outside the bounds, make it move away from the edge if (!bounds.Contains(Position.ToPoint())) direction = (GameRoot.ScreenSize / 2 - Position).ToAngle() + rand.NextFloat(-MathHelper.PiOver2, MathHelper.PiOver2); yield return 0; } } }
Wir können jetzt eine Fabrikmethode erstellen, um wandernde Feinde zu erschaffen, ähnlich wie wir es für den Sucher getan haben:
public static Enemy CreateWanderer(Vector2 position) { var enemy = new Enemy(Art.Wanderer, position); enemy.AddBehaviour(enemy.MoveRandomly()); return enemy; }
Kollisionserkennung
Zur Kollisionserkennung modellieren wir das Schiff des Spielers, die Feinde und die Kugeln als Kreise. Die Erkennung kreisförmiger Kollisionen ist gut, weil sie einfach, schnell und nicht drehbar ist, wenn sich die Objekte drehen. Wie Sie sich erinnern, hat die Entity
-Klasse einen Radius und eine Position (die Position bezieht sich auf den Mittelpunkt der Entity). Dies ist alles, was wir für die zirkuläre Kollisionserkennung benötigen.
Das Testen jeder Entität gegen alle anderen Entitäten, die potenziell kollidieren könnten, kann sehr langsam sein, wenn Sie über eine große Anzahl von Entitäten verfügen. Es gibt viele Techniken, die Sie verwenden können, um die Erkennung von Kollisionen mit breiter Phase zu beschleunigen, wie Quadtrees, Sweep and Prune und BSP-Bäume. Derzeit werden jedoch nur ein paar Dutzend Entitäten gleichzeitig auf dem Bildschirm angezeigt, sodass wir uns nicht um diese komplexeren Techniken kümmern müssen. Wir können sie jederzeit später hinzufügen, wenn wir sie brauchen.
In Shape Blaster kann nicht jede Entität mit jeder anderen Art von Entität kollidieren. Kugeln und das Schiff des Spielers können nur mit Feinden kollidieren. Feinde können auch mit anderen Feinden kollidieren - dies verhindert, dass sie sich überlappen.
Um mit diesen verschiedenen Arten von Kollisionen umzugehen, werden wir dem EntityManager
zwei neue Listen hinzufügen, um Kugeln und Feinde im Auge zu behalten. Immer wenn wir dem EntityManager
eine Entität hinzufügen, möchten wir sie der entsprechenden Liste hinzufügen, also erstellen wir dafür eine private AddEntity()
-Methode. Wir werden auch sicherstellen, dass alle abgelaufenen Entitäten aus allen Listen jedes Frames entfernt werden.
static List<Enemy> enemies = new List<Enemy>(); static List<Bullet> bullets = new List<Bullet>(); private static void AddEntity(Entity entity) { entities.Add(entity); if (entity is Bullet) bullets.Add(entity as Bullet); else if (entity is Enemy) enemies.Add(entity as Enemy); } // ... // in Update() bullets = bullets.Where(x => !x.IsExpired).ToList(); enemies = enemies.Where(x => !x.IsExpired).ToList();
Ersetzen Sie die Aufrufe von entity.Add()
in EntityManager.Add()
und EntityManager.Update()
durch Aufrufe von AddEntity()
.
Fügen wir nun eine Methode hinzu, die bestimmt, ob zwei Entitäten kollidieren:
private static bool IsColliding(Entity a, Entity b) { float radius = a.Radius + b.Radius; return !a.IsExpired && !b.IsExpired && Vector2.DistanceSquared(a.Position, b.Position) < radius * radius; }
Um festzustellen, ob sich zwei Kreise überlappen, prüfen Sie einfach, ob der Abstand zwischen ihnen kleiner ist als die Summe ihrer Radien. Unsere Methode optimiert dies geringfügig, indem sie überprüft, ob das Quadrat der Entfernung kleiner ist als das Quadrat der Summe der Radien. Denken Sie daran, dass es etwas schneller ist, die Entfernung zum Quadrat zu berechnen als die tatsächliche Entfernung.
Je nachdem, welche zwei Objekte kollidieren, passieren verschiedene Dinge. Wenn zwei Feinde kollidieren, wollen wir, dass sie sich gegenseitig wegstoßen. Wenn eine Kugel einen Feind trifft, sollten sowohl die Kugel als auch der Feind zerstört werden. Wenn der Spieler einen Feind berührt, sollte der Spieler sterben und das Level sollte zurückgesetzt werden.
Wir werden der Enemy
-Klasse eine HandleCollision()
-Methode hinzufügen, um Kollisionen zwischen Feinden zu behandeln:
public void HandleCollision(Enemy other) { var d = Position - other.Position; Velocity += 10 * d / (d.LengthSquared() + 1); }
Diese Methode drückt den aktuellen Feind vom anderen Feind weg. Je näher sie sind, desto schwerer wird es geschoben, da die Größe von (d / d.LengthSquared())
über die Distanz nur eins beträgt.
Respawnen des Spielers
Als nächstes brauchen wir eine Methode, mit der das Schiff des Spielers getötet wird. In diesem Fall verschwindet das Schiff des Spielers für kurze Zeit, bevor es wieder erscheint.
Wir beginnen damit, dass wir zwei neue Mitglieder zu PlayerShip
hinzufügen.
int framesUntilRespawn = 0; public bool IsDead { get { return framesUntilRespawn > 0; } }
Fügen Sie ganz am Anfang von PlayerShip.Update()
Folgendes hinzu:
if (IsDead) { framesUntilRespawn--; return; }
Und wir überschreiben Draw()
wie gezeigt:
public override void Draw(SpriteBatch spriteBatch) { if (!IsDead) base.Draw(spriteBatch); }
Schließlich fügen wir PlayerShip
eine Kill()
-Methode hinzu.
public void Kill() { framesUntilRespawn = 60; }
Nachdem alle Teile vorhanden sind, fügen wir dem EntityManager
eine Methode hinzu, die alle Entitäten durchläuft und auf Kollisionen prüft.
static void HandleCollisions() { // handle collisions between enemies for (int i = 0; i < enemies.Count; i++) for (int j = i + 1; j < enemies.Count; j++) { if (IsColliding(enemies[i], enemies[j])) { enemies[i].HandleCollision(enemies[j]); enemies[j].HandleCollision(enemies[i]); } } // handle collisions between bullets and enemies for (int i = 0; i < enemies.Count; i++) for (int j = 0; j < bullets.Count; j++) { if (IsColliding(enemies[i], bullets[j])) { enemies[i].WasShot(); bullets[j].IsExpired = true; } } // handle collisions between the player and enemies for (int i = 0; i < enemies.Count; i++) { if (enemies[i].IsActive && IsColliding(PlayerShip.Instance, enemies[i])) { PlayerShip.Instance.Kill(); enemies.ForEach(x => x.WasShot()); break; } } }
Rufen Sie diese Methode von Update()
direkt nach dem Setzen von isUpdating
auf true
auf.
Feindlicher Spawner
Als letztes müssen Sie die Klasse EnemySpawner
erstellen, die für die Erstellung von Feinden verantwortlich ist. Wir möchten, dass das Spiel einfach beginnt und schwieriger wird, damit der EnemySpawner
im Laufe der Zeit immer schneller Feinde erzeugt. Wenn der Spieler stirbt, setzen wir den EnemySpawner
auf seine anfängliche Schwierigkeit zurück.
static class EnemySpawner { static Random rand = new Random(); static float inverseSpawnChance = 60; public static void Update() { if (!PlayerShip.Instance.IsDead && EntityManager.Count < 200) { if (rand.Next((int)inverseSpawnChance) == 0) EntityManager.Add(Enemy.CreateSeeker(GetSpawnPosition())); if (rand.Next((int)inverseSpawnChance) == 0) EntityManager.Add(Enemy.CreateWanderer(GetSpawnPosition())); } // slowly increase the spawn rate as time progresses if (inverseSpawnChance > 20) inverseSpawnChance -= 0.005f; } private static Vector2 GetSpawnPosition() { Vector2 pos; do { pos = new Vector2(rand.Next((int)GameRoot.ScreenSize.X), rand.Next((int)GameRoot.ScreenSize.Y)); } while (Vector2.DistanceSquared(pos, PlayerShip.Instance.Position) < 250 * 250); return pos; } public static void Reset() { inverseSpawnChance = 60; } }
In jedem Frame gibt es einen in inverseSpawnChance
, mit dem jeder Feindtyp generiert werden kann. Die Chance, einen Feind zu spawnen, steigt allmählich, bis sie ein Maximum von eins zu zwanzig erreicht. Feinde werden immer mindestens 250 Pixel vom Spieler entfernt erstellt.
Seien Sie vorsichtig mit der while-Schleife in GetSpawnPosition()
. Es funktioniert effizient, solange der Bereich, in dem Feinde spawnen können, größer ist als der Bereich, in dem sie nicht spawnen können. Wenn Sie den verbotenen Bereich jedoch zu groß machen, erhalten Sie eine Endlosschleife.
Rufen Sie EnemySpawner.Update()
von GameRoot.Update()
auf und rufen Sie EnemySpawner.Reset()
auf, wenn der Spieler getötet wird.
Punktzahl und Leben
In Shape Blaster beginnen Sie mit vier Leben und erhalten alle 2000 Punkte ein zusätzliches Leben. Sie erhalten Punkte für die Zerstörung von Feinden, wobei verschiedene Arten von Feinden unterschiedliche Punkte wert sind. Jeder zerstörte Feind erhöht auch Ihren Punktemultiplikator um eins. Wenn Sie innerhalb kurzer Zeit keine Feinde töten, wird Ihr Multiplikator zurückgesetzt. Die Gesamtpunktzahl, die Sie von jedem zerstörten Feind erhalten, ist die Anzahl der Punkte, die der Feind wert ist, multipliziert mit Ihrem aktuellen Multiplikator. Wenn Sie alle Ihre Leben verlieren, ist das Spiel vorbei und Sie beginnen ein neues Spiel, wobei Ihre Punktzahl auf Null zurückgesetzt wird.
Um all dies zu handhaben, erstellen wir eine statische Klasse namens PlayerStatus
.
static class PlayerStatus { // amount of time it takes, in seconds, for a multiplier to expire. private const float multiplierExpiryTime = 0.8f; private const int maxMultiplier = 20; public static int Lives { get; private set; } public static int Score { get; private set; } public static int Multiplier { get; private set; } private static float multiplierTimeLeft; // time until the current multiplier expires private static int scoreForExtraLife; // score required to gain an extra life // Static constructor static PlayerStatus() { Reset(); } public static void Reset() { Score = 0; Multiplier = 1; Lives = 4; scoreForExtraLife = 2000; multiplierTimeLeft = 0; } public static void Update() { if (Multiplier > 1) { // update the multiplier timer if ((multiplierTimeLeft -= (float)GameRoot.GameTime.ElapsedGameTime.TotalSeconds) <= 0) { multiplierTimeLeft = multiplierExpiryTime; ResetMultiplier(); } } } public static void AddPoints(int basePoints) { if (PlayerShip.Instance.IsDead) return; Score += basePoints * Multiplier; while (Score >= scoreForExtraLife) { scoreForExtraLife += 2000; Lives++; } } public static void IncreaseMultiplier() { if (PlayerShip.Instance.IsDead) return; multiplierTimeLeft = multiplierExpiryTime; if (Multiplier < maxMultiplier) Multiplier++; } public static void ResetMultiplier() { Multiplier = 1; } public static void RemoveLife() { Lives--; } }
Rufen Sie PlayerStatus.Update()
von GameRoot.Update()
auf, wenn das Spiel nicht angehalten ist.
Als nächstes möchten wir Ihre Punktzahl, Leben und Multiplikator auf dem Bildschirm anzeigen. Dazu müssen wir eine SpriteFont
im Content
-Projekt und eine entsprechende Variable in der Art
-Klasse hinzufügen, die wir Font
nennen. Laden Sie die Schriftart in Art.Load()
wie bei den Texturen.
Ändern Sie das Ende von GameRoot.Draw()
, an dem der Cursor wie unten gezeigt gezeichnet wird.
spriteBatch.Begin(0, BlendState.Additive); spriteBatch.DrawString(Art.Font, "Lives: " + PlayerStatus.Lives, new Vector2(5), Color.White); DrawRightAlignedString("Score: " + PlayerStatus.Score, 5); DrawRightAlignedString("Multiplier: " + PlayerStatus.Multiplier, 35); // draw the custom mouse cursor spriteBatch.Draw(Art.Pointer, Input.MousePosition, Color.White); spriteBatch.End();
DrawRightAlignedString()
ist eine Hilfsmethode zum Zeichnen von Text, der auf der rechten Seite des Bildschirms ausgerichtet ist. Fügen Sie es zu GameRoot
hinzu, indem Sie den folgenden Code hinzufügen.
private void DrawRightAlignedString(string text, float y) { var textWidth = Art.Font.MeasureString(text).X; spriteBatch.DrawString(Art.Font, text, new Vector2(ScreenSize.X - textWidth - 5, y), Color.White); }
Jetzt sollten Ihr Leben, Ihre Punktzahl und Ihr Multiplikator auf dem Bildschirm angezeigt werden. Wir müssen diese Werte jedoch noch als Reaktion auf Spielereignisse ändern. Fügen Sie der Enemy
-Klasse eine Eigenschaft namens PointValue
hinzu.
public int PointValue { get; private set; }
Stellen Sie den Punktwert für verschiedene Feinde auf einen Wert ein, der Ihrer Meinung nach angemessen ist. Ich habe den wandernden Feinden einen Punkt wert gemacht und den suchenden Feinden zwei Punkte.
Fügen Sie als Nächstes die folgenden zwei Zeilen zu Enemy.WasShot()
hinzu, um die Punktzahl und den Multiplikator des Spielers zu erhöhen:
PlayerStatus.AddPoints(PointValue); PlayerStatus.IncreaseMultiplier();
Rufen Sie PlayerStatus.RemoveLife()
in PlayerShip.Kill()
auf. Wenn der Spieler alle seine Leben verliert, rufen Sie PlayerStatus.Reset()
auf, um seinen Punktestand und seine Leben zu Beginn eines neuen Spiels zurückzusetzen.
Highscores
Lassen Sie uns die Möglichkeit für das Spiel hinzufügen, Ihre beste Punktzahl zu verfolgen. Wir möchten, dass diese Punktzahl über alle Spiele hinweg erhalten bleibt, also speichern wir sie in einer Datei. Wir halten es wirklich einfach und speichern den Highscore als einzelne Klartextnummer in einer Datei im aktuellen Arbeitsverzeichnis (dies ist das gleiche Verzeichnis, das die .exe
-Datei des Spiels enthält).
Fügen Sie PlayerStatus
die folgenden Methoden hinzu:
private const string highScoreFilename = "highscore.txt"; private static int LoadHighScore() { // return the saved high score if possible and return 0 otherwise int score; return File.Exists(highScoreFilename) && int.TryParse(File.ReadAllText(highScoreFilename), out score) ? score : 0; } private static void SaveHighScore(int score) { File.WriteAllText(highScoreFilename, score.ToString()); }
Die Methode LoadHighScore()
überprüft zuerst, ob die Highscore-Datei vorhanden ist, und überprüft dann, ob sie eine gültige Ganzzahl enthält. Die zweite Überprüfung wird höchstwahrscheinlich nie fehlschlagen, es sei denn, der Benutzer ändert die Highscore-Datei manuell in etwas Ungültiges, aber es ist ratsam, vorsichtig zu sein.
Wir möchten den Highscore beim Start des Spiels laden und speichern, wenn der Spieler einen neuen Highscore erreicht. Dazu ändern wir den statischen Konstruktor und die Reset()
-Methode in PlayerStatus
. Wir fügen auch eine Hilfseigenschaft hinzu, IsGameOver
, die wir gleich verwenden werden.
public static bool IsGameOver { get { return Lives == 0; } } static PlayerStatus() { HighScore = LoadHighScore(); Reset(); } public static void Reset() { if (Score > HighScore) SaveHighScore(HighScore = Score); Score = 0; Multiplier = 1; Lives = 4; scoreForExtraLife = 2000; multiplierTimeLeft = 0; }
Das sorgt dafür, dass der Highscore verfolgt wird. Jetzt müssen wir es anzeigen. Fügen Sie GameRoot.Draw()
im selben SpriteBatch
-Block, in dem der andere Text gezeichnet wird, den folgenden Code hinzu:
if (PlayerStatus.IsGameOver) { string text = "Game Over\n" + "Your Score: " + PlayerStatus.Score + "\n" + "High Score: " + PlayerStatus.HighScore; Vector2 textSize = Art.Font.MeasureString(text); spriteBatch.DrawString(Art.Font, text, ScreenSize / 2 - textSize / 2, Color.White); }
Dadurch werden Ihre Punktzahl und Ihr Highscore nach dem Spiel zentriert auf dem Bildschirm angezeigt.
Als letzte Anpassung erhöhen wir die Zeit, bevor das Schiff im Spiel wieder erscheint, um dem Spieler Zeit zu geben, seine Punktzahl zu sehen. Modifiziere PlayerShip.Kill()
, indem du die Respawn-Zeit auf 300 Frames (fünf Sekunden) setzt, wenn der Spieler kein Leben mehr hat.
// in PlayerShip.Kill() PlayerStatus.RemoveLife(); framesUntilRespawn = PlayerStatus.IsGameOver ? 300 : 120;
Das Spiel ist jetzt spielbereit. Es sieht vielleicht nicht nach viel aus, aber es sind alle grundlegenden Mechaniken implementiert. In zukünftigen Tutorials werden wir einen Blütenfilter und Partikeleffekte hinzufügen, um es aufzupeppen. Aber jetzt fügen wir schnell etwas Sound und Musik hinzu, um es interessanter zu machen.
Ton und Musik
Das Abspielen von Sound und Musik ist in XNA einfach. Zuerst fügen wir der Content-Pipeline unsere Soundeffekte und Musik hinzu. Stellen Sie im Bereich Eigenschaften sicher, dass der Inhaltsprozessor für die Musik auf Song
und für die Sounds auf Sound Effect
eingestellt ist.
Als nächstes erstellen wir eine statische Hilfsklasse für die Sounds.
static class Sound { public static Song Music { get; private set; } private static readonly Random rand = new Random(); private static SoundEffect[] explosions; // return a random explosion sound public static SoundEffect Explosion { get { return explosions[rand.Next(explosions.Length)]; } } private static SoundEffect[] shots; public static SoundEffect Shot { get { return shots[rand.Next(shots.Length)]; } } private static SoundEffect[] spawns; public static SoundEffect Spawn { get { return spawns[rand.Next(spawns.Length)]; } } public static void Load(ContentManager content) { Music = content.Load<Song>("Sound/Music"); // These linq expressions are just a fancy way loading all sounds of each category into an array. explosions = Enumerable.Range(1, 8).Select(x => content.Load<SoundEffect>("Sound/explosion-0" + x)).ToArray(); shots = Enumerable.Range(1, 4).Select(x => content.Load<SoundEffect>("Sound/shoot-0" + x)).ToArray(); spawns = Enumerable.Range(1, 8).Select(x => content.Load<SoundEffect>("Sound/spawn-0" + x)).ToArray(); } }
Da wir mehrere Variationen jedes Sounds haben, wählen die Eigenschaften Explosion
, Shot
und Spawn
einen zufälligen Sound aus den Varianten aus.
Rufen Sie Sound.Load()
in GameRoot.LoadContent()
auf. Um die Musik abzuspielen, fügen Sie die folgenden zwei Zeilen am Ende von GameRoot.Initialize()
hinzu.
MediaPlayer.IsRepeating = true; MediaPlayer.Play(Sound.Music);
Um Sounds in XNA abzuspielen, können Sie einfach die Play()
-Methode für einen SoundEffect
aufrufen. Diese Methode bietet auch eine Übersteuerung, mit der Sie Lautstärke, Tonhöhe und Panorama des Sounds anpassen können. Ein Trick, um unsere Sounds abwechslungsreicher zu gestalten, besteht darin, diese Mengen bei jedem Spiel anzupassen.
Um den Soundeffekt für das Schießen auszulösen, fügen Sie die folgende Zeile in PlayerShip.Update()
innerhalb der if-Anweisung hinzu, in der die Aufzählungszeichen erstellt werden. Beachten Sie, dass wir die Tonhöhe zufällig bis zu einer Quinte einer Oktave nach oben oder unten verschieben, damit sich die Klänge weniger wiederholen.
Sound.Shot.Play(0.2f, rand.NextFloat(-0.2f, 0.2f), 0);
Lösen Sie auf ähnliche Weise jedes Mal, wenn ein Feind zerstört wird, einen Explosionssoundeffekt aus, indem Sie Enemy.WasShot()
Folgendes hinzufügen.
Sound.Explosion.Play(0.5f, rand.NextFloat(-0.2f, 0.2f), 0);
Sie haben jetzt Sound und Musik in Ihrem Spiel. Einfach, nicht wahr?
Abschluss
Damit sind die grundlegenden Spielmechaniken abgeschlossen. Im nächsten Tutorial fügen wir einen Bloom-Filter hinzu, um die Neonlichter zum Leuchten zu bringen.