Schüsse in Spielen (Pool)

Häufig fragen Einsteiger in Foren, wie sie ein Sprite oder eine Spielfigur dazu bringen, zu schießen. Insbesondere bei der Verwaltung der Schüsse gibt es immer wieder große Fragezeichen, denn es gibt hier den einen oder andere Fallstrick.

In diesem Mini-Tutorial möchte ich erklären, wie man ein Sprite dazu bringt, zu schießen, wie die Richtung des Schusses bestimmt wird und wie man eine Liste mit Schüssen verwaltet. Dazu werde ich mit Hilfe von ein paar Zeilen Code ein paar Beispiele aufzeigen, die in eigenen Spielen verwendet werden können und zum erweitern einladen.

Schüsse sind nicht sonderlich schwer, wenn man weis, wie diese angewendet werden. Das wichtigste ist erst einmal, daß wir uns einen Schuss erzeugen. Dies geht ziemlich einfach mit einer sogenannten Structure:

    public class Shoot
    {
        public Vector2 Position
        {
            get;
            set;
        }

        public Vector2 Direction
        {
            get;
            set;
        }

        public bool Alive
        {
            get;
            set;
        }

        public void Initialize(Vector2 startPosition, Vector2 direction)
        {
            this.Position = startPosition;
            this.Direction = direction;
            this.Alive = true;
        }

        public Shoot(Vector2 startPosition, Vector2 direction)
        {
            this.Initialize(startPosition, direction);
        }

        public void Update(GameTime gameTime)
        {
            this.Position += this.Direction * (float)gameTime.ElapsedGameTime.TotalSeconds;
        }
    }

Diese Klasse stellt nun unseren Schuss dar. Der Schuss hat eine Position und eine Richtung, sowie einen Konstruktor zum erzeugen eines neuen Schusses und eine Update-Methode um die Position des Schusses zu aktualisieren und ihn zu bewegen.

Die Besonderheit an dieser Klasse ist die Eigenschaft Alive vom Typ Boolean und die Methode Initialize die fast so aussieht, wie der Konstruktor, der diese Methode sogar aufruft. Dies hat einen Grund, wie im weiteren Verlauf erkennbar werden wird.

In der Game-Klasse erzeugen wir eine Member-Variable wie folgt:

List<Shoot> shoots = new List<Shoot>();

Dies ist unsere Liste mit Schüssen.

Die Update Methode muss auch erweitert werden, damit wir neue Schüsse erzeugen können. Diese werden immer in der Mitte des Bildschirms abgeschossen und sollen einfach langsam nach rechts fliegen. Wenn sie den Bildschirm verlassen, dann sollen sie verschwinden. Die Update-Methode wird dazu wie folgt erzeugt:

        KeyboardState lastState = Keyboard.GetState();

        protected override void Update(GameTime gameTime)
        {
            // Allows the game to exit
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
                this.Exit();

            KeyboardState keyboardState = Keyboard.GetState();

            if (keyboardState.IsKeyDown(Keys.Space) && lastState.IsKeyUp(Keys.Space))
            {
                createShoot();
            }

            lastState = keyboardState;

            foreach (Shoot s in shoots)
            {
                if (s.Alive)
                {
                    s.Update(gameTime);
                }

                if (s.Position.X > 800f)
                {
                    s.Alive = false;
                }
            }

            base.Update(gameTime);
        }

        private void createShoot()
        {
            foreach (Shoot s in shoots)
            {
                if (!s.Alive)
                {
                    s.Initialize(new Vector2(0f, 240f), new Vector2(75.0f, 0.0f));
                    return;
                }
            }

            shoots.Add(new Shoot(new Vector2(0f, 240f), new Vector2(75.0f, 0.0f)));
        }

Dies ist eine Menge Code, aber auch bereits das komplette Herzstück und sehr brauchbar. Auf die Tastaturbehandlung etc. werde ich jetzt nicht näher eingehen, da diese selbsterklärend sein sollte und ich dies als Grundlagen voraussetze. In Zeile 13 wird jedenfalls ein Schuss mit Hilfe der Methode createShoot() erzeugt, wenn die Leertaste gedrückt wird.

In den Zeilen 18 bis 29 werden die Schüsse bewegt und (Zeile 27) zerstört, wenn sie den Bildschirm verlassen (am rechten Rand).

Kommen wir nun zur – für den Anfänger etwas eigenartig aussehenden Methode – createShoot(), die neue Schüsse erzeugt. Um diese zu erklären, muss ich ein wenig ausholen. C# (genau wie Java) ist eine Programmiersprache, die einen sogenannten Garbage Collector verwendet, um den Speicher wieder freizugeben, den der Entwickler anfordert (z.B., wenn mit new eine neue Klasse instantiiert wird). Dies ist äußerst hilfreich und vermeidet es, daß sich der Entwickler darum selbst kümmern muss. Dies ist einer der Gründe, warum C# deutlich einfacher ist als z.B. C/C++. Das Problem bei der Sache ist jedoch, daß sowohl das Erzeugen, als auch das Zerstören von Klassen Zeit kostet. Insbesondere auf der XBox und dem Windows Phone (also alle XNA-Plattformen, die das sogenannte Compact-Framework) verwenden fällt dies extrem ins Gewicht und kann dazu führen, daß das Spiel sehr schnell anfängt zu ruckeln. Eine einfache Möglichkeit dies zu verhindern, ist ein sogenannter Pool.

Ein Pool ist ganz einfach eine Liste (oder ein Array), daß Objekte beeinhaltet, die bereits erzeugt wurden. Benötigt man einen neues Objekt, so muss dieses nicht erzeugt werden, sondern wird ganz einfach aus dem Pool geholt. Wird ein Schuss zerstört, so wird der Speicher nicht freigegeben, sondern er wird einfach als frei markiert und kann somit wieder verwendet werden.

Exakt dieses Verhalten wurde in der Methode createShoot umgesetzt. Als Markierung, ob ein Schuss gerade verwendet wird, oder nicht, dient die Variable Alive. Ist diese true, so wird der Schuss verwendet. Ist diese false so wird der Schuss nicht verwendet. Um einen Schuss freizugeben, wird diese Variable einfach auf false gesetzt. In Zeile 20 wird übrigens sichergestellt, daß nur Schüsse aktualisiert und bewegt werden, die tatsächlich verwendet werden.

Wir gehen also in Zeile 36 alle Schüsse in unserem Pool durch. Finden wir einen Schuss, der nicht verwendet wird, so setzen wir diesen auf die Anfangsposition (Zeile 40) und verlassen die Methode, da ein Schuss erzeugt wurde. Finden wir keinen Schuss in dieser Auflistung (was z.B. beim ersten Schuss der Fall ist), so erzeugen wir in Zeile 45 einen neuen Schuss und legen diesen in den Pool.

Alles was jetzt noch gemacht werden muss, ist das zeichnen von allen „lebendigen“ Schüssen. Dies ist wieder sehr einfach und sollte mit dem bereits erworbenen Wissen selbsterklärend sein:

            spriteBatch.Begin();

            foreach (Shoot s in shoots)
            {
                if (s.Alive)
                {
                    spriteBatch.Draw(shootTexture, s.Position, Color.White);
                }
            }

            spriteBatch.End();
Advertisements

Veröffentlicht am 27.01.2011, in Grundlagen, Mini-Tutorial, XNA. Setze ein Lesezeichen auf den Permalink. 14 Kommentare.

  1. Das sollte eine List sein, oder? Sonst funktioniert das nicht 😉

  2. Erstmal danke für diesen Quellcode
    Allerdings wird der Schuss, wenn er >800 geflogen ist komplett gesperrt und man kann nichtmehr weiter schiessen.

    • Hallo, erstmal gern geschehen 🙂

      Ich kann das Verhalten nicht nachvollziehen, da muss irgendwo bei dir ein Fehler sein. In Zeile 25 wird geprüft, ob der Schuss > 800 ist und der Status wird auf false gesetzt. Wird nun in Zeile 13 ein neuer Schuss angefordert, so werden alle Schüsse in Zeile 36-38 auf Inaktivität geprüft (welche ja durch Zeile 27 eingestellt wurde). Wurde ein Schuss gefunden, so wird dieser auf eine neue Position gesetzt und wieder aktiv geschaltet (Zeile 40) und vorzeitig abgebrochen (Zeile 41). Wird keiner dieser inaktiven Schüsse gefunden, so wird ein komplett neuer Schuss erzeugt (Zeile 45).

      Ich habe diesen Code direkt aus einem Beispielprojekt (zuhause) kopiert und dort funktioniert das schießen einwandfrei. Schick mir doch mal das Projekt, dann schau ich mir an, wo das Problem liegen könnte…

  3. Wäre es nicht schneller, mehrere Queues zu machen?

    Zu Beginn landen alle in der „inactive Queue“, sobald ein neuer Schuss erstellt wird, landet er in der „active Queue“ und bei einer Kollision wieder in der „inactive Queue“. Das Löschen und entfernen sollte bei einer Queue sehr schnell sein, bei vielen Schüssen könnte das iterieren über die gesamte Liste nicht ideal sein.

    • Das ist vollkommen korrekt, allerdings auch mit einer kleinen Einschränkung (siehe unten). Ich habe darauf jedoch aus zwei Gründen verzichtet:

      – Die Anzahl der Schüsse in diesem Demo ist relativ überschaubar und klein, so daß dies praktisch nicht ins Gewicht fällt
      – Ich wollte das Beispiel nicht komplizierter machen

      Das Löschen aus einer Liste (Queue<T> ist für die „Active List“ nicht möglich, jedoch für die „Inactive List“) ist jedoch ziemlich langsam. Ich empfehle um dies zu „verbessern“ den Einsatz der FastList<T> aus der starLiGHT.Engine. Diese ist beim Löschen und Clearen ungefähr zehn mal so schnell wie die List<T> von .NET. Dadurch würde der Vorteil von mehreren Listen unter Umständen komplett verschwinden.

  4. Edit: Die Standard Queue Klasse ist hier vll. nicht ideal, bei einer eigenen Implementierung, bei der jeder Schuss aber gleichzeitig einen Verweis auf den nächsten hat habe ich kaum Overhead (ein paar Referenzen neu setzen).

    • Das was du beschreibst nennt man SingleLinkedList. Diese hat deutliche Vorteile beim entfernen von Einträgen und auch beim hinzufügen. Die Nachteile (dier hier aber aufgrund der relativ kleinen Menge kaum ins Gewicht fallen) dieser Liste ist, daß sie zum einen nicht so schnell gelöscht werden kann (viele kleine Speicherbereiche wurden reserviert), der Garbage Collector stärker belastet wird (viele kleine, verkettete Objekte), deutlich mehr Speicher benötigt (bis zu 128Bit je Eintrag) und diese eine schlechtere Cache Coherence hat (aufgrund vieler, fragmentierter, aber verketteter Einzelobjekte).

  5. Schick mir doch mal bitte deine Mailadresse, dann schick ich dir mein Projekt 🙂

    Gruß
    Andre

  6. Hab ganz vergessen, dass ich noch was posten wollte, aber lieber spät als nie. Meine Idee ist eine Linked List zu verwenden (ob Doppelt oder einfach ist egal), bei der der Datentyp gleichzeitig Container ist.

    z.B. so:

    http://codepaste.net/9nv87s

    Das Snippet schützt vor Dummheit nicht, sollte aber relativ schnell sein denke ich:

    1. Bei erstellen und freigeben nur Setzen von Referenzen
    2. Alle items in einem Block allokiert.

    • Grundsätzlich gebe ich dir Recht, allerdings hat die LinkedList auch Nachteile. Man kann zwar relativ schnell und einfach neue Objekte hinzufügen und auch bestehende extrem schnell entfernen, aber zu einem recht großen Preis: Der Speicher fragmentiert dabei. Die Objekte liegen nicht mehr zwangsweise in einem zusammenhängenden Speicherbereich und pro Objekt gibt es einen Overhead von 8 – 16 Byte (32Bit und 64 Bit Plattformen, jeweils zwei Referenzen). Dies führt dazu, daß beim Iterieren der Liste, was ja ebenfalls sehr häufig passiert zwei Dinge passieren:

      1. Um zum nächsten Objekt zu kommen, muss jedesmal ein Zeiger dereferenziert werden. Dies geht zwar schnell, ist aber trotzdem ein Overhead gegenüber einem einfachen „Zeiger++“ (was intern beim iterieren eines Arrays passiert).
      2. Die Cache-Misses steigen. Die Cache-Coherence des Prozessors ist also deutlich schlechter, was dazu führt, daß der Prozessor die Daten langsamer verarbeitet. Optimierungen wie Prefetch etc. funktionieren nicht mehr.

      Zusätzlich zu alle dem steigt durch die höhere Anzahl von Objekten bzw. Speicherallozierungen (im Gegensatz zu einem Array) noch die Arbeit für den Garbage Collector, was gerade auf Plattformen wie XBox und Windows Phone problematisch ist. Komplexere Objekte und eine höhere Anzahl von Objekten führen zu einem deutlich langsameren arbeiten des GC.

      Man kann diesem ein wenig begegnen, wenn man anstatt einer DoubleLinkedList, wie du sie vorgeschlagen hast, eine SingleLinkedList verwendet. Dabei gibt es nur eine Referenz auf das nächste Objekt, nicht aber auf das vorherige. Dadurch sinkt der Speicherverbrauch und die Komplexität um 50%. Besser sind aber spezialisierte Datenstrukturen. Das kommt aber ein wenig auf den Einsatzzweck an.

  7. Hey danke fūr das Tutorial hat mir echt weiter geholfen, du könntest ja auch mal ein kleines Spiel entwickeln wie z.B ein Space Invaider und dann ein Tutorial dazu machen, du machst das echt Super, ich wünschte ich könnte auch so gut Programmieren wie du, leider komm ich grad nicht weiter 😦

Kommentar verfassen

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

WordPress.com-Logo

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

Twitter-Bild

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

Facebook-Foto

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

Google+ Foto

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

Verbinde mit %s

%d Bloggern gefällt das: