Bitmaps

Pengo © 2002-2003, Joost Ronkes Agerbeek

Geef maar toe: je vindt Console Pengo maar een lelijk ding. Je wilt 3D Pengo met full-screen anti-aliasing, perspective-correct texture mapping, texturing and lighting (T&L), bump mapping, environment mapping; kortom, een spel dat het maximale haalt uit je GeForce 4! Pech, je zult het moeten doen met 2D bitmaps.

Bitmap

Vorige les hebben we een venster op het scherm gezet, deze les tekenen we daar Pengo, de vijanden en de blokken in. We spreken alleen niet over tekeningen, maar over bitmaps. Deze bitmaps kun je maken in een tekenprogramma naar keuze, als je ze maar opslaat als BMP-bestand.

Ik heb de bitmaps die ik gebruik opgenomen in de downloads bij deze les. Ik raad je aan om deze bitmaps te gebruiken tijdens de les; als je de code af hebt, kun je de bitmaps naar hartelust wijzigen.

Als je het bestand pengo.bmp opent in een tekenprogramma, dan zie je het volgende.

Pengo.bmp bevat niet één bitmap, maar alle bitmaps voor WinPengo. Straks zal ik laten zien hoe je de goede bitmap uit het bestand haalt.

[ Naar boven | Terug naar Pengo ]

Tekenen in Windows

Omdat Windows event-driven is, kunnen we niet zo maar op elk willekeurig moment tekenen naar het venster. In plaats daarvan stuur Windows ons venster een WM_PAINT bericht als het venster opnieuw getekend moet worden. Dit bericht moeten we afhandelen in onze window procedure.

De volgende code handelt het WM_PAINT bericht af. De functie ValidateRect vertelt Windows dat we WM_PAINT ontvangen en verwerkt hebben. Als we dit niet doen, dan blijft Windows WM_PAINT naar ons venster sturen.

Main.cpp
case WM_PAINT:
	{
		// vertel Windows dat we WM_PAINT hebben afgehandeld
		ValidateRect(windowHandle, NULL);
		return 0;
	}
[ Naar boven | Terug naar Pengo ]

Bitmap laden

We kunnen Windows wel vertellen dat we WM_PAINT afgehandeld hebben, maar voorlopig hebben we nog niets gedaan. We willen een bitmap naar het venster tekenen, dus die bitmap moeten we eerst laden. Dat gaat met een aanroep naar LoadImage. Als we klaar zijn met de bitmap, halen we hem uit het geheugen door middel van een aanroep naar DeleteObject.

Main.cpp
case WM_PAINT:
	{
		// laad bitmap
		HANDLE myPengoBitmap = LoadImage(0, "Pengo.bmp", IMAGE_BITMAP,
			20, 60, LR_LOADFROMFILE);
			
		// verwijder bitmap uit geheugen
		DeleteObject(myPengoBitmap);

		// vertel Windows dat we WM_PAINT hebben afgehandeld
		ValidateRect(windowHandle, NULL);
		return 0;
	}

De eerste parameter is de handle van de instantie van de applicatie. Omdat wij onze bitmap uit een bestand halen, hoeven we deze niet op te geven en zetten we hem gewoon op 0. (Je kunt bitmaps ook opnemen in je programma en dan moet je hier wel de juiste handle doorgeven.)

De tweede parameter is de naam van het bestand dat we willen openen. De derde parameter vertelt dat we een bitmap willen openen (en geen cursor of pictogram). De volgende twee parameters geven aan hoe groot de bitmap is in pixels en de laatste parameter geeft aan dat we de bitmap uit een bestand willen laden.

En de bitmap is geladen. Viel wel mee, hè. Nu nog tekenen. Dat valt even tegen. :-s

[ Naar boven | Terug naar Pengo ]

Device contexts

Voordat we kunnen gaan tekenen, moeten we Windows om een stukje geheugen vragen waarin we mogen tekenen. Dit geheugen noemen we een device context. Wij willen tekenen in ons venster, dus we willen een device context hebben die bij ons venster hoort. Deze kunnen we opvragen door GetDC aan te roepen. Deze functie krijgt als parameter de handle mee van ons venster. Als we klaar zijn met tekenen, moeten we de device context weer vrijgeven door ReleaseDC aan te roepen.

Main.cpp
case WM_PAINT:
	{
		// laad bitmap
		HANDLE myPengoBitmap = LoadImage(0, "Pengo.bmp", IMAGE_BITMAP,
			20, 60, LR_LOADFROMFILE);

		// vraag device context voor venster op
		HDC myWindowDC = GetDC(windowHandle);
		
		// geef device contexts vrij
		ReleaseDC(windowHandle, myWindowDC);
		
		// verwijder bitmap uit geheugen
		DeleteObject(myPengoBitmap);

		// vertel Windows dat we WM_PAINT hebben afgehandeld
		ValidateRect(windowHandle, NULL);
		return 0;
	}

In Windows kunnen we alleen bitmaps kopiëren van de ene device context naar de andere, dus we zullen op één of andere manier onze bitmap in een device context moeten krijgen. Dit doen we door eerst een device context aan te maken die compatible is met de device context van ons venster en daarna de bitmap te selecteren.

Valt het op dat ik heel veel details weglaat? Mmm. Als het maar werkt, niet waar. :-P

Main.cpp
case WM_PAINT:
	{
		// laad bitmap
		HANDLE myPengoBitmap = LoadImage(0, "Pengo.bmp", IMAGE_BITMAP,
			20, 60, LR_LOADFROMFILE);
	
		// 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, myPengoBitmap);
		
		// geef device context van bitmap vrij
		DeleteDC(myBitmapDC);
		
		// geef device contexts vrij
		ReleaseDC(windowHandle, myWindowDC);
		
		// verwijder bitmap uit geheugen
		DeleteObject(myPengoBitmap);
	
		// vertel Windows dat we WM_PAINT hebben afgehandeld
		ValidateRect(windowHandle, NULL);
		return 0;
	}

Je ziet, ook de device context van de bitmap moet je vrijgeven, maar dit keer doe je dat met een aanroep naar DeleteDC. Dit komt doordat je de device context van de bitmap zelf hebt aangemaakt. De device context van het venster bestond al; die heb je alleen maar opgevraagd.

[ Naar boven | Terug naar Pengo ]

Bitmap kopiëren

Tot slot moeten we de bitmap kopiëren. Dit doen we met de functie BitBlt. Ik kan er niets aan doen, zo heet hij echt. BitBlt staat voor bitmap blit en blit is een afkorting (nou ja, afkorting...) van bit-block transfer. Duidelijk, hè.

Main.cpp
case WM_PAINT:
	{
		// laad bitmap
		HANDLE myPengoBitmap = LoadImage(0, "Pengo.bmp", IMAGE_BITMAP,
			20, 60, LR_LOADFROMFILE);

		// 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, myPengoBitmap);

		// teken bitmap
		BitBlt(myWindowDC, 0, 0, 20, 60, myBitmapDC, 0, 0, SRCCOPY);

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

		// geef device context van venster vrij
		ReleaseDC(windowHandle, myWindowDC);
		
		// verwijder bitmap uit geheugen
		DeleteObject(myPengoBitmap);

		// vertel Windows dat we WM_PAINT hebben afgehandeld
		ValidateRect(windowHandle, NULL);
		return 0;
	}

Met de eerste parameter geef je aan op welke device context je wilt tekenen. De twee parameters daarna zijn de coördinaten waar je de bitmap wilt hebben en de volgende twee parameters zijn de grootte van de bitmap. De zesde parameter is de device context die onze bitmap bevat. Daarvan kun je met de volgende parameters aangeven vanaf welke positie in de bitmap je de bitmap getekend wilt hebben (later meer hierover). De laatste parameter geeft aan dat we de bitmap letterlijk willen kopiëren.

[ Naar boven | Terug naar Pengo ]

Initialisatie

Het WM_PAINT bericht wordt tijdens het uitvoeren van WinPengo velen keren aangeroepen. Het is dan natuurlijk helemaal niet handig als we de bitmap telkens opnieuw uit het bestand moeten lezen. We kunnen de bitmap beter helemaal aan het begin van het programma één keer inlezen en hem dan daarna meerdere keren gebruiken. Om te zorgen dat de bitmap vanuit het hele programma bereikbaar is, maken we hem globaal. Een bitmap heeft natuurlijk met graphics te maken, dus we maken de bestanden Graphics.cpp en Graphics.h aan.

Graphics.cpp
/******************************************************************************
	Bestand:   Graphics.cpp
	Project:   WinPengo

	Copyright: (c) 2003 Joost Ronkes Agerbeek

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

#include <windows.h>

/******************************************************************************
	Globale variabelen
******************************************************************************/

// alle bitmaps die we tijdens het spel gebruiken
HANDLE Bitmaps;
Graphics.h
/******************************************************************************
	Bestand:   Graphics.h
	Project:   WinPengo

	Copyright: (c) 2003 Joost Ronkes Agerbeek

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

#ifndef __GRAPHICS_H__
#define __GRAPHICS_H__

#include <windows.h>

/******************************************************************************
	Externe globale variabelen
******************************************************************************/

// alle bitmaps die we tijdens het spel gebruiken
extern HANDLE Bitmaps;

#endif

Je ziet dat ik de variabele Bitmaps (meervoud) heb genoemd. Dit heb ik gedaan omdat we alle bitmaps in één bitmap hebben opgeslagen. We laden de bitmap vlak voordat de event loop start en na de event loop verwijderen we de bitmap uit het geheugen. Vergeet niet Graphics.h te includen.

Main.cpp
// laad bitmap
Bitmaps = LoadImage(0, "Pengo.bmp", IMAGE_BITMAP, 20, 60, LR_LOADFROMFILE);

// 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);
	}
} while (msg.message != WM_QUIT);

// verwijder bitmap uit geheugen
DeleteObject(Bitmaps);

De code om de bitmap te laden kunnen we nu natuurlijk weghalen uit de window procedure.

Main.cpp
case WM_PAINT:
	{	
		// 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);

		// teken bitmap
		BitBlt(myWindowDC, 0, 0, 20, 60, myBitmapDC, 0, 0, SRCCOPY);

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

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

		// vertel Windows dat we WM_PAINT hebben afgehandeld
		ValidateRect(windowHandle, NULL);
		return 0;
	}
[ Naar boven | Terug naar Pengo ]

Van Console Pengo naar WinPengo

We zijn nu zo ver dat we Console Pengo om kunnen zetten naar WinPengo. Om dat te doen, hebben we een aantal bestanden van Console Pengo nodig. Kopieer de volgende tien bestanden van de map waar Console Pengo in staat naar de map waar WinPengo in staat en voeg ze toe aan je project.

Als je de code nu compileert krijg je een foutmelding. In de functie EraseBlock in Enemy.cpp wordt ClearBlock aangeroepen. Die functie hebben we niet meegekopieerd en we hebben 'm ook niet meer nodig. Je kunt de aanroep dus verwijderen.

[ Naar boven | Terug naar Pengo ]

Level tekenen

Je ziet dat we de code van Console Pengo gewoon opnieuw kunnen gebruiken. Het enige dat we moeten veranderen is de code waarmee we het level tekenen en de game loop. Het tekenen van een level zetten we weer in een functie DrawLevel. Deze functie roepen we dan aan vanuit de window procedure. Vergeet niet om DrawLevel op te nemen in Graphics.h.

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);

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

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

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

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

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

De functie DrawLevel is in feite een combinatie van DrawLevel uit Console Pengo en de code die we in de window procedure hadden staan. Het enige nieuwe is dat de functies DrawBlock, DrawEnemy en DrawPlayer (die we nog moeten schrijven) de device contexts van het venster en de bitmaps meekrijgen. Hierdoor weten deze functies waarheen ze moeten tekenen en het voorkomt dat we de device context met de bitmaps meerdere keren moeten aanmaken.

Merk ook op dat DrawLevel de window handle meekrijgt als parameter. Die hebben we namelijk nodig om de device context van het venster op te kunnen vragen.

Van de code voor het afhandelen van WM_PAINT blijft niet veel meer over: DrawLevel aanroepen en Windows vertellen dat WM_PAINT is afgehandeld.

Main.cpp
case WM_PAINT:
	{
		// teken level
		DrawLevel(GlobalLevel, windowHandle);

		// vertel Windows dat we WM_PAINT hebben afgehandeld
		ValidateRect(windowHandle, NULL);
		return 0;
	}
[ Naar boven | Terug naar Pengo ]

Vijanden, spelers en blokken tekenen

Zoals de code nu is, kun je hem niet compileren omdat we de functies DrawBlock, DrawEnemy en DrawPlayer nog niet hebben geschreven. Dat gaan we nu doen. We beginnen met DrawPlayer.

We zullen eerst moeten berekenen waar we de speler moeten tekenen. In de structure Player slaan we de positie op in het aantal vakjes dat de speler naar rechts en naar beneden staat, maar nu willen we niet het aantal vakjes weten, maar het aantal pixels. Om dat uit te kunnen rekenen moeten we weten hoeveel pixels er in een vakje gaan. In ons geval is elk vakje 20 pixels breed en 20 pixels hoog.

Om van het aantal vakjes tot het aantal pixels te komen, moeten we het aantal vakjes vermenigvuldigen met het aantal pixels per vakje. Bijvoorbeeld, als Player.X gelijk is aan 12, dan moeten we de speler tekenen op positie 20 * 12 = 240. We kunnen het getal 20 natuurlijk rechtstreeks in onze code zetten, maar dat is niet zo slim. Als we later grotere of kleinere bitmaps willen gebruiken, moeten we dat in onze code makkelijk kunnen veranderen. We gebruiken daarom weer constanten.

Graphics.h
/******************************************************************************
	Constante variabelen
******************************************************************************/

// de breedte van een bitmap
const int BitmapWidth = 20;

// de hoogte van een bitmap
const int BitmapHeight = 20;

In DrawPlayer kunnen we nu als volgt bepalen waar de bitmap van de speler getekend moet worden.

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)
{
	// bereken x-coördinaat van bitmap
	int myX = player.X * BitmapWidth;

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

Deze coördinaten geven we straks mee aan BitBlt. Nu moeten we alleen uit de grote bitmap met alle bitmaps voor WinPengo nog het juiste stukje halen. Dit kunnen we doen door aan BitBlt de grootte mee te geven van het stukje dat we willen hebben en de positie van het stukje binnen de bitmap. De grootte hebben we in de constanten staan en de positie is in het geval van de speler vrij makkelijk: helemaal linksboven, oftwel (0, 0).

Graphics.cpp
// teken bitmap
BitBlt(windowDC, myX, myY, BitmapWidth, BitmapHeight, bitmapDC, 0, 0,
	SRCCOPY);

De code voor het tekenen van de blokken en de vijanden verschilt maar weinig van de code voor het tekenen van de speler. Het enige verschil zit hem in de positie van de plaatjes binnen de grote bitmap. De vijand staat op de tweede regel en die begint op positie 1 * 20 = 20 (denk eraan, we beginnen altijd bij 0 te tellen). Het blok staat op de derde regel en die begint op positie 2 * 20 = 40. In de code vervangen we de 20 in de berekening weer door de constante.

Graphics.cpp
/**
 * Tekent een blok naar het scherm.
 *
 * @param	block		het blok dat getekend moet worden
 * @param	windowDC	de device context van het venster
 * @param	bitmapDC	de device context waar de bitmaps op staan
 */
void DrawBlock(const Block& block, HDC windowDC, HDC bitmapDC)
{
	// bereken x-coördinaat van bitmap
	int myX = block.X * BitmapWidth;

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

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

/**
 * Tekent een vijand naar het scherm.
 *
 * @param	enemy		de vijand die getekend moet worden
 * @param	windowDC	de device context van het venster
 * @param	bitmapDC	de device context waar de bitmaps op staan
 */
void DrawEnemy(const Enemy& enemy, HDC windowDC, HDC bitmapDC)
{
	// bereken x-coördinaat van bitmap
	int myX = enemy.X * BitmapWidth;

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

	// teken bitmap
	BitBlt(windowDC, myX, myY, BitmapWidth, BitmapHeight, bitmapDC,
		0, 1 * BitmapHeight, SRCCOPY);
}
[ Naar boven | Terug naar Pengo ]

Venstergrootte

Als je Pengo nu start is de kans groot dat het level niet precies in het venster past. We moeten het venster dus aanpassen aan de grootte van het venster. Het is niet zo heel moeilijk om uit te rekenen hoe groot het venster moet zijn. We weten hoeveel vakjes er in een level gaan en we weten hoe groot elk vakje is. Als we die twee gegevens met elkaar vermenigvuldigen, weten we hoe groot het level in totaal is.

Het enige probleem dat we hebben, is dat we nu de randen van het venster en de titelbalk niet meerekenen. Gelukkig heeft de Win32 API een functie die dat voor ons kan uitrekenen: AdjustWindowRect. We stoppen er de grootte van ons level in en we krijgen de grootte van het hele venster terug. Als we dit uitrekenen voordat we ons venster aanmaken, kunnen we de grootte meegeven aan CreateWindow.

Main.cpp
// stel grootte van level in
RECT myRectangle;

myRectangle.left   = 0;
myRectangle.right  = GlobalLevel.Width * 20;
myRectangle.top    = 0;
myRectangle.bottom = GlobalLevel.Height * 20;

// bereken grootte van venster
AdjustWindowRect(&myRectangle, WS_CAPTION, false);

int myWindowWidth = myRectangle.right - myRectangle.left;
int myWindowHeight = myRectangle.bottom - myRectangle.top;

// maak venster
HWND myWindowHandle = CreateWindow(
	"StandardWindowClass",       	// naam van de window class
	"Pengo",                      	// titel van het venster
	WS_OVERLAPPED | WS_VISIBLE | WS_SYSMENU,	// vensterstijl
	CW_USEDEFAULT, CW_USEDEFAULT, 	// positie van het venster
	myWindowWidth, myWindowHeight,	// grootte van het venster
	0, 0,                        	// deze parameters gebruiken wij niet
	instance,                    	// de instantie van de applicatie
	0);

De code ziet er misschien wat lastig uit, maar het valt wel mee. De Win32 API heeft een structure RECT waarin we een vierkant kunnen opslaan. Hierin zetten we de grootte van ons level en we geven het mee aan AdjustWindowRect. AdjustWindowRect past het vierkant aan, dus nadat we de functie hebben aangeroepen zijn de waarden in myRectangle verandert. Verder moeten we aan AdjustWindowRect opgeven welke window style ons venster heeft. Het enige dat van invloed is op de grootte van het venster, is het feit dat het een titelbalk heeft en dat geven we aan met WS_CAPTION. De derde parameter staat op false en geeft aan dat ons venster geen menubalk bevat.

Nu myRectangle de juiste dimensies van ons venster bevat, kunnen we de breedte en de hoogte uitrekenen. Let op, je mag er niet vanuit gaan de myRectangle.left en myRectangle.top gelijk zijn aan 0. De breedte en de hoogte van het venster geven we vervolgens mee aan CreateWindow en ons venster heeft nu de juiste grootte.

[ Naar boven | Terug naar Pengo ]

Conclusie

En daar moet je het voorlopig mee doen. We hebben gezien hoe we in Windows een bitmap kunnen laden en die kunnen tekenen in het venster. Bovendien hebben we gezien hoe we bepaalde stukjes uit een bitmap kunnen halen en we hebben de grootte van ons venster aangepast aan ons level.

De volgende keer zetten we het hele zaakje in beweging. Ja, ja, je hebt bijna een spel dat niet alleen speelbaar is, maar dat er ook goed genoeg uit ziet om aan je vrienden te laten zien. :-)

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