Alien Invasion: Finishing the Game

  • 14 SCORING: In this chapter, Alien Invasion will be completed by adding a Play button to start/restart the game, increasing game speed with levels, and implementing a scoring system.

Adding the Play Button

  • A Play button will appear before the game starts and after it ends, allowing the player to start a new game.
  • Currently, the game starts immediately when alien_invasion.py is run.
  • Modify the __init__() method in AlienInvasion to start the game in an inactive state:
  def __init__(self):
      """Initialize the game, and create game resources."""
      pygame.init()
      --snip--
      # Start Alien Invasion in an inactive state.
      self.game_active = False

Creating a Button Class

  • Since Pygame doesn't have a built-in button method, a Button class will be created to make a filled rectangle with a label.
  • Save as button.py:
  import pygame.font

  class Button:
      """A class to build buttons for the game."""
      def __init__(self, ai_game, msg):
          """Initialize button attributes."""
          self.screen = ai_game.screen
          self.screen_rect = self.screen.get_rect()

          # Set the dimensions and properties of the button.
          self.width, self.height = 200, 50
          self.button_color = (0, 135, 0)
          self.text_color = (255, 255, 255)
          self.font = pygame.font.SysFont(None, 48)

          # Build the button's rect object and center it.
          self.rect = pygame.Rect(0, 0, self.width, self.height)
          self.rect.center = self.screen_rect.center

          # The button message needs to be prepped only once.
          self._prep_msg(msg)
  • Imports the pygame.font module for rendering text.

  • The __init__() method takes self, ai_game object, and msg (button text).

  • Sets button dimensions, color, text color, and font attributes.

  • Centers the button by creating a rect and setting its center attribute to match the screen's center.

  • Calls _prep_msg() to render the text as an image.

    • Code for _prep_msg():
  def _prep_msg(self, msg):
      """Turn msg into a rendered image and center text on the button."""
      self.msg_image = self.font.render(msg, True, self.text_color,
              self.button_color)
      self.msg_image_rect = self.msg_image.get_rect()
      self.msg_image_rect.center = self.rect.center
  • Takes self and msg parameters.

  • font.render() turns the text into an image, stored in self.msg_image.

  • Antialiasing is turned on for smoother text edges.

  • Sets the text background to the same color as the button.

  • Centers the text image on the button by creating a rect from the image and setting its center attribute to match that of the button.

    • Code for draw_button():
  def draw_button(self):
      """Draw blank button and then draw message."""
      self.screen.fill(self.button_color, self.rect)
      self.screen.blit(self.msg_image, self.msg_image_rect)
  • screen.fill() draws the rectangular portion of the button.
  • screen.blit() draws the text image to the screen.

Drawing the Button to the Screen

  • Update import statements in alien_invasion.py:
  --snip--
  from game_stats import GameStats
  from button import Button
  • Create the button instance in __init__():
  def __init__(self):
      --snip--
      self.game_active = False

      # Make the Play button.
      self.play_button = Button(self, "Play")
  • Call draw_button() in _update_screen():
  def _update_screen(self):
      --snip--
      self.aliens.draw(self.screen)

      # Draw the play button if the game is inactive.
      if not self.game_active:
          self.play_button.draw_button()

      pygame.display.flip()

Starting the Game

  • Add an elif block to _check_events() to monitor mouse events over the button:
  def _check_events(self):
      """Respond to keypresses and mouse events."""
      for event in pygame.event.get():
          if event.type == pygame.QUIT:
              --snip--
          elif event.type == pygame.MOUSEBUTTONDOWN:
              mouse_pos = pygame.mouse.get_pos()
              self._check_play_button(mouse_pos)
  • pygame.mouse.get_pos() returns a tuple with the x- and y-coordinates of the mouse cursor when clicked.

  • These coordinates are sent to _check_play_button().

    • Code for _check_play_button():
  def _check_play_button(self, mouse_pos):
      """Start a new game when the player clicks Play."""
      if self.play_button.rect.collidepoint(mouse_pos):
          self.game_active = True
  • collidepoint() checks if the mouse click overlaps the Play button's rect.
  • If so, game_active is set to True, and the game begins.

Resetting the Game

  • To reset the game statistics, clear out the old aliens and bullets, build a new fleet, and center the ship, include this code in _check_play_button():
  def _check_play_button(self, mouse_pos):
      """Start a new game when the player clicks Play."""
      if self.play_button.rect.collidepoint(mouse_pos):
          # Reset the game statistics.
          self.stats.reset_stats()
          self.game_active = True

          # Get rid of any remaining bullets and aliens.
          self.bullets.empty()
          self.aliens.empty()

          # Create a new fleet and center the ship.
          self._create_fleet()
          self.ship.center_ship()
  • Resets game statistics by calling self.stats.reset_stats().
  • Empties existing bullets and aliens groups.
  • Creates a new fleet and centers the ship.

Deactivating the Play Button

  • To ensure the Play button region only responds to clicks when the Play button is visible, set the game to start only when game_active is False:
  def _check_play_button(self, mouse_pos):
      """Start a new game when the player clicks Play."""
      button_clicked = self.play_button.rect.collidepoint(mouse_pos)
      if button_clicked and not self.game_active:
          # Reset the game statistics.
          self.stats.reset_stats()
          --snip--
  • The game restarts only if Play is clicked and the game is not currently active.

Hiding the Mouse Cursor

  • Make the mouse cursor invisible when the game becomes active by editing the _check_play_button() definition:
  def _check_play_button(self, mouse_pos):
      """Start a new game when the player clicks Play."""
      button_clicked = self.play_button.rect.collidepoint(mouse_pos)
      if button_clicked and not self.game_active:
          --snip--
          # Hide the mouse cursor.
          pygame.mouse.set_visible(False)
  • Make the cursor reappear once the game ends:
  def _ship_hit(self):
      """Respond to ship being hit by alien."""
      if self.stats.ships_left > 0:
          --snip--
      else:
          self.game_active = False
          pygame.mouse.set_visible(True)

Leveling Up

  • The game's speed will increase each time the player clears the screen.

Modifying the Speed Settings

  • Reorganize the Settings class to group the game settings into static and dynamic ones.

  • Ensure any settings that change during the game reset when a new game starts.

  • __init__() method for settings.py:

  def __init__(self):
      """Initialize the game's static settings."""
      # Screen settings
      self.screen_width = 1200
      self.screen_height = 800
      self.bg_color = (230, 230, 230)

      # Ship settings
      self.ship_limit = 3

      # Bullet settings
      self.bullet_width = 3
      self.bullet_height = 15
      self.bullet_color = 60, 60, 60
      self.bullets_allowed = 3

      # Alien settings
      self.fleet_drop_speed = 10

      # How quickly the game speeds up
      self.speedup_scale = 1.1

      self.initialize_dynamic_settings()

Initialize Dynamic Settings

  • Add a speedup_scale setting to control how quickly the game speeds up.

    • A value of 2 will double the game speed every time the player reaches a new level.
    • A value of 1 will keep the speed constant.
    • A value like 1.1 should increase the speed enough to make the game challenging but not impossible.
  • Call the initialize_dynamic_settings() method to initialize the values for attributes that need to change throughout the game.

  • Code for initialize_dynamic_settings():

  def initialize_dynamic_settings(self):
      """Initialize settings that change throughout the game."""
      self.ship_speed = 1.5
      self.bullet_speed = 2.5
      self.alien_speed = 1.0

      # fleet_direction of 1 represents right; -1 represents left.
      self.fleet_direction = 1
  • This method sets the initial values for the ship, bullet, and alien speeds.

  • We'll increase these speeds as the player progresses in the game and reset them each time the player starts a new game.

  • We include fleet_direction in this method so the aliens always move right at the beginning of a new game.

  • We don't need to increase the value of fleetdropspeed, because when the aliens move faster across the screen, they'll also come down the screen faster.

    • Code for increase_speed():
  def increase_speed(self):
      """Increase speed settings."""
      self.ship_speed *= self.speedup_scale
      self.bullet_speed *= self.speedup_scale
      self.alien_speed *= self.speedup_scale
  • Increase the speeds of the ship, bullets, and aliens each time the player reaches a new level.

  • This method multiplies each speed setting by the value of speedup_scale.

    • Call increase_speed() in _check_bullet_alien_collisions():
  def _check_bullet_alien_collisions(self):
      --snip--
      if not self.aliens:
          # Destroy existing bullets and create new fleet.
          self.bullets.empty()
          self._create_fleet()
          self.settings.increase_speed()
  • This speeds up the entire game.

Resetting the Speed

  • Return any changed settings to their initial values each time the player starts a new game:
  def _check_play_button(self, mouse_pos):
      """Start a new game when the player clicks Play."""
      button_clicked = self.play_button.rect.collidepoint(mouse_pos)
      if button_clicked and not self.game_active:
          # Reset the game settings.
          self.settings.initialize_dynamic_settings()
          --snip--

Scoring

  • Implement a scoring system to track the game's score in real-time and display the high score, level, and number of ships remaining.

GameStats Class

  • Add a score attribute to GameStats:
  class GameStats:
      --snip--
      def reset_stats(self):
          """Initialize statistics that can change during the game."""
          self.ships_left = self.ai_settings.ship_limit
          self.score = 0
  • To reset the score each time a new game starts, initialize score in reset_stats() rather than __init__().

Displaying the Score

  • Create a new class, Scoreboard, to display the score on the screen. Eventually, we'll use it to report the high score, level, and number of ships remaining as well.

class Scoreboard:

 import pygame.font

 class Scoreboard:
    """A class to report scoring information."""
    def __init__(self, ai_game):
        """Initialize scorekeeping attributes."""
        self.screen = ai_game.screen
        self.screen_rect = self.screen.get_rect()
        self.settings = ai_game.settings
        self.stats = ai_game.stats

        # Font settings for scoring information.
        self.text_color = (30, 30, 30)
        self.font = pygame.font.SysFont(None, 48)

        # Prepare the initial score image.
        self.prep_score()
  • Imports the pygame.font module.

  • The __init__() method takes ai_game parameter, so it can access the settings, screen, and stats objects.

  • Sets a text color and instantiate a font object.

  • Calls prep_score() to turn the text to be displayed into an image.

    • Code for prep_score():
  def prep_score(self):
      """Turn the score into a rendered image."""
      score_str = str(self.stats.score)
      self.score_image = self.font.render(score_str, True,
              self.text_color, self.settings.bg_color)

      # Display the score at the top right of the screen.
      self.score_rect = self.score_image.get_rect()
      self.score_rect.right = self.screen_rect.right - 20
      self.score_rect.top = 20
  • turn the numerical value stats.score into a string then pass this string to render(), which creates the image

  • We'll position the score in the upper-right corner of the screen and have it expand to the left as the score increases and the width of the number grows.

    • Code for show_score():
  def show_score(self):
      """Draw score to the screen."""
      self.screen.blit(self.score_image, self.score_rect)
  • Displays the score image onscreen at the location score_rect specifies.

    • Create a Scoreboard instance in AlienInvasion, update the import statements:
 from game_stats import GameStats from scoreboard import Scoreboard --snip--
  • Then draw the scoreboard onscreen in _update_screen():
 def _update_screen(self):
    --snip-- self.aliens.draw(self.screen)

    # Draw the score information. self.sb.show_score()

    # Draw the play button if the game is inactive.
    --snip--

Updating the Score as Aliens Are Shot Down

  • To write a live score onscreen, update the value of stats.score whenever an alien is hit, and then call prep_score() to update the score image.
  def initialize_dynamic_settings(self):
      --snip--

      # Scoring settings
      self.alien_points = 50
  • Increase each alien's point value as the game progresses or reset the value each time a new game starts.

    • Update the score in _check_bullet_alien_collisions():
  def _check_bullet_alien_collisions(self):
      """Respond to bullet-alien collisions."""

      # Remove any bullets and aliens that have collided.
      collisions = pygame.sprite.groupcollide(
              self.bullets, self.aliens, True, True)

      if collisions:
          self.stats.score += self.settings.alien_points
          self.sb.prep_score()
          --snip--
  • When a bullet hits an alien, Pygame returns a collisions dictionary. If this dictionary exists, the alien's value is added to the score, and prep_score() is called to update the score.

Resetting the Score

  • Prepping score when starting a new game
    • Call prep_score() after resetting the game stats when starting a new game.
 def _check_play_button(self, mouse_pos):
    --snip--
    if button_clicked and not self.game_active:
      --snip--
      # Reset the game statistics.
      self.stats.reset_stats()
      self.sb.prep_score()

Making sure to score all hits

refine bullet-alien collision detection to award points for each alien hit.

 def _check_bullet_alien_collisions(self):
    --snip--
    if collisions:
     for aliens in collisions.values():
      self.stats.score += self.settings.alien_points * len(aliens)
      self.sb.prep_score()

Rounding The Score

  • Most arcade-style shooting games report scores as multiples of 10, so let’s fol- low that lead with our scores. Also, let’s format the score to include comma separators in large numbers. We’ll make this change in Scoreboard:
 def prep_score(self):
    """Turn the score into a rendered image."""
    rounded_score = round(self.stats.score, -1)
    score_str = f"{rounded_score:,}"
    self.score_image = self.font.render(score_str, True,
       self.text_color, self.settings.bg_color)

    --snip--

High Scores

  • Every player wants to beat a game’s high score, so let’s track and report high scores to give players something to work toward. We’ll store high scores in GameStats:
 def __init__(self, ai_game):
  --snip--
  # High score should never be reset.
  self.high_score = 0

We call checkhighscore() when the collisions dictionary is present, and we do so after updating the score for all the aliens that have been hit.The first time you play Alien Invasion, your score will be the high score, so it will be displayed as the current score and the high score. But when you start a second game, your high score should appear in the middle and your current score should appear at the right, as shown in Figure 14-4.

Displaying the Level

  • Update GameStats by initializing level:
  def reset_stats(self):
      """Initialize statistics that can change during the game."""
      self.ships_left = self.settings.ship_limit
      self.score = 0
      self.level = 1
  • Have Scoreboard display the current level by calling prep_level() from __init__():
  def __init__(self, ai_game):
      --snip--
      self.prep_high_score()
      self.prep_level()
  • Define prep_level
 def prep_level(self):
      """Turn the level into a rendered image."""
      level_str = str(self.stats.level)
      self.level_image = self.font.render(level_str, True,
              self.text_color, self.settings.bg_color)

      # Position the level below the score.
      self.level_rect = self.level_image.get_rect()
      self.level_rect.right = self.score_rect.right
      self.level_rect.top = self.score_rect.bottom + 10
  • Also need to update show_score
 def show_score(self):
   """Draw scores and level to the screen."""
   self.screen.blit(self.score_image, self.score_rect)
   self.screen.blit(self.high_score_image, self.high_score_rect)
   self.screen.blit(self.level_image, self.level_rect)

The prep_level() method creates an image from the value stored in stats.level 1 and sets the image’s right attribute to match the score’s right attribute 2. It then sets the top attribute 10 pixels beneath the bottom of the score image to leave space between the score and the level 3.
We’ll increment stats.level and update the level image in _check_bullet _alien_collisions():

 def _check_bullet_alien_collisions(self):
  --snip--
  if not self.aliens:
   # Destroy existing bullets and create new fleet.
   self.bullets.empty()
   self._create_fleet()
   self.settings.increase_speed()
   # Increase level.
   self.stats.level += 1
   self.sb.prep_level()

Non Textual ships

Now we need to draw the ships to the screen:

def show_score(self):
    """Draw scores, level, and ships to the screen."""
    self.screen.blit(self.score_image, self.score_rect)
    self.screen.blit(self.high_score_image, self.high_score_rect)
    self.screen.blit(self.level_image, self.level_rect)

    self.ships.draw(self.screen)