Conversie

Pengo © 2002-2003, Joost Ronkes Agerbeek

Vorige keer zijn we begonnen met het converteren van Console Pengo naar WinPengo, maar we zijn nog niet helemaal klaar. Deze les maken we de conversie af en heb je dus een functioneel spel voor Windows. :-)

Beweging

Op dit moment staat alles en iedereen nog stil. Daar wordt een spel niet spannender van en dus brengen we er verandering in.

[ Naar boven | Terug naar Pengo ]

Game loop

Om WinPengo aan de gang te krijgen, moeten we de game loop schrijven. Op dit moment hebben we al een lus die het hele spel door wordt uitgevoerd, namelijk de event loop. Deze kunnen we mooi gebruiken om ook als game loop dienst te doen. We voegen de code toe die alle elementen in het spel moet verplaatsen. Ik zal niet ingaan op de details; die hebben we in eerdere lessen al gezien.

Main.cpp
// vraag tijd op voor speler, blokken en vijanden
DWORD myPlayerTime = timeGetTime();
DWORD myBlockTime  = timeGetTime();
DWORD myEnemyTime  = timeGetTime();

// event loop
MSG msg;

do
{
	// is er een bericht naar het venster gestuurd?
	if (PeekMessage(&msg, myWindowHandle, 0, 0, PM_REMOVE))
	{
		// vertaal bericht
		TranslateMessage(&msg);

		// verstuur bericht naar window procedure
		DispatchMessage(&msg);
	}

	// vraag tijd op 
	DWORD myTime = timeGetTime();

	// is het tijd om de speler te verplaatsen?
	if ((myPlayerTime + PlayerDelay) < myTime)
	{
		// ja, begin opnieuw met de timing voor de speler
		myPlayerTime = myTime;
		
		// beweegt de speler?
		if (GlobalLevel.Player.IsMoving)
		{
			// verplaats de speler
			MovePlayer(GlobalLevel.Player);
		}
	}

	// is het tijd om de blokken te verplaatsen?
	if ((myBlockTime + BlockDelay) < myTime)
	{
		// ja, begin opnieuw met de timing voor de blokken
		myBlockTime = myTime;

		// verplaats de blokken
		for (int i = 0; i < GlobalLevel.Blocks.size(); i++)
		{
			// beweegt het blok?
			if (GlobalLevel.Blocks.at(i).IsMoving)
			{
				// verplaats het blok
				MoveBlock(GlobalLevel.Blocks.at(i));
			}
		}
	}

	// is het tijd om de vijanden te verplaatsen?
	if ((myEnemyTime + EnemyDelay) < myTime)
	{
		// ja, begin opnieuw met de timing voor de vijanden
		myEnemyTime = myTime;

		// verplaats de vijanden
		for (int i = 0; i < GlobalLevel.Enemies.size(); i++)
		{
			// verplaats het vijand
			MoveEnemy(GlobalLevel.Enemies.at(i));
		}
	}

} while (msg.message != WM_QUIT);

Merk op dat we de functies ClearBlock, ClearEnemy en ClearPlayer niet meer aanroepen. (Vergelijk de code maar met de game loop van Console Pengo als je niet weet waar ik het over heb.)

We maken weer gebruik van de constanten PlayerDelay, EnemyDelay en BlockDelay om de snelheid van het spel te bepalen, dus die constanten moeten we toevoegen, bovenaan Main.cpp.

Main.cpp
/******************************************************************************
	Constante variabelen
******************************************************************************/

/**
 * De vertragingen van de speler, blokken en vijanden.
 */
const int PlayerDelay = 100;
const int BlockDelay  = 10;
const int EnemyDelay  = 300;

Om Pengo te kunnen compileren, moet je winmm.lib weer toevoegen aan je project (zie de eerste les).

Compileren, starten en... het hele zaakje staat nog steeds stil. Dat wil zeggen, dat lijkt zo. Het probleem is namelijk dat het venster niet opnieuw getekend wordt. Windows tekent een venster alleen opnieuw als het echt nodig is. We moeten Windows dus op één of andere manier meedelen dat we het nodig vinden dat het venster opnieuw getekend wordt.

Dat doen we met de functie InvalidateRect. Met InvalidateRect vertel je Windows welk deel van je venster opnieuw getekend moet worden. Wij willen het hele venster opnieuw tekenen. Zet deze functieaanroep als laatste statement in de game loop.

Main.cpp
// vertel Windows dat het venster opnieuw getekend moet worden
InvalidateRect(myWindowHandle, 0, false);

InvalidateRect krijgt drie parameters. De eerste is de window handle, zodat Windows weet over welk venster we het hebben. De tweede parameter geeft aan welk deel van het venster we opnieuw willen tekenen. Door een 0 op te geven, vertellen we Windows dat we het over het hele venster hebben. De derde parameter bepaalt of Windows het venster eerst leeg moet maken (vergelijkbaar met ClearScreen). Dat willen we niet en straks zal duidelijk worden waarom niet.

O jee, wat krijgen we nu! Dat zijn wel een heleboel vijanden. Blijkbaar blijven bitmaps van de vijanden staan. Dat krijg je als je ClearEnemy weghaalt uit de game loop.

[ Naar boven | Terug naar Pengo ]

Back buffer

Omdat alle tekenfuncties aangeroepen worden vanuit de window procedure, is het niet handig om een Windows-versie te schrijven van ClearEnemy. De meest voor de hand liggende optie is om in DrawLevel eerst het venster leeg te maken en daarna het level opnieuw te tekenen. Hierdoor gaat het spel echter verschrikkelijk flikkeren. (Geloof me, ik heb het geprobeerd.)

Om dit probleem om te lossen maken we gebruik van een techniek die bekend staat als back buffering. Dit werkt als volgt. Eerst tekenen we het nieuwe level op een plaats in het geheugen, maar niet op het scherm. Dit noemen we de back buffer. Vervolgens kopiëren we de back buffer in één keer naar het venster, over de oude afbeelding heen. Het vorige frame is nu volledig verdwenen en we hebben het venster niet eerst leeg moeten maken.

We moeten in DrawLevel eerst een back buffer aanmaken. Deze back buffer is in principe een bitmap die we zelf tekenen, dus we gebruiken hiervoor de functie CreateCompatibleBitmap. Echter, om te kunnen tekenen hebben we een device context nodig. We maken dus ook een nieuwe device context aan en plaatsen daar onze back buffer in. Nu kunnen we alle andere functies zoals DrawEnemy op de back buffer laten tekenen.

Graphics.cpp
/**
 * Tekent het level naar het scherm.
 *
 * @param	level       	het level dat getekend moet worden
 * @param	windowHandle	de handle van het venster
 */
void DrawLevel(const Level& level, HWND windowHandle)
{
	// vraag device context voor venster op
	HDC myWindowDC = GetDC(windowHandle);

	// maak device context voor bitmap
	HDC myBitmapDC = CreateCompatibleDC(myWindowDC);

	// plaats bitmap in device context
	SelectObject(myBitmapDC, Bitmaps);

	// bereken grootte van back buffer
	int myWidth  = GlobalLevel.Width * BitmapWidth;
	int myHeight = GlobalLevel.Height * BitmapHeight;

	// maak bitmap voor back buffer
	HANDLE myBackBuffer = CreateCompatibleBitmap(myWindowDC, myWidth,
		myHeight);

	// maak device context voor back buffer
	HDC myBackBufferDC = CreateCompatibleDC(myWindowDC);

	// plaats back buffer bitmap in back buffer device context
	SelectObject(myBackBufferDC, myBackBuffer);
	
	// maak back buffer leeg
	RECT myRectangle;
	myRectangle.left = 0;
	myRectangle.right = myWidth;
	myRectangle.top = 0;
	myRectangle.bottom = myHeight;

	FillRect(myBackBufferDC, &myRectangle,
		(HBRUSH) GetStockObject(BLACK_BRUSH));

	// doorloop alle blokken in het level
	for (int i = 0; i < level.Blocks.size(); i++)
	{
		// teken blok
		DrawBlock(level.Blocks.at(i), myBackBufferDC, myBitmapDC);
	}

	// teken speler
	DrawPlayer(level.Player, myBackBufferDC, myBitmapDC);

	// doorloop alle vijanden in het level
	for (int j = 0; j < level.Enemies.size(); j++)
	{
		// teken vijand
		DrawEnemy(level.Enemies.at(j), myBackBufferDC, myBitmapDC);
	}

	// geef device context van back buffer vrij
	DeleteDC(myBackBufferDC);

	// geef bitmap van back buffer vrij
	DeleteObject(myBackBuffer);

	// geef device context van bitmap vrij
	DeleteDC(myBitmapDC);

	// geef device context van venster vrij
	ReleaseDC(windowHandle, myWindowDC);
}

We moeten aan CreateCompatibleBitmap opgeven hoe groot de back buffer moet zijn. Dit berekenen we door het aantal vakjes in het level te vermenigvuldigen met het aantal pixels in elk vakje. Merk op dat bij de aanroepen van DrawBlock, DrawEnemy en DrawPlayer de parameter myWindowDC vervangen is door myBackBufferDC. De bitmaps worden niet meer onmiddellijk naar het venster getekend, maar komen eerst terecht in de back buffer.

Opmerking: onder Windows 95/98/Me maakt CreateCompatibleBitmap de bitmap niet leeg. Dat betekent dat we dat zelf moeten doen. Vandaar het stukje code onder: maak de back buffer leeg. FillRect tekent een rechthoek ter grootte van de gehele back buffer met de kleur zwart. Het lijkt erop dat Windows 2000/XP dit automatisch doet.

We geven aan het eind ook de device context en de bitmap van de back buffer vrij. Dit voorkomt dat we geheugen verspillen.

Tot slot moeten we de back buffer kopiëren naar het venster door middel van een BitBlt. Denk eraan dat je dit moet doen voordat je de device contexts vrijgeeft. Je kunt niet op een device context tekenen die je al vrij hebt gegeven.

Graphics.cpp
// kopieer back buffer naar venster
BitBlt(myWindowDC, 0, 0, myWidth, myHeight, myBackBufferDC, 0, 0, SRCCOPY);

Aaah, dat ziet er al een stuk beter uit. :-)

[ Naar boven | Terug naar Pengo ]

Toetsen inlezen

Het zal je niet ontgaan zijn dat je Pengo nog niet kunt besturen. Het inlezen van toetsen gaat in Windows anders dan in een console application. Zodra je op een toets drukt, stuurt Windows een WM_KEYDOWN message naar je venster. Dit bericht moeten we afhandelen in de window procedure.

Als je window procedure wordt aangeroepen met een WM_KEYDOWN message, dan vult Windows ook de wParam parameter. Daarin staat namelijk op welke toets er is gedrukt. wParam is weer een oude vertrouwde virtual key. Bepalen op welke toets er is gedrukt, gaat dus hetzelfde als in Console Pengo.

Main.cpp
case WM_KEYDOWN:
	{
		// welke toets is ingedrukt?
		switch (wParam)
		{
		case PengoUp:
			{
				// stel richting in
				GlobalLevel.Player.Direction = Up;
	
				// beweeg speler
				GlobalLevel.Player.IsMoving = true;
			} break;
		case PengoDown:
			{
				// stel richting in
				GlobalLevel.Player.Direction = Down;
	
				// beweeg speler
				GlobalLevel.Player.IsMoving = true;
			} break;
		case PengoLeft:
			{
				// stel richting in
				GlobalLevel.Player.Direction = Left;
	
				// beweeg speler
				GlobalLevel.Player.IsMoving = true;
			} break;
		case PengoRight:
			{
				// stel richting in
				GlobalLevel.Player.Direction = Right;
	
				// beweeg speler
				GlobalLevel.Player.IsMoving = true;
			} break;
		case PengoKick:
			{
				// laat speler schoppen
				Kick(GlobalLevel.Player);
			} break;
		}
	
		return 0;
	}

We moeten alleen nog de constanten PengoUp, PengoDown, PengoLeft, PengoRight en PengoKick definiëren.

Main.cpp
/**
 * 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;
const int PengoKick  = VK_SPACE;
[ Naar boven | Terug naar Pengo ]

Dood gaan

Hé, staat de collision detection uit? Nee hoor, maar het spel gaat nu gewoon door als Pengo dood is. We controleren namelijk IsDead nergens. We moeten dus de voorwaarde waaronder we de game loop verlaten aanpassen.

Main.cpp
} while ((msg.message != WM_QUIT) && (!GlobalLevel.Player.IsDead))

En wat als alle vijanden dood zijn?

Main.cpp
} while ((msg.message != WM_QUIT) && (!GlobalLevel.Player.IsDead)
		&& (GlobalLevel.Enemies.size() > 0));

Richting

We zijn er bijna, maar één ding ontbreekt nog: je kunt niet zien welke kant Pengo op loopt. We hebben dus aparte bitmaps nodig voor elke richting die Pengo op kan. Ik heb een nieuw bitmapbestand toegevoegd aan downloads voor deze les.

Je ziet dat de verschillende bitmaps voor Pengo nu naast elkaar staan in de volgorde omhoog, naar beneden, naar links, naar rechts. De grootte van de gehele bitmap kunnen we makkelijk uitrekenen: vier keer BitmapWidth en drie keer BitmapHeight. Dat moeten we dus ook opgeven bij het laden van de bitmap.

Main.cpp
// bereken grootte van de te laden bitmap
int myWidth  = 4 * BitmapWidth;
int myHeight = 3 * BitmapHeight;

// laad bitmap
Bitmaps = LoadImage(0, "Pengo.bmp", IMAGE_BITMAP, myWidth, myHeight,
	LR_LOADFROMFILE);

De code om Pengo in verschillende richtingen te laten kijken, brengt op zich niet veel nieuws, omdat we in Console Pengo al iets soortgelijks hebben gedaan. We moeten alleen uitrekenen welk deel van de bitmap we moeten tekenen. Daarvoor vermenigvuldigen we het bitmapnummer met de het aantal pixels dat een bitmap breed is. Het bitmapnummer geeft aan waar de bitmap staat in het bestand, dus 0 voor omhoog, 1 voor naar beneden, 2 voor naar links en 3 voor naar rechts.

Graphics.cpp
/**
 * Tekent de speler naar het scherm.
 *
 * @param	player  	de speler die getekend moet worden
 * @param	windowDC	de device context van het venster
 * @param	bitmapDC	de device context waar de bitmaps op staan
 */
void DrawPlayer(const Player& player, HDC windowDC, HDC bitmapDC)
{
	int myBitmapPosition;

	// welke richting kijkt de speler op?
	switch (player.Direction)
	{
	case Down:
		{
			// bereken positie van bitmap in bestand
			myBitmapPosition = BitmapWidth * 0;
		} break;
	case Up:
		{
			// bereken positie van bitmap in bestand
			myBitmapPosition = BitmapWidth * 1;
		} break;	
	case Left:
		{
			// bereken positie van bitmap in bestand
			myBitmapPosition = BitmapWidth * 2;
		} break;
	case Right:
		{
			// bereken positie van bitmap in bestand
			myBitmapPosition = BitmapWidth * 3;
		} break;
	}

	// bereken x-coördinaat van bitmap
	int myX = player.X * BitmapWidth;

	// bereken y-coördinaat van bitmap
	int myY = player.Y * BitmapHeight;

	// teken bitmap
	BitBlt(windowDC, myX, myY, BitmapWidth, BitmapHeight, bitmapDC,
		myBitmapPosition, 0, SRCCOPY);
}

En daarmee hebben we Console Pengo geconverteerd naar WinPengo. :-)

[ Naar boven | Terug naar Pengo ]

Conclusie

En dat is het. We hebben heel Console Pengo geconverteerd naar Windows. Bovendien hebben we gezien hoe back buffering werkt en waar we het voor nodig hebben.

En daarmee is heel Pengo af. Heel Pengo? Nee. een kleine nederzetting bleef moedig weerstand bieden aan de overweldigers en maakte het leven van de Romeinen in de omringende legerplaatsen bepaald niet gemakkelijk. Ehm, ik bedoel... Nou ja, je ziet het volgende les wel.

Programmeren is leuk. :-)

[ Naar boven | Terug naar Pengo ]

Downloads

De volgende bestanden horen bij deze les.

[ Naar boven | Terug naar Pengo ]

Valid XHTML 1.0! Correct CSS! Laatst bijgewerkt: dinsdag 15 april 2014