Nieuws — Lesstof — Pengo — Projecten — Console API — Links
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.
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.
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.
case WM_PAINT: { // vertel Windows dat we WM_PAINT hebben afgehandeld ValidateRect(windowHandle, NULL); return 0; }
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.
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
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.
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
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.
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è.
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.
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.
/****************************************************************************** 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;
/****************************************************************************** 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.
// 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.
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; }
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.
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.
/** * 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.
case WM_PAINT: { // teken level DrawLevel(GlobalLevel, windowHandle); // vertel Windows dat we WM_PAINT hebben afgehandeld ValidateRect(windowHandle, NULL); return 0; }
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.
/****************************************************************************** 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.
/** * 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).
// 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.
/** * 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); }
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.
// 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.
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. :-)
De volgende bestanden horen bij deze les.