7. Levens verliezen#
In de huidige versie van onze Endless Runner is het spel afgelopen zodra de dinosaurus een cactus raakt. Misschien vind je het leuker om de speler een aantal levens te geven en het spel pas te beëindigen als de levens op zijn. In dit deel gaan we dat programmeren.
Knipperende dino#
Als de dinosaurus een cactus raakt, en de speler een leven verliest, willen we dat de dinosaurus even knippert. Dat maakt voor de speler duidelijk dat er iets is gebeurd, en bovendien kan de dino tijdens het knipperen niet nogmaals worden geraakt door een cactus.
Er zijn meerdere manieren om het knippereffect te programmeren. Je zou bijvoorbeeld de player.images lijst kunnen wijzigen naar een lijst waarin zich ook lege sprites bevinden. Dat zijn sprites die volledig transparant zijn. Als je dit wilt proberen, download dan empty.png en plaats het in de images map. Vervolgens voeg je aan je code een tweede lijst met sprites toe:
19# Actors
20player = Actor('walk00')
21walk_images = ['walk00', 'walk01', 'walk02', 'walk03']
22hit_images = ['walk00', 'empty', 'walk01', 'empty', 'walk02', 'empty', 'walk03', 'empty']
23player.images = walk_images
Wanneer de dino een cactus raakt, kun je de player.images variabele veranderen naar de hit_images lijst. Na een paar seconden kun je de player.images weer terugzetten naar de walk_images lijst. Dit is een manier om het knippereffect te programmeren.
Je kunt het knipperen echter ook laten gebeuren door in de draw() functie de dino afwisselend wél en níet te laten tekenen. Deze manier is iets ingewikkelder, maar je hebt meer controle over de snelheid van het knipperen en je hebt geen extra sprite nodig. In dit deel beschrijven we deze manier.
Voeg allereerst een nieuwe constante toe aan de code, die de duur van het knipperen bepaalt.
9# Constanten
10HORIZON = 400
11BASELINE = HORIZON + 45
12GRAVITY = 1
13SPEED = 8
14BLINK_DURATION = 45
De constante BLINK_DURATION geeft aan hoe lang de dino moet knipperen in frames. In dit geval is dat 45 frames, wat ongeveer 0,75 seconden is. Dit is een goede tijd om de dino te laten knipperen. Je kunt deze waarde natuurlijk ook aanpassen naar eigen voorkeur.
Aan de player actor voegen we twee nieuwe variabelen toe: is_blinking en blink_timer. De eerste is een boolean die aangeeft of de dino aan het knipperen is. De tweede is een timer die bijhoudt hoe lang de dino al knippert. Voeg de volgende regels toe aan de code:
20# Actors
21player = Actor('walk00')
22walk_images = ['walk00', 'walk01', 'walk02', 'walk03']
23player.images = walk_images
24player.fps = 10
25player.left = 10
26player.bottom = BASELINE
27player.vy = 0
28player.is_blinking = False
29player.blink_timer = BLINK_DURATION
In de update() functie moeten we nu een paar dingen regelen:
Als de dinosaurus een cactus raakt terwijl hij knippert, mag er niets gebeuren.
Als de dinosaurus een cactus raakt en hij is niet aan het knipperen, moet hij gaan knipperen.
Als de dinosaurus aan het knipperen is, moet
blink_timerworden verlaagd. Zodra de timer op 0 staat, moet de dinosaurus stoppen met knipperen en de timer wordt weer opBLINK_DURATIONgezet.
We pakken eerst de punten 1 en 2 aan:
91 if not player.is_blinking and player.collidelist(obstacles) != -1:
92 player.is_blinking = True
De dino gaat nu alleen knipperen als hij niet al bezig was met knipperen en een cactus raakt.
Het verlagen van de timer en het stoppen met knipperen regelen we met een nieuw if statement in de update() functie:
94 if player.is_blinking:
95 player.blink_timer -= 1
96 if player.blink_timer <= 0:
97 player.is_blinking = False
98 player.blink_timer = BLINK_DURATION
Nu moeten we alleen nog de draw() functie aanpassen. Dit is misschien wel het ingewikkeldste stukje. We gaan er met een if statement voor zorgen dat:
als
player.is_blinkingFalseis, de dino gewoon wordt getekend;als
player.is_blinkingTrueis, de dino wordt getekend alsplayer.blink_timereen even getal is en niet wordt getekend alsplayer.blink_timereen oneven getal is. Dit zorgt ervoor dat de dino knippert.
In code ziet dat er zo uit:
49# Functie draw()
50def draw():
51 draw_background()
52 if game_over:
53 screen.draw.text('Game Over', midbottom = (WIDTH / 2, HEIGHT / 2 - 10), color = 'white', fontsize = 60)
54 screen.draw.text(f'Score: {score}', midtop = (WIDTH / 2, HEIGHT / 2 + 10), color = 'white', fontsize = 60)
55 else:
56 if not player.is_blinking or player.blink_timer % 2 == 0:
57 player.draw()
58 for obstacle in obstacles:
59 obstacle.draw()
60 screen.draw.text(f'Score: {score}', (15, 10), color = 'darkorchid4', fontsize = 48)
Kun je je herinneren dat de % operator de restwaarde van een deling geeft? Bijvoorbeeld 7 % 2 = 1 en 8 % 2 = 0. Elk even getal dat je door 2 deelt, heeft rest 0 en elk oneven getal dat je door 2 deelt, heeft rest 1. Dit is precies wat we hier gebruiken om te bepalen of de timer een even of oneven getal is. Als player.blink_timer % 2 == 0 waar is, dan is de waarde van player.blink_timer een even getal. In dat geval wordt de dino getekend. En als de waarde oneven is, wordt de dino niet getekend.
Wanneer je nu het spel speelt, zie je dat het knippereffect werkt, maar veel te snel gaat. De dino knippert nu 30 keer per seconde. Door een extra berekening in regel 56 toe te voegen, kunnen we het knipperen vertragen:
Nu delen we de tijd eerst door 5 en bekijken dan of het resultaat even of oneven is. Dit zorgt ervoor dat de dino in een rustiger tempo knippert. Uiteraard kun je zelf experimenteren met de snelheid van het knipperen door de waarde 5 te vervangen door een andere waarde.
Levens#
Om het aantal levens bij te houden, hebben we uiteraard weer een variabele nodig. Je kunt ervoor kiezen om een globale variabele te maken, maar dan moet je die telkens weer global maken in de functies waarin je de waarde van de variabele wilt veranderen. Het is gemakkelijker om deze variabele aan de player actor toe te voegen. Dit kan met de volgende regel code:
20# Actors
21player = Actor('walk00')
22walk_images = ['walk00', 'walk01', 'walk02', 'walk03']
23player.images = walk_images
24player.fps = 10
25player.left = 10
26player.bottom = BASELINE
27player.vy = 0
28player.is_blinking = False
29player.blink_timer = BLINK_DURATION
30player.lives = 3
In de draw() functie zorgen we ervoor dat het aantal levens in het venster wordt getoond. Ook hiervoor hoeven we slechts één regel toe te voegen:
50# Functie draw()
51def draw():
52 draw_background()
53 if game_over:
54 screen.draw.text('Game Over', midbottom = (WIDTH / 2, HEIGHT / 2 - 10), color = 'white', fontsize = 60)
55 screen.draw.text(f'Score: {score}', midtop = (WIDTH / 2, HEIGHT / 2 + 10), color = 'white', fontsize = 60)
56 else:
57 if not player.is_blinking or (player.blink_timer // 5) % 2 == 0:
58 player.draw()
59 for obstacle in obstacles:
60 obstacle.draw()
61 screen.draw.text(f'Score: {score}', (15, 10), color = 'darkorchid4', fontsize = 48)
62 screen.draw.text(f'Lives: {player.lives}', topright = (WIDTH - 15, 10), align = 'right', color = 'darkorchid4', fontsize = 48)
Omdat we de levens in de rechterbovenhoek willen weergeven, gebruiken we de topright parameter. De align parameter zorgt ervoor dat de tekst rechts uitgelijnd is.
Nu moeten we de levens ook daadwerkelijk verminderen als de dinosaurus een cactus raakt. Dit doen we in de update() functie, waar we de waarde van player.lives met 1 verlagen.
94 if not player.is_blinking and player.collidelist(obstacles) != -1:
95 player.is_blinking = True
96 player.lives -= 1
97 if player.lives <= 0:
98 game_over = True
Test het spel nu uit. Als de dinosaurus een cactus raakt, moet hij knipperen en het aantal levens moet met 1 worden verminderd. Als de levens op zijn, moet het spel eindigen.
Er is nog wel iets vreemds aan de hand met ons spel. Run de code en laat de dino lopen zonder te springen. Je bent dan al heel snel dood en bent over geen enkele cactus gesprongen, maar tóch behaal je een score van 2 punten. Dat komt doordat de score wordt verhoogd wanneer een cactus links uit beeld verdwijnt, ongeacht of de dino hem heeft geraakt. Gelukkig kunnen we ook die euvel met één extra regel verhelpen:
84 for obstacle in obstacles.copy():
85 if obstacle.right < 0:
86 obstacles.remove(obstacle)
87 if not player.is_blinking:
88 score += 1
Nu wordt de score alleen verhoogd als de dinosaurus niet aan het knipperen is.
Hartjes#
Meestal wordt het aantal levens weergegeven met hartjes. Wij gaan dat nu ook doen. Net als voor de obstakels gaan we een lijst gebruiken, omdat we meerdere hartjes willen tekenen en we niet van tevoren weten hoeveel. Ook gaan we de hartjes animeren zoals we bij de dino hebben gedaan.
Het maken van de lijst voor de hartjes gaat op dezelfde manier als voor de obstakels. We hebben nu alleen 11 sprites in plaats van 6. Voeg de volgende regels toe aan je code:
32hearts = []
33heart_images = ['heart00', 'heart01', 'heart02', 'heart03', 'heart04', 'heart05', 'heart06', 'heart07', 'heart08', 'heart09', 'heart10']
Voor de obstacles lijst definieerden we een functie add_obstacle() die een nieuwe cactus aan de lijst toevoegde telkens wanneer dat nodig was. Voor de hearts lijst maken we een functie fill_hearts() die de lijst vult met hartjes. Deze functie wordt straks één keer aangeroepen aan het begin van het spel. We voorzien fill_hearts() ook van een parameter lives voor het aantal hartjes dat we in de lijst willen stoppen. Voeg de volgende code toe onder de add_obstacle() functie:
46# Functie fill_hearts()
47def fill_hearts(lives):
48 for l in range(lives):
49 heart = Actor('heart00')
50 heart.images = heart_images
51 heart.scale = 0.1
52 heart.right = WIDTH - 15 - l * (heart.get_rect().width + 10)
53 heart.top = 10
54 heart.fps = 5
55 hearts.append(heart)
De eerste regels zullen voor zich spreken, maar regel 51 roept waarschijnlijk vragen op. De pgzhelper module bevat de functie scale() waarmee je de grootte van een sprite kunt aanpassen. De waarde die je meegeeft aan scale() is een percentage van de originele grootte. In dit geval is dat 0.1, wat betekent dat het hartje 10% van de originele grootte is. We hebben dit nodig omdat de heart sprites in de images map veel te groot zijn voor ons spel. Je kunt dit zien door in de Verkenner met de rechtermuisknop op bijvoorbeeld hearts00.png te klikken en dan te kiezen voor Eigenschappen en vervolgens het tabblad details.
De hearts00.png afbeelding is 302 × 256 pixels groot, terwijl ons game venster maar 800 × 600 pixels groot is. Als we de hartjes niet zouden schalen, zouden ze veel te groot zijn voor ons spel.
Op regel 52 staat een ingewikkelde berekening:
heart.right = WIDTH - 15 - l * (heart.get_rect().width + 10)
Deze berekening zorgt ervoor dat het eerste hartje (als l = 0 is) op 15 pixels van de rechterrand van het venster komt te staan. Het tweede hartje (als l = 1 is) komt 10 pixels links van het eerste hartje te staan, enzovoort. De waarde 10 is de ruimte tussen de hartjes. Als je dat niet mooi vindt, kun je deze waarde natuurlijk aanpassen.
Met heart.get_rect().width krijgen we de breedte van het hartje. Dus de rechterkant van het tweede hartje komt op 10 pixels plus de breedte van een hartje van de rechterkant van het eerste hartje te staan.
Zoals gezegd, gaan we de fill_hearts() functie maar één keer aanroepen, bij aanvang van het spel. Plaats helemaal onderaan je programma de volgende code:
126# HOOFDPROGRAMMA
127fill_hearts(player.lives)
Om de hartjes te kunnen zien, moeten we ze nog wel even tekenen in de draw() functie. Dit doen we met een for loop, net zoals we dat deden voor de cacti. Vervang de regel die de levens als tekst op het scherm toont door de for loop:
64# Functie draw()
65def draw():
66 draw_background()
67 if game_over:
68 screen.draw.text('Game Over', midbottom = (WIDTH / 2, HEIGHT / 2 - 10), color = 'white', fontsize = 60)
69 screen.draw.text(f'Score: {score}', midtop = (WIDTH / 2, HEIGHT / 2 + 10), color = 'white', fontsize = 60)
70 else:
71 if not player.is_blinking or (player.blink_timer // 5) % 2 == 0:
72 player.draw()
73 for obstacle in obstacles:
74 obstacle.draw()
75 screen.draw.text(f'Score: {score}', (15, 10), color = 'darkorchid4', fontsize = 48)
76 for heart in hearts:
77 heart.draw()
Als je nu het programma runt, zie je de hartjes, maar ze bewegen nog niet. Om dat voor elkaar te krijgen, moeten we in de update() functie weer de animate() functie aanroepen voor elk hartje in de lijst:
79# Functie update()
80def update():
81 global obstacle_timeout, score, game_over
82
83 if game_over:
84 return
85
86 player.animate()
87 for heart in hearts:
88 heart.animate()
89
90 player.y += player.vy
Om een hartje te laten verdwijnen zodra de speler een leven verliest, gebruiken we de pop() functie. Deze functie verwijdert het laatste item uit een lijst.
112 if not player.is_blinking and player.collidelist(obstacles) != -1:
113 player.is_blinking = True
114 player.lives -= 1
115 hearts.pop()
116 if player.lives <= 0:
117 game_over = True
Run het spel nu nogmaals en kijk of de hartjes verdwijnen als de dinosaurus een cactus raakt. Als dat goed gaat, ben je klaar!