Terug naar de inhoudsopgave

Pengo – Game loop

Na een half jaar programmeren hebben we genoeg kennis opgedaan om aan een echt project te beginnen. De komende weken houden we ons bezig met de drijvende kracht achter de ontwikkeling van de computerindustrie: seks. Eh, pardon: spelletjes.

Het spel waarvoor ik gekozen heb, heet Pengo. Aangezien we tot nu toe nog geen grafische applicaties hebben geschreven, ben ik op zoek gegaan naar een spel dat in een console te spelen was. Hopelijk hebben we straks nog tijd over om het spel om te zetten naar een echte Windows-applicatie. Je zult dan ook zien dat we met een beetje slim ontwerp dezelfde code kunnen gebruiken voor de console applicatie als voor de Windows-applicatie.

Over Pengo

Pengo is een arcade game uit 1982 van Sega. De versie van Pengo die wij gaan programmeren, verschilt enigszins van het origineel. Je speelt de pinguïn Pengo en loopt door een veld met allemaal blokken. Deze blokken kun je wegduwen. Het is de bedoeling dat je je vijanden, de snow bees, tussen de blokken plet, zonder door hen gestoken te worden.

De beste manier om een idee te krijgen van wat Pengo is, is door het te spelen. Speel ai pengo.

Terug naar boven

Verplaatsen

Tijdens deze les doen we een aantal vingeroefeningen. Aan het eind hebben we een speler die over het scherm kan lopen. Belangrijker is echter dat we weten hoe we beweging kunnen programmeren en hoe de game loop werkt.

Ons spel Pengo zal aan het eind bestaan uit een flink aantal functies verspreid over verschillende bestanden, maar in deze les schrijven we alles nog in de functie main. Laten we maar beginnen met de algemene code in te typen. Maak een nieuw project aan met de naam Pengo en voeg het bestand Main.cpp toe.

Main.cpp
/******************************************************************************
	Bestand:   Main.cpp
	Project:   Pengo

	Copyright: (c) 2003 Joost Ronkes Agerbeek

	Auteur:    Joost Ronkes Agerbeek <joost@ronkes.nl>
	Datum:     11 januari 2003
******************************************************************************/

#include <iostream>

using namespace std;

/**
 * Het programma start hier.
 *
 * @return	altijd 0
 */
int main()
{
	// wacht op enter
	cout << endl;
	system("pause");

	return 0;
}

Bij een project van deze omvang is het extra belangrijk dat je zorgt dat je commentaar op orde is. Zorg ervoor dat al je bestanden een banner hebben en voorzie al je functies royaal van commentaar. Beter te veel commentaar dan te weinig.

Verder is het belangrijk dat je je houdt aan de stijlregels. Met de hoeveelheid code die bij Pengo komt kijken, kun je er zeker van zijn dat je veel van je tijd zult besteden aan bug hunting. Nette code maakt dat proces een stuk minder vervelend.

Terug naar boven

Positie

Het doel van deze les is om Pengo op het scherm te tekenen en ervoor te zorgen dat de speler Pengo kan verplaatsen met behulp van (pijltjes)toetsen. Als we Pengo willen verplaatsen, zullen we eerst moeten bijhouden waar hij staat. We houden de positie van Pengo bij in twee variabelen: één voor de x-coördinaat en één voor de y-coördinaat.

Main.cpp
// houd de positie van Pengo bij
int myX = 40;
int myY = 12;

Ik heb de variabelen meteen geïnitialiseerd op 40 en 12. Hiermee staat Pengo in het begin in het midden van het scherm. (We gaan er even van uit dat het speelveld bestaat uit het hele scherm.) Nu we de positie van Pengo weten, kunnen we hem met behulp van de Console API gemakkelijk tekenen.

Main.cpp
// teken Pengo
MoveCursor(myX, myY);
cout << "*";

Voorlopig gebruiken we een asterisk (*) om aan te geven waar Pengo staat. Denk eraan dat je de Console API moet installeren en dat je Console.h moet includen.

Terug naar boven

Toetsen inlezen

Met behulp van de Console API kunnen we wachten tot de speler op een toets drukt en daarna Pengo verplaatsen, afhankelijk van welke toets is ingedrukt. We gebruiken hiervoor de functie GetVirtualKey. Om te bepalen welke toets is ingedrukt, gebruiken we een switch-statement. We kunnen ook een aantal if-statements gebruiken, maar switch is een stuk overzichtelijker en levert bovendien minder typewerk en minder bugs op. Vooral dat laatste is erg belangrijk.

Main.cpp
// wacht op een toets
int myKey = GetVirtualKey();

// verwijder Pengo van scherm
MoveCursor(myX, myY);
cout << " ";

// welke toets is ingedrukt?
switch (myKey)
{
case VK_UP:
	{
		// verplaats Pengo omhoog
		myY--;
	} break;
case VK_DOWN:
	{
		// verplaats Pengo omlaag
		myY++;
	}
case VK_LEFT:
	{
		// verplaats Pengo naar links
		myX--;
	} break;
case VK_RIGHT:
	{
		// verplaats Pengo naar rechts
		myX++;
	}
}

// teken Pengo
MoveCursor(myX, myY);
cout << "*";

In bovenstaande code wordt Pengo meteen op zijn nieuwe positie getekend. Zodra de speler op een toets drukt, weten we dat Pengo van zijn oude positie wegloopt en we halen hem daar weg. Dan werken we de coördinaten van Pengo bij en tot slot tekenen we hem opnieuw.

Terug naar boven

Constanten

Voordat we verder gaan met de beweging van Pengo, is het tijd voor wat goede programmeergewoontes. De code die we zojuist geschreven hebben, stelt de speler in staat om Pengo te verplaatsen met behulp van de pijltjestoetsen. Het kan best zijn dat we op een gegeven moment besluiten om andere toetsen te gebruiken. In dat geval is het niet handig dat we ergens middenin onze code hebben staan welke toetsen de speler moet gebruiken.

We zullen gebruik maken van variabelen om aan te geven welke toetsen het spel gebruikt. Deze variabelen initialiseren we bovenaan het bestand, zodat we ze makkelijk terug kunnen vinden als we ze willen veranderen. Hetzelfde doen we met het ASCII-teken dat Pengo voor moet stellen.

Main.cpp
/******************************************************************************
	Bestand:   Main.cpp
	Project:   Pengo

	Copyright: (c) 2003 Joost Ronkes Agerbeek

	Auteur:    Joost Ronkes Agerbeek <joost@ronkes.nl>
	Datum:     11 januari 2003
******************************************************************************/

#include <iostream>
#include "Console.h"

using namespace std;

/******************************************************************************
	Constante variabelen
******************************************************************************/

/**
 * De toetsen voor de besturing van Pengo.
 */
const int PengoUp    = VK_UP;
const int PengoDown  = VK_DOWN;
const int PengoLeft  = VK_LEFT;
const int PengoRight = VK_RIGHT;

/**
 * Het ASCII-teken dat Pengo voorstelt.
 */
const char PengoCharacter = '*';

/******************************************************************************
	Globale functies
******************************************************************************/

/**
 * Het programma start hier.
 *
 * @return	altijd 0
 */
int main()
{
	// houd positie van Pengo bij
	int myX = 40;
	int myY = 12;

	// teken Pengo
	MoveCursor(myX, myY);
	cout << PengoCharacter;

	// wacht op een toets
	int myKey = GetVirtualKey();

	// verwijder Pengo van scherm
	MoveCursor(myX, myY);
	cout << " ";

	// welke toets is ingedrukt?
	switch (myKey)
	{
	case PengoUp:
		{
			// verplaats Pengo omhoog
			myY--;
		} break;
	case PengoDown:
		{
			// verplaats Pengo omlaag
			myY++;
		} break;
	case PengoLeft:
		{
			// verplaats Pengo naar links
			myX--;
		} break;
	case PengoRight:
		{
			// verplaats Pengo naar rechts
			myX++;
		} break;
	}

	// teken Pengo
	MoveCursor(myX, myY);
	cout << PengoCharacter;

	// wacht op enter
	cout << endl;
	system("pause");

	return 0;
}

Nu staan de besturingstoetsen in globale variabelen. Als we nu besluiten dat de speler Pengo omhoog moet verplaatsen met de A-toets, dan veranderen we de variabele PengoUp in VK_A en als we Pengo willen tekenen met een dollar-teken, dan veranderen we PengoCharacter in een $. In de code van main zijn alle vaste waarden vervangen door de nieuwe variabelen.

Het woord const voor al deze variabelen verdient wel enige uitleg. Met het woord const geven we aan dat we te maken hebben met een constante: een variabele waarvan de waarde niet veranderd kan worden. Een constante variabele volgt dezelfde regels als een normale variabele, maar je kunt hem niet veranderen. Als je dat toch probeert, krijg je een foutmelding van de compiler. De volgende code werkt dus niet.

// initialiseer een constante
const int myConst = 10;

// verander waarde van constante
myConst = 20;	// FOUT: je kunt de waarde van een constante niet veranderen

Door constante variabelen te gebruiken zijn we er zeker van dat we niet per ongeluk de toetsen of het teken van Pengo veranderen.

Terug naar boven

Beweging

Tot nu toe kunnen we Pengo maar één positie verplaatsen. We willen natuurlijk dat we met Pengo veel verder kunnen lopen. De oplossing ligt voor de hand: een lus.

Main.cpp
// laat speler Pengo besturen
int myKey;

do
{
	// wacht op een toets
	myKey = GetVirtualKey();

	// verwijder Pengo van scherm
	MoveCursor(myX, myY);
	cout << " ";

	// welke toets is ingedrukt?
	switch (myKey)
	{
	case PengoUp:
		{
			// verplaats Pengo omhoog
			myY--;
		} break;
	case PengoDown:
		{
			// verplaats Pengo omlaag
			myY++;
		} break;
	case PengoLeft:
		{
			// verplaats Pengo naar links
			myX--;
		} break;
	case PengoRight:
		{
			// verplaats Pengo naar rechts
			myX++;
		} break;
	}

	// teken Pengo
	MoveCursor(myX, myY);
	cout << PengoCharacter;

} while (myKey != GameExit);

De gehele code voor het wachten op een toets en het verplaatsen van Pengo staat nu in een do-lus. De declaratie van myKey is buiten de lus gehaald, omdat hij anders out-of-scope is bij het while-statement. De lus gaat door totdat de speler op <Esc> drukt. Deze toets is gedefinieerd in de constante variabele GameExit.

Main.cpp
/**
 * De toetsen voor de besturing van het spel.
 */
const int GameExit = VK_ESCAPE;

Terug naar boven

Vloeiende beweging

De lus waarin het hele spel draait, noemen we de game loop. Tot nu toe hebben we een hele simpele game loop. Ik kan je nu al vertellen dat deze game loop niet volstaat. Pengo beweegt nu iedere keer dat we op een toets drukken, maar het zou veel aardiger zijn als Pengo uit zichzelf bleef lopen. Bijvoorbeeld, we drukken op naar links en Pengo blijft naar links lopen totdat we op naar boven drukken.

Om dit mogelijk te maken, moeten we een aantal wijzigingen doorvoeren. Om te beginnen moeten we bijhouden welke richting Pengo uitloopt.

Main.cpp
// houd richting van Pengo bij
int myDirection = 0;

We spreken af dat de waarde 0 betekent dat Pengo stilstaat, 1 betekent dat Pengo omhoog loopt, 2 naar beneden, 3 naar links en 4 naar rechts. Nu moeten we ervoor zorgen dat we de richting bijwerken als de speler op een toets drukt.

Main.cpp
// welke toets is ingedrukt?
switch (myKey)
{
case PengoUp:
	{
		// stel richting in
		myDirection = 1;
	} break;
case PengoDown:
	{
		// stel richting in
		myDirection = 2;
	} break;
case PengoLeft:
	{
		// stel richting in
		myDirection = 3;
	} break;
case PengoRight:
	{
		// stel richting in
		myDirection = 4;
	} break;
}

// in welke richting beweegt Pengo?
switch (myDirection)
{
case 1:
	{
		// verplaats Pengo omhoog
		myY--;
	} break;
case 2:
	{
		// verplaats Pengo omlaag
		myY++;
	} break;
case 3:
	{
		// verplaats Pengo naar links
		myX--;
	} break;
case 4:
	{
		// verplaats Pengo naar rechts
		myX++;
	} break;
}

De code is nu gesplitst in twee switch-statements. De eerste switch controleert wat de invoer van de gebruiker is en werkt de richting van Pengo bij. De tweede switch controleert in welke richting Pengo loopt en verplaatst hem.

Elke keer dat de game loop wordt uitgevoerd, wordt Pengo nu verplaatst. Omdat we willen dat Pengo ook blijft lopen als de speler niet op een toets drukt, kunnen we niet meer op invoer van de gebruiker blijven wachten. We moeten GetVirtualKey dus vervangen door PeekVirtualKey.

Main.cpp
// wacht op een toets
myKey = PeekVirtualKey();

// is er een toets ingedrukt?
if (myKey != 0)
{
	// ja, verwijder toets uit buffer
	GetVirtualKey();

	// welke toets is ingedrukt?
	switch (myKey)
	{
	case PengoUp:
		{
			// stel richting in
			myDirection = 1;
		} break;
	case PengoDown:
		{
			// stel richting in
			myDirection = 2;
		} break;
	case PengoLeft:
		{
			// stel richting in
			myDirection = 3;
		} break;
	case PengoRight:
		{
			// stel richting in
			myDirection = 4;
		} break;
	}
}

// verwijder Pengo van scherm
MoveCursor(myX, myY);
cout << " ";

// in welke richting beweegt Pengo?
switch (myDirection)
{
case 1:
	{
		// verplaats Pengo omhoog
		myY--;
	} break;
case 2:
	{
		// verplaats Pengo omlaag
		myY++;
	} break;
case 3:
	{
		// verplaats Pengo naar links
		myX--;
	} break;
case 4:
	{
		// verplaats Pengo naar rechts
		myX++;
	} break;
}

Met PeekVirtualKey kunnen we toetsen uitlezen zonder erop te wachten. Als de speler geen toets indrukt, gaat het spel gewoon door. Omdat PeekVirtualKey toetsen niet uit de buffer haalt, moet we GetVirtualKey aanroepen. Voer de code uit en je kunt zien dat Pengo ook verplaatst als je niet voortdurend op een toets drukt.

Terug naar boven

Vertraging

Oeps! Dat gaat wel erg snel. Pengo wordt nu zo snel verplaatst dat je hem bijna niet kunt volgen. We moeten het spel wat vertragen. Dit doen we door te zorgen dat het spel telkens een bepaalde tijd wacht voordat Pengo weer een positie wordt opgeschoven.

We moeten op een of andere manier dus de tijd bijhouden. Gelukkig heeft de Win32 API een aantal functies waarmee we de tijd kunnen opvragen. Wij gebruiken de timeGetTime functie. timeGetTime meet de tijd in milliseconden (dat is een duizendste van een seconde). We zijn niet geïnteresseerd in de manier waarop timeGetTime de tijd teruggeeft, we hoeven alleen maar een bepaalde tijd te wachten. Om timeGetTime te kunnen gebruiken, moet je #include <windows.h> gebruiken.

Main.cpp
// vraag huidige tijd op
DWORD myTime = timeGetTime();

// wacht tot vertraging om is
while (timeGetTime() < (myTime + GameDelay));

Bovenstaande code komt helemaal in het begin van de game loop te staan. De return value van timeGetTime is een DWORD en daarom is ook myTime van dat datatype. DWORD is geen standaarddatatype, maar is gedefinieerd in de Win32 API. DWORD is een geheel getal, net als int, maar kan groter worden dan int.

De while-lus ziet er wat eigenaardig uit. Er is helemaal geen body die herhaald wordt. Dat is omdat er niets herhaalt hoeft te worden, behalve de controle of de vertraging al voorbij is. De while-lus doet dus niets anders dan wachten.

De tijd dat deze vertraging duurt, is weer vastgelegd in een constante.

/**
 * De vertraging van de game loop in milliseconden.
 */
const int GameDelay = 250;

Als je Pengo nu compileert, krijg je waarschijnlijk een foutmelding van de linker. Dat komt doordat timeGetTime opgenomen is in een library die niet meegelinkt wordt. Net als dat je dat gedaan hebt voor Console.lib, moet je aangeven dat je winmm.lib wilt meelinken. Afhankelijk van je IDE, moet je kiezen uit de volgende stappen.

Terug naar boven

Conclusie

En dat is het voor deze keer. We hebben gezien hoe je een game loop kunt programmeren om beweging te simuleren. Bovendien hebben we ervoor gezorgd dat de game loop niet te snel gaat.

Tussen neus en lippen door zijn de volgende C++-onderwerpen aan bod geweest:

De volgende keer houden we ons uitgebreid bezig met het programmeren van de speler. Tot die tijd raad ik je aan om veel te experimenteren met de code die we tot nu toe hebben geschreven. Voer wat wijzigingen door en zie wat het effect is.

Programmeren is leuk. :-)

Terug naar boven

Extra

Als je de bovenstaande code af hebt en je wilt Pengo graag uitbreiden, dan kun je de volgende features toevoegen.

Terug naar boven