Balloon Ninja: A better scoring system

Back to Tutorial Contents

Pop Rate

In the last section of this tutorial, we implemented some logic to determine when the game was over. This was fairly simple logic; the game is over when you miss more than 3 balloons. This works, but once you get to larger batches of balloons being released, the game gets really hard. It might be a good idea to set a percentage of balloons that can’t be missed. We will actually call this a popped_ratio, and say that the player must keep popping at least 90% of the balloons in order to stay alive. Let’s add this parameter to Settings.py:

class Settings():

    def __init__(self):
        # screen parameters
        self.screen_width, self.screen_height = 800, 600
        self.bg_color = 200, 200, 200
        self.scoreboard_height = 50

        self.button_width, self.button_height = 250, 50
        self.button_bg = (0,163,0)
        self.button_text_color = (235,235,235)
        self.button_font, self.button_font_size = 'Arial', 24

        # game status
        self.game_active = False

        # game over conditions
        self.min_popped_ratio = 0.9
        self.games_played = 0

        self.initialize_game_parameters()

    def initialize_game_parameters(self):
        # game play parameters
        self.balloon_speed = 0.1
        # How quickly the speed of balloons rises
        #  ~1.05 during testing and ~1.01 for actual play
        self.speed_increase_factor = 1.05
        self.points_per_balloon = 10

        # Number of balloons to release in a spawning:
        self.batch_size = 1
        # Number of balloons that need to be popped before increasing batch_size
        #  For actual play, probably want ~10; for testing, ~3
        self.pops_needed = 3

We change line 18 from misses_allowed to min_popped_ratio, and set it to 90%. Now we need to modify Engine.py to use this value:

import pygame, sys
from Balloon import Balloon

class Engine():

    def __init__(self):
        pass

    def release_batch(self, screen, settings, balloons):
        for x in range(0, settings.batch_size):
            self.spawn_balloon(screen, settings, balloons)

    def check_balloons(self, balloons, sword, scoreboard, screen, settings, time_passed):
        # Find any balloons that have been popped,
        #  or have disappeared off the top of the screen
        for balloon in balloons:
            balloon.update(time_passed)

            if balloon.rect.colliderect(sword.rect):
                self.pop_balloon(scoreboard, settings, balloon, balloons)
                continue

            if balloon.y_position < -balloon.image_h/2 + settings.scoreboard_height:
                self.miss_balloon(scoreboard, balloon, balloons)
                self.spawn_balloon(screen, settings, balloons)
                continue

            balloon.blitme()

        if scoreboard.balloons_popped > 0 or scoreboard.balloons_missed > 0:
            current_popped_ratio = float(scoreboard.balloons_popped)/(scoreboard.balloons_popped + scoreboard.balloons_missed)
            if current_popped_ratio < settings.min_popped_ratio:
                # Set game_active to false, empty the list of balloons, and increment games_played
                settings.game_active = False
                settings.games_played += 1

    def update_sword(self, sword, mouse_x, mouse_y, settings):
        # Update the sword's position, and draw the sword on the screen
        sword.x_position = mouse_x
        if sword.grabbed:
            sword.y_position = mouse_y
        else:
            sword.y_position = sword.image_h/2 + settings.scoreboard_height
        sword.update_rect()
        sword.blitme()

    def miss_balloon(self, scoreboard, balloon, balloons):
        scoreboard.balloons_missed += 1
        balloons.remove(balloon)

    def pop_balloon(self, scoreboard, settings, balloon, balloons):
        scoreboard.balloons_popped += 1
        scoreboard.score += settings.points_per_balloon
        balloons.remove(balloon)
        # If we have popped enough balloons, increase batch_size:
        if scoreboard.balloons_popped % settings.pops_needed == 0:
            settings.batch_size += 1

    def spawn_balloon(self, screen, settings, balloons):
        balloons.append(Balloon(screen, settings))

    def check_events(self, settings, scoreboard, sword, play_button, mouse_x, mouse_y, balloons):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                sys.exit()
            if event.type == pygame.MOUSEBUTTONDOWN:
                if sword.rect.collidepoint(mouse_x, mouse_y):
                    sword.grabbed = True
                if play_button.rect.collidepoint(mouse_x, mouse_y):
                    # Play button has been pressed.  Empty list of balloons,
                    #  initialize scoreboard and game parameters, and make game active.
                    del balloons[:]
                    scoreboard.initialize_stats()
                    settings.initialize_game_parameters()
                    settings.game_active = True
            if event.type == pygame.MOUSEBUTTONUP:
                sword.grabbed = False

On line 30, we need to make sure that at least one balloon has been popped or missed. Otherwise, we will get a division by zero error. For readability, we calculate the current popped ratio on a separate line. We have to convert the number of balloons popped to a decimal float number, so we can calculate our popped ratio accurately. The popped ratio is the number of balloons popped, divided by the sum of all balloons that have been popped or missed.

This results in much more exciting gameplay. Every missed balloon moves us closer to the game being over, but if we start to play better we can extend the game. Here’s a screenshot of typical play at this point:

Basing the game active status on the ratio of balloons popped to balloons missed makes for extended, more exciting game play.

Basing the game active status on the ratio of balloons popped to balloons missed makes for extended, more exciting game play.

Displaying the Pop Rate

Now let’s display this percentage on the scoreboard, so people can see how well they are doing. We need to move the popped_ratio so that it is a scoreboard variable, and then display that variable. Let’s modify Engine.py first:

import pygame, sys
from Balloon import Balloon

class Engine():

    def __init__(self):
        pass

    def release_batch(self, screen, settings, balloons):
        for x in range(0, settings.batch_size):
            self.spawn_balloon(screen, settings, balloons)

    def check_balloons(self, balloons, sword, scoreboard, screen, settings, time_passed):
        # Find any balloons that have been popped,
        #  or have disappeared off the top of the screen
        for balloon in balloons:
            balloon.update(time_passed)

            if balloon.rect.colliderect(sword.rect):
                self.pop_balloon(scoreboard, settings, balloon, balloons)
                continue

            if balloon.y_position < -balloon.image_h/2 + settings.scoreboard_height:
                self.miss_balloon(scoreboard, balloon, balloons)
                self.spawn_balloon(screen, settings, balloons)
                continue

            balloon.blitme()

        if scoreboard.balloons_popped > 0:
            scoreboard.popped_ratio = float(scoreboard.balloons_popped)/(scoreboard.balloons_popped + scoreboard.balloons_missed)
            if scoreboard.popped_ratio < settings.min_popped_ratio:
                # Set game_active to false, empty the list of balloons, and increment games_played
                settings.game_active = False
                settings.games_played += 1

    def update_sword(self, sword, mouse_x, mouse_y, settings):
        # Update the sword's position, and draw the sword on the screen
        sword.x_position = mouse_x
        if sword.grabbed:
            sword.y_position = mouse_y
        else:
            sword.y_position = sword.image_h/2 + settings.scoreboard_height
        sword.update_rect()
        sword.blitme()

    def miss_balloon(self, scoreboard, balloon, balloons):
        scoreboard.balloons_missed += 1
        balloons.remove(balloon)

    def pop_balloon(self, scoreboard, settings, balloon, balloons):
        scoreboard.balloons_popped += 1
        scoreboard.score += settings.points_per_balloon
        balloons.remove(balloon)
        # If we have popped enough balloons, increase batch_size:
        if scoreboard.balloons_popped % settings.pops_needed == 0:
            settings.batch_size += 1

    def spawn_balloon(self, screen, settings, balloons):
        balloons.append(Balloon(screen, settings))

    def check_events(self, settings, scoreboard, sword, play_button, mouse_x, mouse_y, balloons):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                sys.exit()
            if event.type == pygame.MOUSEBUTTONDOWN:
                if sword.rect.collidepoint(mouse_x, mouse_y):
                    sword.grabbed = True
                if play_button.rect.collidepoint(mouse_x, mouse_y):
                    # Play button has been pressed.  Empty list of balloons,
                    #  initialize scoreboard and game parameters, and make game active.
                    del balloons[:]
                    scoreboard.initialize_stats()
                    settings.initialize_game_parameters()
                    settings.game_active = True
            if event.type == pygame.MOUSEBUTTONUP:
                sword.grabbed = False

Here we just tell our engine to update the scoreboard popped_ratio variable that we are about to create, and then use that updated value. Here is our new Scoreboard.py:

import pygame, pygame.font
from pygame.sprite import Sprite

class Scoreboard(Sprite):

    def __init__(self, screen, sb_height):

        Sprite.__init__(self)
        self.screen = screen

        self.initialize_stats()

        # Set dimensions and properties of scoreboard
        self.sb_height, self.sb_width = sb_height, self.screen.get_width()
        self.rect = pygame.Rect(0,0, self.sb_width, self.sb_height)
        self.bg_color=(100,100,100)
        self.text_color = (225,225,225)
        self.font = pygame.font.SysFont('Arial', 18)

        # Set positions of individual scoring elements on the scoreboard
        self.x_popped_position, self.y_popped_position = 20.0, 10.0
        self.x_missed_position, self.y_missed_position = 150.0, 10.0
        self.x_score_position, self.y_score_position = self.screen.get_width() - 200, 10.0
        self.x_ratio_position, self.y_ratio_position = 300, 10.0

    def initialize_stats(self):
        # Game attributes to track for scoring
        self.balloons_popped = 0
        self.balloons_missed = 0
        self.score = 0
        self.popped_ratio = 1.0

    def prep_scores(self):
        self.popped_string = "Popped: " + str(self.balloons_popped)
        self.popped_image = self.font.render(self.popped_string, True, self.text_color)

        self.missed_string = "Missed: " + str(self.balloons_missed)
        self.missed_image = self.font.render(self.missed_string, True, self.text_color)

        self.score_string = "Score: " + str(self.score)
        self.score_image = self.font.render(self.score_string, True, self.text_color)

        self.set_ratio_string()
        self.popped_ratio_image = self.font.render(self.popped_ratio_string, True, self.ratio_text_color)

    def set_ratio_string(self):
        if self.popped_ratio == 1.0:
            self.popped_ratio_string = "Pop Rate: 100%"
        else:
            self.popped_ratio_string = "Pop Rate: " + "{0:.3}%".format(self.popped_ratio*100.0)
        if self.popped_ratio < 0.95:
            self.ratio_text_color = (255, 51, 51)
        else:
            self.ratio_text_color = self.text_color

    def blitme(self):
        # Turn individual scoring elements into images that can be drawn
        self.prep_scores()
        # Draw blank scoreboard
        self.screen.fill(self.bg_color, self.rect)
        # Draw individual scoring elements
        self.screen.blit(self.popped_image, (self.x_popped_position, self.y_popped_position))
        self.screen.blit(self.missed_image, (self.x_missed_position, self.y_missed_position))
        self.screen.blit(self.score_image, (self.x_score_position, self.y_score_position))
        self.screen.blit(self.popped_ratio_image, (self.x_ratio_position, self.y_ratio_position))

On line 25 we position the ratio just after the number of missed balloons on the scoreboard. On line 32 we initialize the ratio to 1.0, or 100%. Lines 44 and 45 create a string representing the ratio on the scoreboard, and then render the string to an image as we have done with our other game stats.

Since there is a bit of logic in creating the ratio stat, we create a helper function on lines 47-55. In this function, lines 48 and 49 report a ratio of 100% if the ratio is 1.0. Line 51 turns the decimal ratio, which may be something like 0.9877537 into a nicely formatted percentage such as “98.8%”. We multiply the ratio by 100.0 to turn it into a percentage, and then use python’s format function to display 3 significant digits, along with a percent symbol. If you are curious about this function, take a look at this question on Stack Overflow. Lines 52 through 55 display the ratio in red if the popped percentage is less than 95%.

Here is a screenshot at the end of a game now, showing the percentage of balloons popped throughout the game:

Game over screen, showing the pop rate at the end of the game.

Game over screen, showing the pop rate at the end of the game.

Variable Scoring

If you’ve been playing the game as it is being developed, you can see that it’s getting more challenging. Let’s reward the player for surviving longer by making balloons worth more as the game progresses. This will first require changes to balloon_ninja.py:

import pygame, sys
from Settings import Settings
from Balloon import Balloon
from Sword import Sword
from Scoreboard import Scoreboard
from Button import Button
from Engine import Engine

def run_game():
    # Get access to our game settings
    settings = Settings()
    engine = Engine()

    # initialize game
    pygame.init()
    screen = pygame.display.set_mode( (settings.screen_width, settings.screen_height), 0, 32)
    clock = pygame.time.Clock()
    scoreboard = Scoreboard(screen, settings.scoreboard_height)
    play_button = Button(screen, settings.screen_width/2-settings.button_width/2,
                            settings.screen_height/2-settings.button_height/2, settings, "Play Balloon Ninja")
    game_over_button = Button(screen, play_button.x_position, play_button.y_position-2*settings.button_height,
                            settings, "Game Over")

    # Create a list to hold our balloons, and create our first balloon
    balloons = []

    # Create our dagger
    sword = Sword(screen, settings.scoreboard_height)

    # main event loop
    while True:
        # Advance our game clock, get the current mouse position, and check for new events
        time_passed = clock.tick(50)
        mouse_x, mouse_y = pygame.mouse.get_pos()[0], pygame.mouse.get_pos()[1]
        engine.check_events(settings, scoreboard, sword, play_button, mouse_x, mouse_y, balloons)

        # Redraw the empty screen before redrawing any game objects
        screen.fill(settings.bg_color)

        if settings.game_active:
            # Update the sword's position and check for popped or disappeared balloons
            engine.update_sword(sword, mouse_x, mouse_y, settings)
            engine.check_balloons(balloons, sword, scoreboard, screen, settings, time_passed)

            # If all balloons have disappeared, either through popping or rising,
            #  release a new batch of balloons.
            #  Increase the balloon speed for each new batch of balloons.
            if len(balloons) == 0:
                settings.balloon_speed *= settings.speed_increase_factor
                engine.release_batch(screen, settings, balloons)
                if scoreboard.balloons_popped > 0:
                    settings.points_per_balloon = int(round(settings.points_per_balloon * settings.speed_increase_factor))
        else:
            # Show play button
            play_button.blitme()
            # If a game has just ended, show Game Over button
            if settings.games_played > 0:
                game_over_button.blitme()

        # Display updated scoreboard
        scoreboard.blitme()

        # Show the redrawn screen
        pygame.display.flip()

run_game()

Every time we release a new batch of balloons, we have been increasing the speed of the balloons. This is a good place to increase the score for each balloon as well. We do this only after the first balloon is popped, so that our first balloon is still 10 points. We are already calling initialize_settings every time the game starts over, so we don’t have to do anything more to reset the scoring each time a new game is started.

This is good, and it creates better scoring, but we should report the number of points each balloon is worth on the scoreboard. Our scoreboard is getting a bit crowded, though. Let’s stop reporting the number of missed balloons, since that information is contained in the Pop Rate statistic. Our new scoreboard.py will look like this:

import pygame, pygame.font
from pygame.sprite import Sprite

class Scoreboard(Sprite):

    def __init__(self, screen, settings):

        Sprite.__init__(self)
        self.screen = screen
        self.settings = settings

        self.initialize_stats()

        # Set dimensions and properties of scoreboard
        self.sb_height, self.sb_width = settings.scoreboard_height, self.screen.get_width()
        self.rect = pygame.Rect(0,0, self.sb_width, self.sb_height)
        self.bg_color=(100,100,100)
        self.text_color = (225,225,225)
        self.font = pygame.font.SysFont('Arial', 18)

        # Set positions of individual scoring elements on the scoreboard
        self.x_popped_position, self.y_popped_position = 20.0, 10.0
        self.x_ratio_position, self.y_ratio_position = 150, 10.0
        self.x_points_position, self.y_points_position = 350, 10.0
        self.x_score_position, self.y_score_position = self.screen.get_width() - 200, 10.0

    def initialize_stats(self):
        # Game attributes to track for scoring
        self.balloons_popped = 0
        self.balloons_missed = 0
        self.score = 0
        self.popped_ratio = 1.0

    def prep_scores(self):
        self.popped_string = "Popped: " + str(self.balloons_popped)
        self.popped_image = self.font.render(self.popped_string, True, self.text_color)

        self.score_string = "Score: " + str(self.score)
        self.score_image = self.font.render(self.score_string, True, self.text_color)

        self.set_ratio_string()
        self.popped_ratio_image = self.font.render(self.popped_ratio_string, True, self.ratio_text_color)

        self.points_string = "Points per Balloon: " + str(self.settings.points_per_balloon)
        self.points_image = self.font.render(self.points_string, True, self.text_color)

    def set_ratio_string(self):
        if self.popped_ratio == 1.0:
            self.popped_ratio_string = "Pop Rate: 100%"
        else:
            self.popped_ratio_string = "Pop Rate: " + "{0:.3}%".format(self.popped_ratio*100.0)
        if self.popped_ratio < 0.95:
            self.ratio_text_color = (255, 51, 51)
        else:
            self.ratio_text_color = self.text_color

    def blitme(self):
        # Turn individual scoring elements into images that can be drawn
        self.prep_scores()
        # Draw blank scoreboard
        self.screen.fill(self.bg_color, self.rect)
        # Draw individual scoring elements
        self.screen.blit(self.popped_image, (self.x_popped_position, self.y_popped_position))
        self.screen.blit(self.points_image, (self.x_points_position, self.y_points_position))
        self.screen.blit(self.score_image, (self.x_score_position, self.y_score_position))
        self.screen.blit(self.popped_ratio_image, (self.x_ratio_position, self.y_ratio_position))

The scoreboard needs access to our settings object now, so we have changed the function definition slightly. Instead of receiving the scoreboard height directly, it receives the settings object on line 7. On line 11 we make a self.settings variable, so we can access settings within our helper functions. On line 16, we now get the scoreboard height directly from the settings object.

On lines 22-26, we remove the balloons_missed position. We move the Pop Rate to the space where Missed used to live, and we place the Points per Balloon a little to the right of Pop Rate. Lines 45 and 46 set the string and rendered string image for the Points per Balloon stat, and line 65 blits this stat to the scoreboard. Notice that we have removed the lines in prep_scores() and blitme() that refer to balloons_missed. However, we have kept the self.balloons_missed stat on line 31, because we need this statistic in order to calculate the popped ratio.

Our new scoreboard looks like this, near the end of a game:

Balloons are now worth more points as the game progresses.

Balloons are now worth more points as the game progresses.

Cleaning up the batch size

We need to clean up the process by which the batch_size is being increased. Right now, we are increasing the batch size every time the player pops 10 balloons. This is fine early in the game, but it leads to exponential growth pretty quickly. Once the batch size is greater than 10, we start getting multiple increases in batch_size before the current batch is even completed. I am testing the game with an increase every time 3 balloons are popped, which magnifies the problem, but the problem will arise even with a more realistic value.

We can improve this by simply tracking the number of batches that are completed, and increase the batch size every time a number of batches have been finished. This is similar to the concept of moving up in levels or rounds in a game. Let’s start with Settings.py:

class Settings():

    def __init__(self):
        # screen parameters
        self.screen_width, self.screen_height = 800, 600
        self.bg_color = 200, 200, 200
        self.scoreboard_height = 50

        self.button_width, self.button_height = 250, 50
        self.button_bg = (0,163,0)
        self.button_text_color = (235,235,235)
        self.button_font, self.button_font_size = 'Arial', 24

        # game status
        self.game_active = False

        # game over conditions
        self.min_popped_ratio = 0.9
        self.games_played = 0

        self.initialize_game_parameters()

    def initialize_game_parameters(self):
        # game play parameters
        self.balloon_speed = 0.1
        # How quickly the speed of balloons rises
        #  ~1.05 during testing and ~1.01 for actual play
        self.speed_increase_factor = 1.05
        self.points_per_balloon = 10

        # Number of balloons to release in a spawning:
        self.batch_size = 1

        # Number of batches that must be completed to increment batch_size
        self.batches_needed = 3

We get rid of the last few lines in the file involving pops_needed. We replace this with lines 34 and 35, which set the number of batches a player needs to finish before seeing an increase in the number of balloons released in a batch.

Now let’s look at Scoreboard.py:

import pygame, pygame.font
from pygame.sprite import Sprite

class Scoreboard(Sprite):

    def __init__(self, screen, settings):

        Sprite.__init__(self)
        self.screen = screen
        self.settings = settings

        self.initialize_stats()

        # Set dimensions and properties of scoreboard
        self.sb_height, self.sb_width = settings.scoreboard_height, self.screen.get_width()
        self.rect = pygame.Rect(0,0, self.sb_width, self.sb_height)
        self.bg_color=(100,100,100)
        self.text_color = (225,225,225)
        self.font = pygame.font.SysFont('Arial', 18)

        # Set positions of individual scoring elements on the scoreboard
        self.x_popped_position, self.y_popped_position = 20.0, 10.0
        self.x_ratio_position, self.y_ratio_position = 150, 10.0
        self.x_points_position, self.y_points_position = 350, 10.0
        self.x_score_position, self.y_score_position = self.screen.get_width() - 200, 10.0

    def initialize_stats(self):
        # Game attributes to track for scoring
        self.balloons_popped = 0
        self.balloons_missed = 0
        self.score = 0
        self.popped_ratio = 1.0
        self.batches_finished = 0

    def prep_scores(self):
        self.popped_string = "Popped: " + str(self.balloons_popped)
        self.popped_image = self.font.render(self.popped_string, True, self.text_color)

        self.score_string = "Score: " + str(self.score)
        self.score_image = self.font.render(self.score_string, True, self.text_color)

        self.set_ratio_string()
        self.popped_ratio_image = self.font.render(self.popped_ratio_string, True, self.ratio_text_color)

        self.points_string = "Points per Balloon: " + str(self.settings.points_per_balloon)
        self.points_image = self.font.render(self.points_string, True, self.text_color)

    def set_ratio_string(self):
        if self.popped_ratio == 1.0:
            self.popped_ratio_string = "Pop Rate: 100%"
        else:
            self.popped_ratio_string = "Pop Rate: " + "{0:.3}%".format(self.popped_ratio*100.0)
        if self.popped_ratio < 0.95:
            self.ratio_text_color = (255, 51, 51)
        else:
            self.ratio_text_color = self.text_color

    def blitme(self):
        # Turn individual scoring elements into images that can be drawn
        self.prep_scores()
        # Draw blank scoreboard
        self.screen.fill(self.bg_color, self.rect)
        # Draw individual scoring elements
        self.screen.blit(self.popped_image, (self.x_popped_position, self.y_popped_position))
        self.screen.blit(self.points_image, (self.x_points_position, self.y_points_position))
        self.screen.blit(self.score_image, (self.x_score_position, self.y_score_position))
        self.screen.blit(self.popped_ratio_image, (self.x_ratio_position, self.y_ratio_position))

All we are doing here is providing a way to track the number of batches that have been completed. This is done on line 33, with the new variable “batches_finished”.

We also need to modify Engine.py:

import pygame, sys
from Balloon import Balloon

class Engine():

    def __init__(self):
        pass

    def release_batch(self, screen, settings, balloons):
        for x in range(0, settings.batch_size):
            self.spawn_balloon(screen, settings, balloons)

    def check_balloons(self, balloons, sword, scoreboard, screen, settings, time_passed):
        # Find any balloons that have been popped,
        #  or have disappeared off the top of the screen
        for balloon in balloons:
            balloon.update(time_passed)

            if balloon.rect.colliderect(sword.rect):
                self.pop_balloon(scoreboard, settings, balloon, balloons)
                continue

            if balloon.y_position < -balloon.image_h/2 + settings.scoreboard_height:
                self.miss_balloon(scoreboard, balloon, balloons)
                self.spawn_balloon(screen, settings, balloons)
                continue

            balloon.blitme()

        if scoreboard.balloons_popped > 0 or scoreboard.balloons_missed > 0:
            scoreboard.popped_ratio = float(scoreboard.balloons_popped)/(scoreboard.balloons_popped + scoreboard.balloons_missed)
            if scoreboard.popped_ratio < settings.min_popped_ratio:
                # Set game_active to false, empty the list of balloons, and increment games_played
                settings.game_active = False
                settings.games_played += 1

    def update_sword(self, sword, mouse_x, mouse_y, settings):
        # Update the sword's position, and draw the sword on the screen
        sword.x_position = mouse_x
        if sword.grabbed:
            sword.y_position = mouse_y
        else:
            sword.y_position = sword.image_h/2 + settings.scoreboard_height
        sword.update_rect()
        sword.blitme()

    def miss_balloon(self, scoreboard, balloon, balloons):
        scoreboard.balloons_missed += 1
        balloons.remove(balloon)

    def pop_balloon(self, scoreboard, settings, balloon, balloons):
        scoreboard.balloons_popped += 1
        scoreboard.score += settings.points_per_balloon
        balloons.remove(balloon)

    def spawn_balloon(self, screen, settings, balloons):
        balloons.append(Balloon(screen, settings))

    def check_events(self, settings, scoreboard, sword, play_button, mouse_x, mouse_y, balloons):
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                sys.exit()
            if event.type == pygame.MOUSEBUTTONDOWN:
                if sword.rect.collidepoint(mouse_x, mouse_y):
                    sword.grabbed = True
                if play_button.rect.collidepoint(mouse_x, mouse_y):
                    # Play button has been pressed.  Empty list of balloons,
                    #  initialize scoreboard and game parameters, and make game active.
                    del balloons[:]
                    scoreboard.initialize_stats()
                    settings.initialize_game_parameters()
                    settings.game_active = True
            if event.type == pygame.MOUSEBUTTONUP:
                sword.grabbed = False

We are not actually adding anything to Engine.py. In the function pop_balloon(), we need to remove the lines that increase the batch_size after a certain number of balloons have been popped. The new version of pop_balloon() should match what you see above.

Finally, we need to change balloon_ninja.py:

import pygame, sys
from Settings import Settings
from Balloon import Balloon
from Sword import Sword
from Scoreboard import Scoreboard
from Button import Button
from Engine import Engine

def run_game():
    # Get access to our game settings
    settings = Settings()
    engine = Engine()

    # initialize game
    pygame.init()
    screen = pygame.display.set_mode( (settings.screen_width, settings.screen_height), 0, 32)
    clock = pygame.time.Clock()
    scoreboard = Scoreboard(screen, settings)
    play_button = Button(screen, settings.screen_width/2-settings.button_width/2,
                            settings.screen_height/2-settings.button_height/2, settings, "Play Balloon Ninja")
    game_over_button = Button(screen, play_button.x_position, play_button.y_position-2*settings.button_height,
                            settings, "Game Over")

    # Create a list to hold our balloons, and create our first balloon
    balloons = []

    # Create our dagger
    sword = Sword(screen, settings.scoreboard_height)

    # main event loop
    while True:
        # Advance our game clock, get the current mouse position, and check for new events
        time_passed = clock.tick(50)
        mouse_x, mouse_y = pygame.mouse.get_pos()[0], pygame.mouse.get_pos()[1]
        engine.check_events(settings, scoreboard, sword, play_button, mouse_x, mouse_y, balloons)

        # Redraw the empty screen before redrawing any game objects
        screen.fill(settings.bg_color)

        if settings.game_active:
            # Update the sword's position and check for popped or disappeared balloons
            engine.update_sword(sword, mouse_x, mouse_y, settings)
            engine.check_balloons(balloons, sword, scoreboard, screen, settings, time_passed)

            # If all balloons have disappeared, either through popping or rising,
            #  release a new batch of balloons.
            if len(balloons) == 0:
                # If we are not just starting a game, increase the balloon speed and points per balloon,
                #  and increment batches_finished
                if scoreboard.balloons_popped > 0:
                    #  Increase the balloon speed for each new batch of balloons.
                    settings.balloon_speed *= settings.speed_increase_factor
                    settings.points_per_balloon = int(round(settings.points_per_balloon * settings.speed_increase_factor))
                    scoreboard.batches_finished += 1
                # If player has completed required batches, increase batch_size
                if scoreboard.batches_finished % settings.batches_needed == 0 and scoreboard.batches_finished > 0:
                    settings.batch_size += 1
                engine.release_batch(screen, settings, balloons)
        else:
            # Show play button
            play_button.blitme()
            # If a game has just ended, show Game Over button
            if settings.games_played > 0:
                game_over_button.blitme()

        # Display updated scoreboard
        scoreboard.blitme()

        # Show the redrawn screen
        pygame.display.flip()

run_game()

Here we restructure what happens when we have an empty set of balloons. First, we check a couple things if this is not the first batch. If it is not the first batch, we increase the balloon speed for the new batch, increase the points per balloon, and record that a batch has been completed. Then we check to see if the required number of batches has been completed to increase the batch size. If so, we increment batch_size. After all of these parameters have been updated, we release a new batch of balloons on line 58.

When you play the game now, you see a clear progression of steadily increasing batch sizes.

Game instructions

We know how to play the game, because we’ve been working with the code for some time now. It might seem obvious how the game works, but it is not necessarily apparent that you can grab the sword when you first start playing. Let’s add a brief set of instructions, which will be displayed when the game is inactive. We will do this by creating a class for the instructions. Create the following file, and call it Instructions.py:

import pygame.font
from pygame.sprite import Sprite

class Instructions(Sprite):

    def __init__(self, screen, settings):

        Sprite.__init__(self)
        self.screen = screen
        self.settings = settings

        self.text_color = (30,30,30)
        self.font = pygame.font.SysFont('Arial', 24)

        # Store the set of instructions
        self.instr_lines = ["Move your mouse to swipe the sword back and forth."]
        self.instr_lines.append("Or, click on the sword to grab it.")
        self.instr_lines.append("But keep your pop rate above 90%!")

        # The instruction message only needs to be prepped once, not on every blit
        self.prep_msg()

    def prep_msg(self):
        y_position = self.settings.screen_height/2 + self.settings.button_height
        self.msg_images, self.msg_x, self.msg_y = [], [], []
        for index, line in enumerate(self.instr_lines):
            self.msg_images.append(self.font.render(line, True, self.text_color))
            self.msg_x.append(self.settings.screen_width/2-self.font.size(line)[0]/2)
            self.msg_y.append(y_position + index*self.font.size(line)[1])

    def blitme(self):
        for msg_x, msg_y, msg_image in zip(self.msg_x, self.msg_y, self.msg_images):
            self.screen.blit(msg_image, (msg_x, msg_y))

There isn’t really anything new in here. Anytime we are going to display text, we need to import pygame.font. Our instructions will be a sprite, so we import that as well. On lines 15-18, we store our instructions as a list of lines, so we can display each line centered on the screen.

On line 24, we set the y_position of the first line to be just below the center of the screen vertically. On line 25 we initialize empty lists to store our rendered message images, and each image’s x- and y- value. On line 26 we enumerate our loop so we have access to the loop’s index. We will use this index to push each line down below the previous one. Lines 28 and 29 use the pygame.font.size() function to center each line on the screen. This function takes in a piece of text, and returns the width and height of that text as it will be rendered. So, line 28 positions each line at the horizontal midpoint of the window, minus half the width of the rendered text. Line 29 pushes each successive line down by the height of a single line, times the index value of the loop.

The blitme() function for the Instructions class uses Python’s zip function. When given three lists of equal length, the zip function returns the first item in each list, then the second item in each list, and so forth. So each time we pass through the loop we have access to a msg_x value, a msg_y value, and a msg_image. We blit each of these images to the screen at the appropriate xy location.

We just need a couple changes to balloon_ninja.py to make these instructions appear on the screen:

import pygame, sys
from Settings import Settings
from Balloon import Balloon
from Sword import Sword
from Scoreboard import Scoreboard
from Button import Button
from Engine import Engine
from Instructions import Instructions

def run_game():
    # Get access to our game settings
    settings = Settings()
    engine = Engine()

    # initialize game
    pygame.init()
    screen = pygame.display.set_mode( (settings.screen_width, settings.screen_height), 0, 32)
    clock = pygame.time.Clock()
    scoreboard = Scoreboard(screen, settings)
    play_button = Button(screen, settings.screen_width/2-settings.button_width/2,
                            settings.screen_height/2-settings.button_height/2, settings, "Play Balloon Ninja")
    game_over_button = Button(screen, play_button.x_position, play_button.y_position-2*settings.button_height,
                            settings, "Game Over")
    instructions = Instructions(screen, settings)

    # Create a list to hold our balloons, and create our first balloon
    balloons = []

    # Create our dagger
    sword = Sword(screen, settings.scoreboard_height)

    # main event loop
    while True:
        # Advance our game clock, get the current mouse position, and check for new events
        time_passed = clock.tick(50)
        mouse_x, mouse_y = pygame.mouse.get_pos()[0], pygame.mouse.get_pos()[1]
        engine.check_events(settings, scoreboard, sword, play_button, mouse_x, mouse_y, balloons)

        # Redraw the empty screen before redrawing any game objects
        screen.fill(settings.bg_color)

        if settings.game_active:
            # Update the sword's position and check for popped or disappeared balloons
            engine.update_sword(sword, mouse_x, mouse_y, settings)
            engine.check_balloons(balloons, sword, scoreboard, screen, settings, time_passed)

            # If all balloons have disappeared, either through popping or rising,
            #  release a new batch of balloons.
            if len(balloons) == 0:
                # If we are not just starting a game, increase the balloon speed and points per balloon,
                #  and increment batches_finished
                if scoreboard.balloons_popped > 0:
                    #  Increase the balloon speed for each new batch of balloons.
                    settings.balloon_speed *= settings.speed_increase_factor
                    settings.points_per_balloon = int(round(settings.points_per_balloon * settings.speed_increase_factor))
                    scoreboard.batches_finished += 1
                # If player has completed required batches, increase batch_size
                if scoreboard.batches_finished % settings.batches_needed == 0 and scoreboard.batches_finished > 0:
                    settings.batch_size += 1
                engine.release_batch(screen, settings, balloons)
        else:
            # Game is not active, so...
            #  Show play button
            play_button.blitme()
            #  Show instructions for first few games.
            if settings.games_played < 3:
                instructions.blitme()
            #  If a game has just ended, show Game Over button
            if settings.games_played > 0:
                game_over_button.blitme()

        # Display updated scoreboard
        scoreboard.blitme()

        # Show the redrawn screen
        pygame.display.flip()

run_game()

We import our new Instructions class on line 8, and instantiate an instructions object on line 24. While we are adding code to display the instructions, we clean up the logic a bit about what is happening when a game is inactive. First we show the play button (line 64). Then, if the player has completed fewer than 3 games, we display the instructions. Finally, if at least one game has been played we display the Game Over button.

Now, you should see the following screen in between games:

Instructions now appear on startup, and after the first few games.

Instructions now appear on startup, and after the first few games.

Improving our score reporting

Now that we are able to achieve higher scores, let’s clean up the score reporting so that commas are displayed in the score. This is a one-line change to Scoreboard.py:

import pygame, pygame.font
from pygame.sprite import Sprite

class Scoreboard(Sprite):

    def __init__(self, screen, settings):

        Sprite.__init__(self)
        self.screen = screen
        self.settings = settings

        self.initialize_stats()

        # Set dimensions and properties of scoreboard
        self.sb_height, self.sb_width = settings.scoreboard_height, self.screen.get_width()
        self.rect = pygame.Rect(0,0, self.sb_width, self.sb_height)
        self.bg_color=(100,100,100)
        self.text_color = (225,225,225)
        self.font = pygame.font.SysFont('Arial', 18)

        # Set positions of individual scoring elements on the scoreboard
        self.x_popped_position, self.y_popped_position = 20.0, 10.0
        self.x_ratio_position, self.y_ratio_position = 150, 10.0
        self.x_points_position, self.y_points_position = 350, 10.0
        self.x_score_position, self.y_score_position = self.screen.get_width() - 200, 10.0

    def initialize_stats(self):
        # Game attributes to track for scoring
        self.balloons_popped = 0
        self.balloons_missed = 0
        self.score = 0
        self.popped_ratio = 1.0
        self.batches_finished = 0

    def prep_scores(self):
        self.popped_string = "Popped: " + str(self.balloons_popped)
        self.popped_image = self.font.render(self.popped_string, True, self.text_color)

        self.score_string = "Score: " + format(self.score, ',d')
        self.score_image = self.font.render(self.score_string, True, self.text_color)

        self.set_ratio_string()
        self.popped_ratio_image = self.font.render(self.popped_ratio_string, True, self.ratio_text_color)

        self.points_string = "Points per Balloon: " + str(self.settings.points_per_balloon)
        self.points_image = self.font.render(self.points_string, True, self.text_color)

    def set_ratio_string(self):
        if self.popped_ratio == 1.0:
            self.popped_ratio_string = "Pop Rate: 100%"
        else:
            self.popped_ratio_string = "Pop Rate: " + "{0:.3}%".format(self.popped_ratio*100.0)
        if self.popped_ratio < 0.95:
            self.ratio_text_color = (255, 51, 51)
        else:
            self.ratio_text_color = self.text_color

    def blitme(self):
        # Turn individual scoring elements into images that can be drawn
        self.prep_scores()
        # Draw blank scoreboard
        self.screen.fill(self.bg_color, self.rect)
        # Draw individual scoring elements
        self.screen.blit(self.popped_image, (self.x_popped_position, self.y_popped_position))
        self.screen.blit(self.points_image, (self.x_points_position, self.y_points_position))
        self.screen.blit(self.score_image, (self.x_score_position, self.y_score_position))
        self.screen.blit(self.popped_ratio_image, (self.x_ratio_position, self.y_ratio_position))

This is another simple use of Python’s format function. Now when we play, we will see a cleanly-formatted score:

The score is now displayed with  commas.

The score is now displayed with commas.

We now have a dynamic and cleanly-reported scoring system. In the next post, we will add some kittens to the game.

Next: Kittens!

Back to Tutorial Contents

Advertisements

About ehmatthes

Teacher, hacker, new dad, outdoor guy
This entry was posted in programming and tagged , , , , . Bookmark the permalink.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s