python-crash-course-book/chap12_a_ship_that_fires_bu...

17 KiB

Chapter 12. A Ship that Fires Bullets

Starting the Game Project

import sys
import pygame

class AlienInvasion:
    """Overall class to manage game assets and behaviour"""

    def __init__(self):
        """Initialize the game, and create game resources."""
        pygame.init()

        self.screen = pygame.display.set_mode((1200, 800))
        pygame.display.set_caption("Alien Invasion")

    def run_game(self):
        """Start the main loop for the game"""
        while True:

            # event loop, watch for mouse/keyboard events
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    sys.exit()

            # Make the most recently draw screen visible
            pygame.display.flip()

if __name__ == '__main__':
    ai = AlienInvasion()
    ai.run_game()
  • pygame.display.set_mode() assigned a surface to self.screen, which is a part of the screen where a game element can be displayed.
    • When we activate the game's animation loop, this surface will be redrawn every time.
  • Game is contolled by run_game() method. containing
    • event loop block
    • code that manages screen updates.
  • To access the events that Pygame detects, use pygame.event.get() function. In each loop, any keyboard/mouse event will cause the for loop to run.

Creating a Settings Class

In order to manage the setting more efficiently, we move setting into a separate module settings.py

class Settings:
    """A class to store all settings for Alien Invasion"""

    def __init__(self):
        """Initialize the game's settings."""

        # Screen settings
        self.screen_width = 1200
        self.screen_height = 800
        self.bg_color = (230,230,230)

alien_invasion.py will also be modified to fit it.

Adding The Ship Image

  • Verify it's free licensed image

Creating the Ship Class

Create this ship.py module

import pygame

class Ship:
    """A class to manage the ship."""

    def __init__(self, ai_game):
        """Initialize the ship and set its starting position"""
        self.screen = ai_game.screen
        self.screen_rect = ai_game.screen.get_rect()

        # Load the ship image and get its rect
        self.image = pygame.image.load('ship.bmp')
        self.rect = self.image.get_rect()
        # Start each new ship at the bottom center of the screen
        self.rect.midbottom = self.screen_rect.midbottom

    def blitme(self):
        """Draw the ship at its current location"""
        self.screen.blit(self.image,self.rect)
  • Pygame treat all game elements like rectangles (rects). So, collision of objects can be calculated via get_rect() method
  • pygame.image.load() is called to load the image
  • When the image is loaded, we call get_rect() to access ship surface's rect attribute

How to work with a rect object:

  • Use x,y coord of top, bottom, left and right edges of the rect.
  • We can set any of these values to establish the current position of the rect.
  • When working center of a game element, use center, centerx, or centery attribute of a rect.
  • When working with edge of the screen: work with top, bottom, left or right attributes.
  • When adjust horizontal/vertical placement of rect, just use x & y attributes.
  • (0,0) is the top-left corner of the screen.

Drawing the Ship to the Screen

Modify alien_invasion.py by:

  1. Add ship object in __init__
  2. Add self.ship.blitme() in screen redraw code block

Refactoring: The _check_events() and _update_screen() methods

Refactor code:

  • simplifies the structure
  • Make it easier to build on.

Here we refactor the code by break the run_game():

  • Move code for managing events to _check_events()
  • Move code for updating the screen to _update_screen()

helper method:

  • is a method in class but isn't meant to be called through an instance.
  • syntax: a single leading underscore

_check_events() Method

Create function in AlienInvasion and replace original code snippet in while loop

def _check_events(self):
    """Respond to keypresses and mouse events."""
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            sys.exit()

_update_screen() Method

Create function _update_screen() and replace original code snippet

def _update_screen(self):
    """Update images on the screen, and flip to the new screen"""
    self.screen.fill(self.settings.bg_color)
    self.ship.blitme()
def run_game(self):
    """Start the main loop for the game"""
    while True:

        self._check_events()
        self._update_screen()

After replacing lengthy codes with helper function, run_game now become simpler. This is a way of refactoring the code

Piloting the Ship

Now write code that responds when player presses the right or left arrow key.

Responding to a Keypress

  • Whenever the player presses a key, the keypress is registered in Pygame as an event.
  • Each event is picked by pygame.event.get() method.
  • We can specify in _check_events() method what kind of events we want the game to check for.
  • Each keypress is registered as a KEYDOWN event
  • If a K_RIGHT event is found, move the ship to right by increasing the value self.ship.rect.x by 1
    def _check_events(self):
        """Respond to keypresses and mouse events."""
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                sys.exit()
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_RIGHT:
                    self.ship.rect.x += 1

Allowing Continuous Movement

  • We can detect whether the key is released using KEYUP event.
  • We can use KEYDOWN & KEYUP events together with a flag called moving_right to implement continuous motion
    • When moving_right flag is False, the ship will be motionless.
    • Create an attribute called moving_right and update() method to check the status of the moving_right flag.

Change alien_invasion.py:

    def run_game(self):
        """Start the main loop for the game"""
        while True:

            self._check_events()
            self.ship.update()
            self._update_screen()

    def _check_events(self):
        """Respond to keypresses and mouse events."""
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                sys.exit()
            elif event.type == pygame.KEYDOWN:
                if event.key == pygame.K_RIGHT:
                    self.ship.moving_right = True
            elif event.type == pygame.KEYUP:
                if event.key == pygame.K_RIGHT:
                    self.ship.moving_right = False

Change ship.py:

    def __init__(self, ai_game):
        """Initialize the ship and set its starting position"""
        self.screen = ai_game.screen
        self.screen_rect = ai_game.screen.get_rect()

        # Load the ship image and get its rect
        self.image = pygame.image.load('ship.bmp')
        self.rect = self.image.get_rect()
        # Start each new ship at the bottom center of the screen
        self.rect.midbottom = self.screen_rect.midbottom

        # Movement flag
        self.moving_right = False

    def update(self):
        """Update the ship's position based on movement flag"""
        if self.moving_right:
            self.rect.x += 1

Moving Both Left and Right

Apply the same logic on left movement

Adjusting the Ship's Speed

  1. Add ship_speed attribute to the Settings class
  2. Modify update() & __init__ method of Ship, so it move by defined speed
class Settings:
    """A class to store all settings for Alien Invasion"""

    def __init__(self):
        """Initialize the game's settings."""

        # Screen settings
        self.screen_width = 1200
        self.screen_height = 800
        self.bg_color = (230,230,230)
        
        # Ship settings
        self.ship_speed = 1.5

Modify ship.py

    def __init__(self, ai_game):
        """Initialize the ship and set its starting position"""
        self.screen = ai_game.screen
        self.screen_rect = ai_game.screen.get_rect()
        self.settings = ai_game.settings

        # Load the ship image and get its rect
        self.image = pygame.image.load('ship.bmp')
        self.rect = self.image.get_rect()
        # Start each new ship at the bottom center of the screen
        self.rect.midbottom = self.screen_rect.midbottom

        # Store a decimal value for the ship's horizontal position
        self.x = float(self.rect.x)

        # Movement flag
        self.moving_right = False
        self.moving_left = False

    def update(self):
        """Update the ship's position based on movement flag"""
        
        # Update the ship's x value, not the rect
        if self.moving_right:
            self.rect.x += self.settings.ship_speed
        if self.moving_left:
            self.rect.x -= self.settings.ship_speed

Limiting the Ship's Range

Add limit of ship's range

    def update(self):
        """Update the ship's position based on movement flag"""
        
        # Update the ship's x value, not the rect
        if self.moving_right and self.rect.right < self.screen_rect.right:
            self.rect.x += self.settings.ship_speed
        if self.moving_left and self.rect.left > 0:
            self.rect.x -= self.settings.ship_speed

Refactoring _check_events()

As _check_events() is becoming lengthy, we should break/refactor it.

    def _check_events(self):
        """Respond to keypresses and mouse events."""
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                sys.exit()
            elif event.type == pygame.KEYDOWN:
                self._check_keydown_events(event)
            elif event.type == pygame.KEYUP:
                self._check_keyup_events(event)

    def _check_keydown_events(self, event):
        """Respond to keypresses"""
        if event.key == pygame.K_RIGHT:
            self.ship.moving_right = True
        elif event.key == pygame.K_LEFT:
            self.ship.moving_left = True

    def _check_keyup_events(self, event):
        """Respond to key releases"""
        if event.key == pygame.K_RIGHT:
            self.ship.moving_right = False
        elif event.key == pygame.K_LEFT:
            self.ship.moving_left = False

Pressing Q to Quit

  • use pygame.K_q key event (q) to exit program
    def _check_keydown_events(self, event):
        """Respond to keypresses"""
        if event.key == pygame.K_RIGHT:
            self.ship.moving_right = True
        elif event.key == pygame.K_LEFT:
            self.ship.moving_left = True
        elif event.key == pygame.K_q:
            sys.exit()

Running the Game in Fullscreen Mode

To run the game in fullscreen mode

class AlienInvasion:
    """Overall class to manage game assets and behaviour"""

    def __init__(self):
        """Initialize the game, and create game resources."""
        pygame.init()

        self.settings = Settings()

        # self.screen = pygame.display.set_mode((self.settings.screen_width, self.settings.screen_height))
        self.screen = pygame.display.set_mode((0,0), pygame.FULLSCREEN)
        self.settings.screen_width = self.screen.get_rect().width
        self.settings.screen_height = self.screen.get_rect().height
        pygame.display.set_caption("Alien Invasion")

        self.ship = Ship(self)

Shooting Bullets

Write code that fires a bullet, which is represented by a small rectangle, when the player presses the spacebar.

Adding the Bullet Settings

    def __init__(self):
        """Initialize the game's settings."""

        # Screen settings
        ...
        # Ship settings
        ...
        # Bullet settings
        self.bullet_speed = 1.0
        self.bullet_width = 3
        self.bullet_height = 15
        self.bullet_color = (60,60,60)

bullet.py module

import pygame
from pygame.sprite import Sprite

class Bullet(Sprite):
    """
    A class to manage bullets fired from the ship
    """


    def __init__(self, ai_game):
        """Create a bullet object at the ship's current position."""
        super().__init__()
        self.screen = ai_game.screen
        self.settings = ai_game.settings
        self.color = self.settings.bullet_color

        # Create a bullet rect at (0,0) and then set correct position
        self.rect = pygame.Rect(0,0,self.settings.bullet_width, self.settings.bullet_height)
        self.rect.midtop = ai_game.ship.rect.midtop

        # Store the bullet's position as a decimal value
        self.y = float(self.rect.y)
  • self.rect = ... created bullet's rect attribute.
    • First, initialize at (0,0), with width/height stored in self.settings
    • Then, move it to the correct location via self.rect.midtop, as the bullet's position depends on the ship's position

Then add update() & draw_bullet() for Bullet class

    def update(self):
        """Move the bullet up the screen"""
        
        # Update the decimal position of the bullet
        self.y -= self.settings.bullet_speed
        # Update the rect position
        self.rect.y = self.y # When a bullet is fired, it moves up the screen

    def draw_bullet(self):
        """Draw the bullet to the screen"""
        pygame.draw.rect(self.screen, self.color, self.rect)

Storing Bullets in a Group

After defining the Bullet class and its necessary settings, we can write code to fire a bullet each time the player presses the spacebar.

First, we'll create a group in AlienInvasion to store all the live bullets, so we can manage the bullets that have already been fired.

pygame.sprite.Group class behave like a list with some extra functionality that's helpful when building games. So we directly draw the group (instead of individual bullets) to the screen.

alien_invasion.py

def __init__(self):
    ...
    
    self.ship = Ship(self)
    self.bullets = pygame.sprite.Group()

...

def run_game(self):
    """Start the main loop for the game"""
    while True:
        self._check_events()
        self.ship.update()
        self.bullets.update() # initiate the bullets group
        self._update_screen()

Firing Bullets

  1. Add key press event in _check_keydown_events(self,event), which will trigger _fire_bullet() by K_SPACE
  2. Create function _fire_bullet(self)
  3. Add bullets group's drawing action in _update_screen()
from bullet import Bullet

class AlienInvasion:

    def _check_keydown_events(self, event):
        """Respond to keypresses"""
        ...
        elif event.key == pygame.K_q:
            sys.exit()
        elif event.key == pygame.K_SPACE:
            self._fire_bullet()

    def _check_keyup_events(self, event):
        ...

    def _fire_bullet(self):
        """Create a new bullet and add it to the bullets group"""
        new_bullet = Bullet(self)
        self.bullets.add(new_bullet)

    def _update_screen(self):
        """Update images on the screen, and flip to the new screen"""
        ...
        for bullet in self.bullets.sprites():
            bullet.draw_bullet()

        pygame.display.flip()

Deleting Old Bullets

Currently, bullets don't delete themselves after moving out of frame. Only their y-coordinate values grow increasingly negative.

  • Con: bullets continue to consume memory and processing power.

Solution: need recycle these objects during running the game

    def run_game(self):
        """Start the main loop for the game"""
        while True:
            ...

            # Delete bullets out of screen
            for bullet in self.bullets.copy():
                if bullet.rect.bottom <= 0:
                    self.bullets.remove(bullet)
            print(len(self.bullets))

Limiting the Number of Bullets

To limit the number of bullets.

class Settings:
    """A class to store all settings for Alien Invasion"""

    def __init__(self):
        """Initialize the game's settings."""
        ...
        self.bullet_allowed = 3

Modify AlienInvasion class as shown below

class AlienInvasion:
    """Overall class to manage game assets and behaviour"""
    ...

    def _fire_bullet(self):
        """Create a new bullet and add it to the bullets group"""
        if len(self.bullets) < self.settings.bullets_allowed:
            new_bullet = Bullet(self)
            self.bullets.add(new_bullet)

Creating the _update_bullets() Method

Need refactor run_game function in AlienInvasion class again, to make bullets update and deletion independent of run_game()

...
class AlienInvasion:
    """Overall class to manage game assets and behaviour"""
    def run_game(self):
        """Start the main loop for the game"""
        while True:

            self._check_events()
            self.ship.update()
            self._update_bullets()

            self._update_screen()

    def _update_bullets(self):
        """Update position of bullets and get rid of old bullets"""
        # Update bullet positions.
        self.bullets.update()

        # Delete bullets out of screen
        for bullet in self.bullets.copy():
            if bullet.rect.bottom <= 0:
                self.bullets.remove(bullet)
        print(len(self.bullets))
...