Nieuws — Lesstof — Pengo — Projecten — Console API — Links
Pengo © 2002-2003, Joost Ronkes Agerbeek
Op dit moment hebben we een klein probleempje met Pengo. Hij kan namelijk overal doorheen lopen. Daarom houden we ons in deze les bezig met het programmeren van de botsingen. De Engelse term hiervoor is collision detection.
Voordat we botsingen gaan detecteren, moeten we een beetje voorbereidend werk doen. We moeten straks namelijk beschikking hebben over allerlei gegevens van het level. Als we willen dat Pengo niet van het level afloopt, dan moeten we de grootte van het level weten. Als we willen weten of Pengo tegen een blok aan trapt, dan moeten we weten waar de blokken staan.
Al dit soort gegevens staan opgeslagen in het level, dus het level moet overal beschikbaar zijn. Daarom maken we het level globaal. Ten eerste initialiseren we de globale variabele in Level.cpp.
/****************************************************************************** Globale variabelen ******************************************************************************/ // het level Level GlobalLevel = LoadLevel();
We kunnen de naam Level niet meer gebruiken omdat het datatype al zo heet, dus ik heb het level GlobalLevel genoemd. Nu moeten we de declaratie nog opnemen in Level.h. Dit doen we met extern.
/****************************************************************************** Externe globale variabelen ******************************************************************************/ extern Level GlobalLevel;
Het level is nu opgeslagen in een globale variabele. We hoeven dus geen level meer aan te maken in Main.cpp en alle verwijzingen naar myLevel moeten we vervangen door GlobalLevel. Dat is weer een mooie taak voor Zoeken en Vervangen.
// teken level DrawLevel(GlobalLevel); // maak vijand aan Enemy myEnemy = CreateEnemy(10, 10); // teken vijand DrawEnemy(myEnemy); // start game loop int myKey; do { // vraag huidige tijd op DWORD myTime = timeGetTime(); // verwijder speler van scherm ClearPlayer(GlobalLevel.Player); // is er een toets ingedrukt? if (PeekVirtualKey() != 0) { // ja, verwijder toets uit buffer myKey = GetVirtualKey(); // welke toets is ingedrukt? switch (myKey) { 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, myBlock); } break; } } // verplaats de speler MovePlayer(GlobalLevel.Player); // teken level DrawLevel(GlobalLevel); // wacht tot vertraging om is while (timeGetTime() < (myTime + GameDelay)); } while (myKey != GameExit);
En dan nu het echte werk. Laten we er eerst maar voor zorgen dat de speler geen ongeoorloofde dingen kan doen als het level uitwandelen of door blokken en vijanden heen lopen.
Om te beginnen zorgen we ervoor dat Pengo niet van het veld af kan lopen. Op dit moment zorgen we er in MovePlayer al voor dat de speler niet van het scherm af kan. We hoeven dus alleen maar wat if-statements aan te passen. In plaats van de getallen 79 en 24 (de grootte van het scherm) maken we nu gebruik van de hoogte en de breedte van het veld. Min 1, want we beginnen weer te tellen bij 0.
// ja, in welke richting beweegt de speler? switch (player.Direction) { case Up: { // kan speler nog omhoog? if (player.Y > 0) { // ja, verplaats speler omhoog player.Y--; } else { // nee, zet speler stil player.IsMoving = false; } } break; case Down: { // kan speler nog omlaag? if (player.Y < (GlobalLevel.Height - 1)) { // ja, verplaats speler omlaag player.Y++; } else { // nee, zet speler stil player.IsMoving = false; } } break; case Left: { // kan speler nog naar links? if (player.X > 0) { // ja, verplaats speler naar links player.X--; } else { // nee, zet speler stil player.IsMoving = false; } } break; case Right: { // kan speler nog naar rechts? if (player.X < (GlobalLevel.Width - 1)) { // ja, verplaats speler naar rechts player.X++; } else { // nee, zet speler stil player.IsMoving = false; } } break; }
De speler blijft nu keurig binnen het veld, maar hij kan nog steeds dwars door blokken heenlopen. Om dit op te lossen hebben we iets meer creativiteit nodig. We willen namelijk weten of het vakje waar de speler naartoe loopt al een blok bevat. Hiervoor schrijven we een aparte functie die in Level.cpp terecht komt.
/** * Bepaalt of er op de opgegeven coördinaten een blok staat. * * @param x de x-coördinaat die gecontroleerd moet worden * @param y de y-coördinaat die gecontroleerd moet worden * * @return true als er op de opgegeven coördinaten een blok staat, anders * false */ bool IsBlock(int x, int y) { // controleer alle blokken stuk-voor-stuk for (int i = 0; i < GlobalLevel.Blocks.size(); i++) { // is dit het blok dat we zoeken? Block myBlock = GlobalLevel.Blocks.at(i); if ((myBlock.X == x) && (myBlock.Y == y)) { // ja, er staat dus een blok op de opgegeven coördinaten return true; } } // het blok is niet gevonden, dus er staat geen blok op de opgegeven // coördinaten return false; }
In de functie IsBlock lopen we alle blokken langs die in het level staan en we vergelijken de coördinaten van de blokken met de opgegeven coördinaten. Neem de definitie van IsBlock op in Level.h.
Hiermee kunnen we controleren of een speler tegen een blok aanloopt. Ook deze wijziging moeten we doorvoeren in MovePlayer. We roepen IsBlock met de coördinaten waar de speler terecht komt en als er al een blok staat, dan mag de speler daar niet heen lopen.
// ja, in welke richting beweegt de speler? switch (player.Direction) { case Up: { // zet speler stil player.IsMoving = false; // kan speler nog omhoog? if (player.Y > 0) { // ja, loopt speler tegen blok aan? if (!IsBlock(player.X, player.Y - 1)) { // nee, verplaats speler omhoog player.Y--; // zet speler weer in beweging player.IsMoving = true; } } } break; case Down: { // zet speler stil player.IsMoving = false; // kan speler nog omlaag? if (player.Y < (GlobalLevel.Height - 1)) { // ja, loopt speler tegen blok aan? if (!IsBlock(player.X, player.Y + 1)) { // nee, verplaats speler omlaag player.Y++; // zet speler weer in beweging player.IsMoving = true; } } } break; case Left: { // zet speler stil player.IsMoving = false; // kan speler nog naar links? if (player.X > 0) { // ja, loopt speler tegen blok aan? if (!IsBlock(player.X - 1, player.Y)) { // nee, verplaats speler naar links player.X--; // zet speler weer in beweging player.IsMoving = true; } } } break; case Right: { // zet speler stil player.IsMoving = false; // kan speler nog naar rechts? if (player.X < (GlobalLevel.Width - 1)) { // ja, loopt speler tegen blok aan? if (!IsBlock(player.X + 1, player.Y)) { // nee, verplaats speler naar rechts player.X++; // zet speler weer in beweging player.IsMoving = true; } } } break; }
Om de structuur van de code duidelijk te houden, heb ik een kleine wijziging doorgevoerd. Ik zet de speler nu eerst even stel. Alleen als de speler kan verplaatsen, zet ik 'm weer in beweging. Overigens, als je niet wilt dat de speler door blijft lopen, kun je uit bovenstaande functie de regels player.IsMoving = true; schrappen.
Het is tijd om te zorgen dat je dit spel kunt verliezen. Je kunt nog niet winnen, maar goed. Een spel waarin je alleen maar kunt verliezen is spannender dan een spel waarin je alleen maar doelloos kunt rondlopen.
Je verliest als je tegen een vijand aanloopt. We willen dus weten waar de vijanden staan. Hiervoor schrijven we een functie IsEnemy die wel erg veel lijkt op de functie IsBlock. Vergeet niet de definitie van de functie op te nemen in Level.h.
/** * Bepaalt of er op de opgegeven coördinaten een vijand staat. * * @param x de x-coördinaat die gecontroleerd moet worden * @param y de y-coördinaat die gecontroleerd moet worden * * @return true als er op de opgegeven coördinaten een vijand staat, anders * false */ bool IsEnemy(int x, int y) { // controleer alle vijanden stuk-voor-stuk for (int i = 0; i < GlobalLevel.Enemies.size(); i++) { // is dit het vijand dat we zoeken? Enemy myEnemy = GlobalLevel.Enemies.at(i); if ((myEnemy.X == x) && (myEnemy.Y == y)) { // ja, er staat dus een vijand op de opgegeven coördinaten return true; } } // het vijand is niet gevonden, dus er staat geen vijand op de opgegeven // coördinaten return false; }
Zodra de speler tegen een vijand aanloopt, is de speler dood. Dat moeten we kunnen opslaan, dus we breiden de struct Player uit met een variabele IsDead die we bij het aanmaken van een speler op false zetten.
/** * Een speler. */ struct Player { /** * De positie van de speler. */ int X, Y; /** * De richting die de speler op loopt. */ Directions Direction; /** * Geeft aan of de speler beweegt. */ bool IsMoving; /** * Geeft aan of de speler dood is. */ bool IsDead; };
/** * Maakt een nieuwe speler aan en initialiseert de gegevens van de speler. * * @param de x-coördinaat waarop de speler start * @param de y-coördinaat waarop de speler start * * @return een nieuwe speler */ Player CreatePlayer(int x, int y) { // maak nieuwe speler Player myPlayer; // stel standaardwaarden in myPlayer.X = x; myPlayer.Y = y; myPlayer.IsMoving = false; myPlayer.Direction = Up; myPlayer.IsDead = false; // geef speler terug return myPlayer; }
Oke, laten we nu maar eens controleren of de speler tegen een vijand is aangelopen. Dit doen we wederom in MovePlayer. De onderstaande code komt nadat de speler al verplaatst is, dus na de switch.
// is de speler tegen een vijand aangelopen? if (IsEnemy(player.X, player.Y)) { // ja, speler is dood :-( player.IsDead = true; }
Als de speler dood is, moet het spel natuurlijk niet verder gaan. De game loop moet nu dus niet alleen stoppen als er op Escape wordt gedrukt, maar ook als de speler dood is. Met andere woorden, de game loop wordt uitgevoerd zolang er niet op Escape is gedrukt en zolang de speler niet dood is.
} while ((myKey != GameExit) && (!GlobalLevel.Player.IsDead));
De speler kan nu niet meer door blokken heen lopen, maar ooit was het zo dat je blokken kon wegschoppen. Het wordt tijd om die mogelijkheid nieuw leven in te blazen. Daarbij mogen de blokken natuurlijk ook niet door andere blokken heen en je moet ze kapot kunnen schoppen en je moet vijanden kunnen pletten. Sjonge, laten we maar snel verder gaan.
De functie Kick ligt al een tijdje te slapen, dus laten we 'm maar eens wakker schudden. Nu is het zo dat je aan Kick het blok meegeeft waar je tegenaan trapt. Omdat er meerdere blokken in het level staan, moeten we dat aanpassen. Je geeft geen blok meer mee; Kick controleert of je een blok geraakt hebt.
Om dit te kunnen doen is het niet voldoende om te weten òf Pengo tegen een blok aanschopt, we moeten ook weten welk blok dat is. Hiervoor schrijven we een functie GetBlock die het blok teruggeeft dat op de opgegeven coördinaten staat. Denk aan de definitie in het headerbestand.
/** * Vraagt het blok op dat op de opgegeven positie staat. * * @param x de x-coördinaat van het op te vragen blok * @param y de y-coördinaat van het op te vragen blok * * @return het blok dat op de opgegeven positie staat */ Block& GetBlock(int x, int y) { // controleer alle blokken stuk-voor-stuk for (int i = 0; i < GlobalLevel.Blocks.size(); i++) { // is dit het blok dat we zoeken? Block& myBlock = GlobalLevel.Blocks.at(i); if ((myBlock.X == x) && (myBlock.Y == y)) { // ja, geef het blok terug return myBlock; } } // hier zouden we nooit terecht mogen komen, maar om de compiler gerust // te stellen geven we het eerste blok terug return GlobalLevel.Blocks.at(0); }
Let op de ampersand (&) achter de return value Block. Deze hebben we al eerder gezien bij parameters. De return value van GetBlock is dus een reference: we krijgen geen kopie terug van het blok, maar het blok zelf. Alle veranderingen die we daarin aanbrengen, worden dus ook doorgevoerd in het level. Dit noemen we return-by-reference en - voor degene die het zich afvraagd - als je de ampersand weghaalt, spreken we van return-by-value.
Waarschuwing: de functie GetBlock werkt alleen goed als je coördinaten meegeeft waarop daadwerkelijk een blok staat. Je moet de coördinaten dus eerst testen met IsBlock. Dit is geen nette manier om dit probleem te programmeren, maar voor de oplossing hebben we òf pointers òf exceptions nodig (of op z'n minst een assert) en die onderwerpen vallen buiten de scope van dit project.
Laten we maar eens kijken hoe de nieuwe Kick functie eruit ziet.
/** * Laat de speler schoppen. * * @param player de speler die schopt * @param block het blok waar de speler tegenaan kan schoppen */ void Kick(const Player& player) { // in welke richting schopt de speler? switch (player.Direction) { case Up: { // schopt speler tegen blok aan? if (!IsBlock(player.X, player.Y - 1)) { // nee, klaar return; } // vraag blok op waar speler tegenaan schopt Block& myBlock = GetBlock(player.X, player.Y - 1); // zet blok in beweging myBlock.Direction = Up; myBlock.IsMoving = true; } break; case Down: { // schopt speler tegen blok aan? if (!IsBlock(player.X, player.Y + 1)) { // nee, klaar return; } // vraag blok op waar speler tegenaan schopt Block& myBlock = GetBlock(player.X, player.Y + 1); // zet blok in beweging myBlock.Direction = Down; myBlock.IsMoving = true; } break; case Left: { // schopt speler tegen blok aan? if (!IsBlock(player.X - 1, player.Y)) { // nee, klaar return; } // vraag blok op waar speler tegenaan schopt Block& myBlock = GetBlock(player.X - 1, player.Y); // zet blok in beweging myBlock.Direction = Left; myBlock.IsMoving = true; } break; case Right: { // schopt speler tegen blok aan? if (!IsBlock(player.X + 1, player.Y)) { // nee, klaar return; } // vraag blok op waar speler tegenaan schopt Block& myBlock = GetBlock(player.X + 1, player.Y); // zet blok in beweging myBlock.Direction = Right; myBlock.IsMoving = true; } break; } }
Ja, ja, laat dat maar even op je inwerken. :-P
In de game loop testen we weer of de speler Pengo wil laten schoppen...
case PengoKick: { // laat speler schoppen Kick(GlobalLevel.Player); } break;
...en we verplaatsen alle blokken.
// verplaats de blokken for (int i = 0; i < GlobalLevel.Blocks.size(); i++) { MoveBlock(GlobalLevel.Blocks.at(i)); }
Compileren, starten en... jawel, de oude vertrouwde bug. De blokken laten sporen achter. We hebben dus een functie ClearBlock nodig (inclusief definitie).
/** * Verwijdert een blok van het scherm. * * @param block het blok dat verwijderd moet worden */ void ClearBlock(const Block& block) { // verwijder blok WriteText(" ", block.X, block.Y); }
Om nou te voorkomen dat alle blokken op het scherm constant flikkeren, verwijderen we een blok alleen als het in beweging is. En als we dan toch bezig zijn, kunnen we dat met de speler ook wel doen. Je kunt de aanroep van ClearPlayer aan het begin van de game loop dus weghalen.
// beweegt de speler? if (GlobalLevel.Player.IsMoving) { // ja, verwijder speler van het scherm ClearPlayer(GlobalLevel.Player); // verplaats de speler MovePlayer(GlobalLevel.Player); } // verplaats de blokken for (int i = 0; i < GlobalLevel.Blocks.size(); i++) { // beweegt het blok? if (GlobalLevel.Blocks.at(i).IsMoving) { // ja, verwijder het van het scherm ClearBlock(GlobalLevel.Blocks.at(i)); // verplaats het blok MoveBlock(GlobalLevel.Blocks.at(i)); } } // teken level DrawLevel(GlobalLevel);
Ook blokken mogen niet van het level af of door andere blokken heen bewegen. We moeten in de functie MoveBlock dus dezelfde aanpassingen doen als in MovePlayer.
/** * Verplaatst het blok één positie. * * @param block het blok dat verplaatst moet worden */ void MoveBlock(Block& block) { // beweegt het blok? if (block.IsMoving) { // ja, in welke richting beweegt het blok? switch (block.Direction) { case Up: { // zet blok stil block.IsMoving = false; // kan blok nog omhoog? if (block.Y > 0) { // ja, loopt blok tegen blok aan? if (!IsBlock(block.X, block.Y - 1)) { // nee, verplaats blok omhoog block.Y--; // zet blok weer in beweging block.IsMoving = true; } } } break; case Down: { // zet blok stil block.IsMoving = false; // kan blok nog omlaag? if (block.Y < (GlobalLevel.Height - 1)) { // ja, loopt blok tegen blok aan? if (!IsBlock(block.X, block.Y + 1)) { // nee, verplaats blok omlaag block.Y++; // zet blok weer in beweging block.IsMoving = true; } } } break; case Left: { // zet blok stil block.IsMoving = false; // kan blok nog naar links? if (block.X > 0) { // ja, loopt blok tegen blok aan? if (!IsBlock(block.X - 1, block.Y)) { // nee, verplaats blok naar links block.X--; // zet blok weer in beweging block.IsMoving = true; } } } break; case Right: { // zet blok stil block.IsMoving = false; // kan blok nog naar links? if (block.X < (GlobalLevel.Width - 1)) { // ja, loopt blok tegen blok aan? if (!IsBlock(block.X + 1, block.Y)) { // nee, verplaats blok naar rechts block.X++; // zet blok weer in beweging block.IsMoving = true; } } } break; } } }
Als je tegen een blok schopt dat tegen een ander blok aan staat, dan vernietig je het blok. Dat betekent dus dat we in de functie Kick niet alleen moeten controleren of Pengo tegen een blok aantrapt, maar ook of er een blok direct naast staat. Is dat het geval, dan moet het blok waar Pengo tegenaan schopt van het level verwijderd worden.
Het verwijderen van een blok van het level doen we met een aparte functie, die in Level.cpp terecht komt. Vergeet niet de declaratie op te nemen in Level.h.
/** * Verwijdert een blok van het level. * * @param block het blok dat verwijderd moet worden */ void EraseBlock(Block& block) { // controleer alle blokken stuk-voor-stuk for (int i = 0; i < GlobalLevel.Blocks.size(); i++) { // is dit het blok dat we zoeken? Block myBlock = GlobalLevel.Blocks.at(i); if ((myBlock.X == block.X) && (myBlock.Y == block.Y)) { // ja, verwijder blok van scherm ClearBlock(myBlock); // verwijder blok uit lijst GlobalLevel.Blocks.erase(GlobalLevel.Blocks.begin() + i); } } }
Voordat je een blok van het level verwijderd, moet je het ook van het scherm afhalen. Vandaar de aanroep van ClearBlock. Het weghalen van een blok uit de vector gaat op een wat rare manier. De memberfunctie erase neemt deze taak op zich, maar de parameter is misschien niet helemaal duidelijk. Je moet een zogenaamde iterator meegeven. Zonder nou uit te leggen wat het is, kun je zeggen: begin() wijst naar het eerste blok in de lijst en met + i wijs je naar het i-de blok in de lijst. Ietwat vreemd, maar het moet nou eenmaal zo.
We moeten nu aan de functie Kick de code toevoegen die controleert of er een blok staat naast het blok waar Pengo tegenaan schopt.
/** * Laat de speler schoppen. * * @param player de speler die schopt */ void Kick(const Player& player) { // in welke richting schopt de speler? switch (player.Direction) { case Up: { // schopt speler tegen blok aan? if (!IsBlock(player.X, player.Y - 1)) { // nee, klaar return; } // vraag blok op waar speler tegenaan schopt Block& myBlock = GetBlock(player.X, player.Y - 1); // staat er nog een blok naast? if (IsBlock(player.X, player.Y - 2)) { // ja, verwijder blok van level EraseBlock(myBlock); } else { // nee, zet blok in beweging myBlock.Direction = Up; myBlock.IsMoving = true; } } break; case Down: { // schopt speler tegen blok aan? if (!IsBlock(player.X, player.Y + 1)) { // nee, klaar return; } // vraag blok op waar speler tegenaan schopt Block& myBlock = GetBlock(player.X, player.Y + 1); // staat er nog een blok naast? if (IsBlock(player.X, player.Y + 2)) { // ja, verwijder blok van level EraseBlock(myBlock); } else { // zet blok in beweging myBlock.Direction = Down; myBlock.IsMoving = true; } } break; case Left: { // schopt speler tegen blok aan? if (!IsBlock(player.X - 1, player.Y)) { // nee, klaar return; } // vraag blok op waar speler tegenaan schopt Block& myBlock = GetBlock(player.X - 1, player.Y); // staat er nog een blok naast? if (IsBlock(player.X - 2, player.Y)) { // ja, verwijder blok van level EraseBlock(myBlock); } else { // zet blok in beweging myBlock.Direction = Left; myBlock.IsMoving = true; } } break; case Right: { // schopt speler tegen blok aan? if (!IsBlock(player.X + 1, player.Y)) { // nee, klaar return; } // vraag blok op waar speler tegenaan schopt Block& myBlock = GetBlock(player.X + 1, player.Y); // staat er nog een blok naast if (IsBlock(player.X + 2, player.Y)) { // ja verwijder blok van level EraseBlock(myBlock); } else { // zet blok in beweging myBlock.Direction = Right; myBlock.IsMoving = true; } } break; } }
Tot slot moet je met een blok je vijanden kunnen pletten. Om dit voor elkaar te krijgen hebben we een functie EraseEnemy nodig die vijanden van het level haalt. Deze functie lijkt veel op EraseBlock. Denk aan de declaratie in Level.h.
/** * Verwijdert een vijand van het level. * * @param x de x-coördinaat van de vijand die verwijderd moet worden * @param y de y-coördinaat van de vijand die verwijderd moet worden */ void EraseEnemy(int x, int y) { // controleer alle vijanden stuk-voor-stuk for (int i = 0; i < GlobalLevel.Enemies.size(); i++) { // is dit het blok dat we zoeken? Enemy myEnemy = GlobalLevel.Enemies.at(i); if ((myEnemy.X == x) && (myEnemy.Y == y)) { // verwijder vijand uit lijst GlobalLevel.Enemies.erase(GlobalLevel.Enemies.begin() + i); } } }
Omdat we geen functie hebben die ons een Enemy teruggeeft, verwacht EraseEnemy geen Enemy, maar de coördinaten van een Enemy. De wijziging die dit teweeg brengt in onze code is minimaal. (Zoek de verschillen.)
Merk op dat we de vijand niet van het scherm verwijderen, zoals we dat met het blok wel gedaan hadden. Dat hoeft ook niet, want we teken toch een blok over de vijand heen.
In MoveBlock bepalen we nu met behulp van IsEnemy of het blok misschien een vijand raakt. Als dat zo is, dan halen we de vijand weg. Voeg onderstaande code toe net na het bewegen van het blok, dus vlak onder de switch.
// is het blok tegen een vijand aangekomen? if (IsEnemy(block.X, block.Y)) { // ja, verwijder vijand EraseEnemy(block.X, block.Y); }
We zijn er bijna. Het spel moet stoppen zodra alle vijanden dood zijn. Een kleine aanpassing van het while-statement van de game loop.
} while ((myKey != GameExit) && (!GlobalLevel.Player.IsDead) && (GlobalLevel.Enemies.size() > 0));
Good grief. Was dat even een lange les!
En dat is het voor deze keer. We hebben ervoor gezorgd dat spelers niet meer overal doorheen kunnen lopen. Ook blokken botsen nu tegen elkaar aan. We kunnen zelfs blokken kapot trappen, vijanden pletten en dood gaan.
Tussen neus en lippen door zijn de volgende C++-onderwerpen aan bod geweest:
De volgende keer koppelen we de snelheid van de speler en de blokken los en we nemen een klein voorschot op les 8.
Programmeren is leuk. :-)
Als je de bovenstaande code af hebt en je wilt Pengo graag uitbreiden, dan kun je de volgende features toevoegen.
De volgende bestanden horen bij deze les.