Inhaltsverzeichnis

SDL-Tutorial #4 - Und es bewegt sich doch!

Autor: Nicolai 'Prefect' Haehnle

Bis jetzt haben wir zwar schon einiges über Bitmaps gelernt, aber es hat sich noch nie etwas auf dem Bildschirm bewegt. Und ohne Bewegung sind Spiele ziemlich witzlos, oder?

In diesem Kapitel zeige ich Euch, wie Ihr das Programm aus dem letzten Kapitel zum Scrollen bringt. Übrigens werde ich hier kein komplettes Programmlisting mehr hereinstellen, sondern beziehe mich nur noch auf Zeilennummern und Funktionen aus dem Programm des letzten Kapitels.

Der erste Versuch

Zuerst wollen wir per Tastatur scrollen.
Schon die letzten Kapitel haben ja eigentlich auf die Tastatur reagiert: sie haben sich bei Tastendruck einfach beendet. Der zugehörige Programmteil befindet sich in dem switch(event.type)-Statement. Wir könnten da ja eigentlich Code reinschreiben, der überprüft, welche Taste eigentlich gedrückt wurde, und dann entsprechend scrollen. Genau das tun wir jetzt auch erstmal.
Als erstes aber muß eine, oder eigentlich zwei, Variablen her, in denen die momentane Position gespeichert wird. Fügen wir also zwei Integervariablen scrollx und scrolly in main() ein, in denen jeweils die X- und Y-Position gespeichert wird. Ich habe die Variablen um Zeile 74 herum eingefügt:

{
    int running;
    int scrollx, scrolly;

Natürlich müssen diese Variablen vor der Programmschleife noch mit vernünftigen Werten initialisiert werden, deshalb habe ich den Code um Zeile 120 entsprechend modifiziert:

    running = 1;
    scrollx = scrolly = 0;
    while(running) {

Zu Beginn befindet sich der Spieler an der Position (0/0). Ich verwende nun für diese Position ein Koordinatensystem, wie es beim Computer seit Urzeiten verwendet wird - wie es aber für Mathematiker unverständlich ist.

In der Mathematik gibt es das sogenannte kartesische Koordinatensystem. Das ist kein Zauberwort, sondern einfach das KOSY mit den beiden Achsen (x und y), wie es im Mathematikunterricht andauernd verwendet wird. Bei diesem KOSY befinden sich positive x-Werte rechts vom Ursprung, und positive y-Werte befinden sich oberhalb des Ursprungs.

Am Computer wird üblicherweise ein anderes KOSY verwendet, daß aber zumindest aus Grafikprogrammen allen geläufig sein müßte: Die obere linke Ecke des Bildschirms wird mit (0/0) bezeichnet. Nach rechts nehmen die x-Werte zu - genauso wie beim kartesischen System. Allerdings nehmen die y-Werte nach unten hin zu - das heißt, auf dem sichtbaren Bereich des Bildschirms sind die Koordinaten immer positiv.

Ich habe die beiden Koordinatensysteme hier in einem kleinen Schema noch mal illustriert:

Wie ich schon gesagt habe, lehnen sich die Zahlenwerte der Scrollposition an das beim Computer übliche KOSY an. Wenn man nach unten scrollt nehmen die y-Werte also zu, und wenn man nach rechts scrollt nehmen die x-Werte zu.

Als nächstes müssen wir Tastendrücke abfangen und die Scrollposition entsprechend abändern. Dazu erweitern wir, wie schon angesprochen, das switch()-Statement, das die Ereignisse verarbeitet. Es sollte nun wie folgt aussehen:

            switch(event.type) {
            case SDL_KEYDOWN:
                switch(event.key.keysym.sym) {
                case SDLK_ESCAPE:
                    running = 0;
                    break;
 
                case SDLK_LEFT:
                    scrollx -= 10;
                    break;
                case SDLK_RIGHT:
                    scrollx += 10;
                    break;
                case SDLK_UP:
                    scrolly -= 10;
                    break;
                case SDLK_DOWN:
                    scrolly += 10;
                    break;
 
                default:
                    break;
                }
                break;
 
            case SDL_QUIT:
                running = 0;
                break;
            }

Wir ändern nichts an der Bearbeitung von SDL_QUIT. Wenn aber ein Ereignis vom Typ SDL_KEYDOWN eintrifft, überprüfen wir, was denn überhaupt für eine Taste gedrückt wurde. Glücklicherweise wird diese Information in der Struktur SDL_Event mitgeliefert. event.key enthält mehr Informationen zu Tastaturereignissen (es gibt auch noch SDL_KEYUP, wenn eine Taste wieder losgelassen wurde). event.key.keysym beschreibt die Taste, die gedrückt wurde.

keysym selbst hat wieder mehrere Membervariablen.
Die wichtigste ist 'sym'. Sie enthält eine (von SDL definierte) Konstante, die angibt, welche Taste gedrückt wurde. Eine vollständige Liste dieser Konstanten könnt Ihr in der SDL-Dokumentation unter Events → SDL Event Structures → SDLKey finden. 'sym' gibt aber wirklich nur die gedrückte Taste zurück. Das heißt, 'sym' unterscheidet nicht, ob der Benutzer einfach nur 'a' gedrückt hat, oder ob er auch noch die Shift-Taste gedrückt hält. Laßt Euch nicht davon aus dem Konzept bringen, daß es auch Konstanten z.B. für das Dollarzeichen gibt. Auf einer deutschen Tastatur ist das '$' die Tastenkombination Shift-4, und dementsprechend gibt SDL in 'sym' ein SDLK_4, und kein SDLK_DOLLAR zurück.

Die Membervariable 'mod' ist ein Bitfeld und gibt an, welche Modifiertasten (also Shift, Strg, etc…) gerade gedrückt sind. Für diese Modifiertasten erzeugt SDL nämlich nicht unbedingt Ereignisse (auf X-Windows werden auf jeden Fall keine erzeugt - auf anderen Plattformen verhält sich SDL vielleicht anders).

Ein anderes praktisches Feature ist die Membervariable 'unicode'. Um dieses Feature zu nutzen, muß zuerst die Funktion SDL_EnableUNICODE() aufgerufen werden. Danach liefert die Membervariable 'unicode' den Zeichenwert der gedrückten Taste, so wie er auf dem Bildschirm ausgegeben werden sollte. Aus Shift+A wird dann zum Beispiel 'A', währen aus A alleine 'a' wird. Das ist recht nützlich wenn Ihr in Euer Spiel auch eine Art Konsole oder graphische Oberfläche einbauen wollt.

In unserem Fall verwenden wir also die Membervariable sym, um die Taste zu ermitteln. Beim Druck von Escape beendet sich das Programm, beim Druck der Pfeiltasten ändert sich die Scrollposition.

Natürlich nützt es nicht viel, wenn sich die zwei Variablen scrollx und scrolly ändern, der Bildschirminhalt aber nicht verändert wird. Deshalb verschieben wir jetzt die Funktionsaufrufe, die zum Bildschirmaufbau verwendet werden, in die Hauptschleife.

Der Programmteil oberhalb der Hauptschleife sollte nun so aussehen:

    g_Black = SDL_MapRGB(g_pSurfScreen->format, 0, 0, 0);
 
    running = 1;
    scrollx = scrolly = 0;

Dafür sieht das Ende der Hauptschleife wie folgt aus:

        }
 
        SDL_FillRect(g_pSurfScreen, 0, g_Black);
 
        DrawBackground(scrollx, scrolly);
 
        SDL_UpdateRect(g_pSurfScreen, 0, 0, 0, 0);
    }
 
    SDL_FreeSurface(g_pSurfGalaxies);
        return 0;
}

Beachtet bitte, daß sich diese drei Funktionsaufrufe in der äußeren Schleife und nicht in der Ereignisschleife befinden müssen. Jetzt kann man auch schon den äußeren Anblick eines jeden SDL-Spiels ganz gut erkennen.

Da ist zunächst mal eine große Schleife, die genau einmal pro Frame ausgeführt wird. Diese Schleife enthält zunächst einmal eine zweite, kleinere Schleife, in der Ereignisse bearbeitet werden. Darauf folgt dann der Code, um den Bildschirm zu füllen.

Beim Darstellen selbst löschen wir den Bildschirm zunächst mit Schwarz, da ja nicht alle Bereiche des Bildschirms überschrieben werden. Wer das nicht macht erhält zwar vielleicht einen netten Matrix-Effekt, aber kein akzeptables Spiel.

Euch wird sicher aufgefallen sein, daß ich den Aufruf zu DrawBrackground() leicht verändert habe: die momentane Scrollposition wird übergeben. Es ist ja ganz logisch, daß DrawBackground() angepaßt werden muß. Dazu erstmal ein kleines bißchen Theorie vorweg:

Stellt Euch den Galaxienhintergrund einmal wie eine gekachelte Wand vor, bei der auf jeder Kachel genau das gleiche Bild ist. Der Bildschirm wäre ein Rahmen, der kleiner ist als die Kacheln. Die Scrollposition gibt an, an welcher Position sich dieser Rahmen befindet.


Wenn man nun die Koordinaten in diesem Bild in etwa abschätzt, so befindet sich der erste (dunkelgrüne) Rahmen in diesem Bild an Scrollposition (0/0), und die Galaxie befindet sich an der Position (500/400) innerhalb der Kacheln. Der zweite (hellgrüne) Rahmen befindet sich an der Scrollposition (450/200), und wie Ihr sicher erkennen könnt, ist die Galaxie relativ zu diesem Rahmen dann an der Position (50/200). Man erkennt leicht, daß man die Scrollposition von den Koordinaten der Galaxie abziehen muß, wenn man die Position der Galaxie relativ zum Bildschirm erhalten will.

Wir müssen nun also zunächst den Funktionskopf von DrawBackground() anpassen, so daß er wie folgt aussieht:

void DrawBackground(int scrollx, int scrolly)
{

Ich habe lediglich die beiden Parameter scrollx und scrolly hinzugefügt.

Jetzt müssen wir unsere Erkenntnisse über die Galaxienposition noch anwenden, das heißt wir müssen den Code ändern, der die Position der Galaxie auf dem Bildschirm bestimmt. Dieser ist gerade mal zwei Zeilen lang - er füllt das Zielrechteck mit Werten. Der Code um Zeile 63 herum wird also so geändert:

        dest.x = g_Galaxies[i].x - scrollx;
        dest.y = g_Galaxies[i].y - scrolly;
 
        SDL_BlitSurface(g_pSurfGalaxies, &src, g_pSurfScreen, &dest);

An dieser Stelle könnt Ihr das Programm kompilieren und ausprobieren.

Ihr werdet ziemlich schnell auf zwei üble Probleme stoßen:
Das Programm scrollt nur wenn man eine Pfeiltaste drückt. Läßt man die Taste länger gedrückt wird nicht weitergescrollt.
Sobald man ein bißchen gescrollt hat, erscheinen keine Galaxien mehr im Hintergrund.
Natürlich kann das so nicht bleiben…

Gedrückt oder nicht gedrückt?

Eigentlich ist der Grund für das erste Problem recht naheliegend. SDL erzeugt ja nur ein Tastendruck-Ereignis, wenn die Taste tatsächlich gedrückt wurde.

Anmerkung:
Über die Funktion SDL_EnableKeyRepeat() kann man SDL auch dazu bringen, SDL_KEYDOWN-Ereignisse erneut zu erstellen, wenn eine Taste gedrückt bleibt - genauso wie bei Terminals und Texteditoren. In unserem Fall gibt es aber eine bessere Lösung.

SDL erzeugt aber nicht nur ein SDL_KEYDOWN-Ereignis, es erzeugt auch ein SDL_KEYUP-Ereignis wenn eine Taste wieder losgelassen wird. Man könnte also bei einem SDL_KEYDOWN-Ereignis eine Variable setzen, und diese bei SDL_KEYUP wieder löschen. Dann müßte man nur noch innerhalb der Hauptschleife auf diese Variable prüfen.

Es geht aber noch einfacher: SDL merkt sich selbst, welche Tasten zur Zeit gedrückt sind und welche nicht. Diese Information kann, in Form eines Arrays vom Typ Uint8, über die Funktion SDL_GetKeyState() abgerufen werden. Das zurückgegebene Array ist in einem globalen Speicherbereich und wird über die SDLK-Konstanten, die wir schon im Zusammenhang mit Ereignissen kennengelernt haben, indiziert. Dadurch ist die Funktion SDL_GetKeyState() sehr einfach und komfortabel zu verwenden.
Übrigens behält SDL diese Informationen auch immer im Speicher, und ein Aufruf von SDL_GetKeyState() kostet praktisch keine Rechenzeit.

Genug der Vorrede. Entfernen wir also die vier case-Zweige, die bis jetzt fürs Scrolling gesorgt haben. Das switch(event.key.keysym.sym)-Statement bei Zeile 120 sollte nun so aussehen:

                switch(event.key.keysym.sym) {
                case SDLK_ESCAPE:
                    running = 0;
                    break;
 
                default:
                    break;
                }

Wir brauchen noch einen Zeiger, in dem wir den Zeiger auf das Array, der von SDL_GetKeyState() zurückgegeben wird, speichern. Diesen Zeiger - ich nennen ihn sinnigerweise 'keystate' - müssen wir natürlich am Anfang der Hauptschleife (bei Zeile 114) definieren:

    while(running) {
        SDL_Event event;
        Uint8 *keystate;

Folgt nur noch der Code, der dann letztendlich SDL_GetKeyState aufruft und scrollt. Ich habe ihn nach der Ereignisschleife, also bei Zeile 138 eingefügt:

        }
 
        keystate = SDL_GetKeyState(0);
        if (keystate[SDLK_LEFT])
            scrollx -= 10;
        if (keystate[SDLK_RIGHT])
            scrollx += 10;
        if (keystate[SDLK_UP])
            scrolly -= 10;
        if (keystate[SDLK_DOWN])
            scrolly += 10;

Man kann SDL_GetKeyState() übrigens einen Zeiger auf einen Integer übergeben. In diesem Integer speichert SDL dann die Länge des zurückgegebenen Arrays. Andererseits erstreckt sich dieses Array immer mindestens über alle SDLK-Konstanten. Wir können ihn also ignorieren.

Danach folgen einfache if()-Statements, die die verschiedenen Tasten überprüfen. Die Elemente des keystate-Arrays sind immer 0, wenn die Taste nicht gedrückt ist. Wenn sie aber ungleich 0 ist, ist die Taste gedrückt, und wir verändern die Scrollposition entsprechend.

So.. das erste Problem wäre behoben.

Unendliche Welten

Leider erstreckt sich der Sternenhintergrund immer noch nicht weiter als über einen Bildschirm. Der Grund dafür ist ja eigentlich ganz logisch:

Wenn der Spieler 640 Pixel nach links gescrollt ist ist die Scrollposition bei (-640/0). Die Galaxienkoordinaten liegen aber im Bereich von 0 bis 640. Wenn jetzt gescrollt wird, liegen die neu berechneten Galaxienkoordinaten zwischen 640 und 1280 - also außerhalb des Bildschirms.

Eine Möglichkeit, dieses Problem zu umgehen, wäre, die Koordinaten in einer while()-Schleife so lange anzupassen, bis sie wieder im richtigen Bereich sind. Eine Schleife ist notwendig, da der Spieler ja beliebig weit herausscrollen kann.

Die while()-Schleife wäre aber eine sehr unschöne Lösung, und es geht in der Tat besser. Die nächstbessere Lösung ist die Verwendung des Modulooperators %. Dieser gibt ja den Rest einer Division zurück. Wenn wir nun einfach die Koordinaten, die SDL_BlitSurface() übergeben werden, modulo 640 bzw. 480 berechnen, sollten ja eigentlich nur Werte im Rahmen herauskommen.

Aber auch diese Lösung hat einen Haken: der Rest der Division einer negativen Zahl ergibt wiederum eine negative Zahl. Wenn wir also nach rechts scrollen wäre die SDL_BlitSurface() übergebene Koordinate immer noch negativ.

Man könnte nun versuchen, negative Werte auf positive zu spiegeln, aber das ergibt ein auffallend symmetrisches Bild am Punkt (0/0). Besser wäre es, die negativen Werte ganz los zu werden. Und genau das werden wir tun. Diese Lösung mag etwas esoterisch erscheinen und auf den ersten Blick nicht ganz einleuchtend. Dennoch solltet Ihr wirklich versuchen, sie im Innersten zu verstehen.

Dazu muß man wissen, wie der Computer Zahlen im Binärformat darstellt. Wie eigentlich bekannt sein sollte, wird ja aus einer Zahl wie z.B. 9 die Binärzahl 0000000000001001. Ich verwende bewußt 32 Binärziffern, denn wir verwenden ja 32bit-Zahlen (Anm. von TTT: Nunja, da kann wohl einer nicht zählen =)). Der Clou ist nun, wie der Prozessor negative Zahlen darstellt. Um eine lange Geschichte sehr stark abzukürzen, -9 wird als 1111111111110111 dargestellt. Man stellt fest, dass

  10000000000000000 (das heißt 2^32)
- 00000000000001001 (dezimal: 9)
------------------
   1111111111110111 (dezimal: -9)

Würden wir die binäre Repräsentation der Zahl -9 als positive Zahl verstehen, so würde sich 2^32 - 9 ergeben. Wenn Ihr Euch das nochmal im Kopf herumgehen laßt, so sollte Euch auch klar werden, warum ein 'unsigned int' Zahlen von 0 bis 2^32 - 1, ein 'signed int' dagegen aber Zahlen von -2^31 bis 2^31 - 1 speichern kann.

Der Trick ist also, daß wir die Koordinate der Galaxie zunächst in ein 'unsigned int' casten, und erst dann die Modulooperation durchführen. Wenn wir jetzt aber einfach Modulo 640 bzw. 480 anwenden ergibt sich leider wieder ein neues Problem: (2^32 - 9) % 640 ist nämlich 247. Allerdings sollte sich -9 ja nur 9 Pixel rechts vom rechten Rand des Bildschirms befinden. Eigentlich müßte also 631 herauskommen. Daß dies nicht der Fall ist liegt daran, daß 640 kein Teiler von 2^32 ist.

Bisher haben wir den Galaxienhintergrund ja immer auf einen Bereich von 640×480 verteilt. Wir werden nun die Grenzen dieses Bereichs auf die nächsten 2er-Potenzen, also 1024×512 aufrunden müssen. Das ist aber eigentlich kein Problem, im Gegenteil, es hat sogar noch zwei Vorteile: erstens wiederholt sich der Hintergrund nicht ganz so häufig. Den zweiten Grund werdet Ihr erst später zu würdigen wissen ;)

Nach so viel trockenem Text, der aber leider notwendig war, wird es Zeit, das gesammelte Wissen umzusetzen. Zunächst muß RandBackground() so verändert werden, daß sich der Hintergrund auf einen Bereich von 1024×512 erstreckt. Wir verändern den Code von RandBackground() also so, daß die innere Schleife wie folgt aussieht:

    for(i = 0; i g_Galaxies[i].x = rand() % 1024;
        g_Galaxies[i].y = rand() % 512;
        g_Galaxies[i].type = rand() % num_types;
    }

Entsprechend muß dann die Berechnung der Zielkoordinaten für SDL_BlitSurface() in DrawBackground() geändert werden. Dies sieht dann so aus:

        dest.x = (unsigned int)(g_Galaxies[i].x - scrollx) % 1024;
        dest.y = (unsigned int)(g_Galaxies[i].y - scrolly) % 512;
 
        SDL_BlitSurface(g_pSurfGalaxies, &src, g_pSurfScreen, &dest);

Durch die Erweiterung des Hintergrundsbereiches hat sich natürlich die Dichte des Hintergrunds geändert. Je nach Geschmack ist es also angebracht, die Präprozessorkonstante NUM_GALAXIES in Zeile 20 anzupassen.

Das Scrolling sieht nun so weit schon ganz gut aus, hat aber leider immer noch einen kleinen Schönheitsfehler. Wenn Ihr langsam genug scrollt (evtl. müßt Ihr den Code in der Hauptschleife so verändern, daß das Scrolling langsamer läuft) werdet Ihr feststellen, daß eine Galaxie am linken bzw. oberen Rand des Bildschirms ganz plötzlich verschwindet bevor sie ganz herausscrollt.

Auch das hat seinen logischen Grund: Angenommen, eine Galaxie befindet sich an der Bildschirmposition (0/100). Wenn jetzt weiter nach rechts gescrollt wird, müßte sich die Galaxie z.B. an der Bildschirmposition (-8/100) befinden. Durch unsere Modulooperation wird sie aber an die Stelle (1016/100) verschoben und ist damit nicht mehr sichtbar.

Hier zeigt sich ein weiterer Vorteil davon, daß der Hintergrundbereich nun größer ist als der Bildschirm. Wäre dies nicht der Fall, so müßten wir jetzt die Galaxie zweimal darstellen (einmal bei (-8/100) und einmal bei (1016/100)), damit die Anzeige vollständig korrekt ist. Stattdessen können wir uns mit einem ebenso simplen wie effektiven Trick (manch einer mag Hack sagen) behelfen: Wir reduzieren die Zielkoordinate, die SDL_BlitSurface() übergeben wird, einfach um die maximale Breite bzw. Höhe der Galaxie.

Damit sieht der entsprechende Codeausschnitt in DrawBackground() so aus:

        dest.x = ((unsigned int)(g_Galaxies[i].x - scrollx) % 1024) - 16;
        dest.y = ((unsigned int)(g_Galaxies[i].y - scrolly) % 512) - 16;
 
        SDL_BlitSurface(g_pSurfGalaxies, &src, g_pSurfScreen, &dest);

Ach übrigens: Anstatt Modulo 1024 könnt Ihr auch ein bitweises AND verwenden, also z.B. '(unsigned int)(g_Galaxies[i].x - scrollx) & 1023)' im obigen Codeausschnitt. Warum das so ist, dürft Ihr selbst herausfinden. Für den Prozessor ist ein bitweises AND immer effizienter als eine Division, daher ist das bitweise AND eigentlich vorzuziehen. Andererseits erkennt zumindest gcc dies automatisch und optimiert den Code entsprechend.

Natürlich war das noch längst nicht alles, was ich zum Thema Scrolling zu sagen habe. Andererseits ist dieses Kapitel bereits jetzt mit Abstand länger als alle vorigen. Deswegen hebe ich mir ein paar Verbesserungen sowie Mausunterstützung für das nächste Kapitel auf.

Der Quellcode zu diesem Tutorial sowie die Makefile und MSVC++-Projektdateien sind zum Download verfügbar: (sdl_tut4.tgz sdl_tut4.zip)

Das von mir verwendete Galaxienbitmap entstammt übrigens einzelnen Galaxien aus dem Bild vom letzten Kapitel.

Anmerkung

Dieses Tutorial stammt aus der ehemaligen Sammlung des resourcecode.de und konnte dank der freundlichen Zustimmung des Autors in das thewall-Wiki übertragen werden.