October 16, 2024

Asteroid Clash: A Python Game made with Pygame and OOPs

Spread the love

Disclaimer: I have followed an article on RealPython.com. Here’s the link to the article. In this article, I will not go through a step-by-step method of building the project (because it’s already explained in the RealPython article) neither it’s an article about Pygmae, instead, I will focus on my takeaway and things I learned from the project on a high level.

You can find my complete code on my Github repository here.

project overview

I will try to recreate one of the classic games: Asteroid. In this project, we will go through various Pygame concepts like:

  • Loading images
  • Displaying images
  • Handling user input
  • Moving objects
  • Detecting collisions
  • Playing sounds

The project will feature a single spaceship that can rotate left and right as well as accelerate forward. When it’s not moving it will continue with the same velocity to mimic the motion in space. The ship will face many asteroids moving with random velocity and to proceed, we need to shoot the asteroids with bullets causing them to break into 2 smaller asteroids, and eventually disappear.

Keymapping for our game
KeyAction
RightRotate the spaceship right
LeftRotate the spaceship left
UpAccelerate the spaceship forward
SpaceShoot bullets
EscExit the game

initializing the game

Let’s create a class for our game, which can be later modified when we need to add functionalities and logic for the game.

# Importing Libraries
import pygame

# Defining SpaceRocks Class
class AsteroidClash:
    # Initializing
    def __init__(self) -> None:
        self._init_pygame()
        self.screen = pygame.display.set_mode((800, 600))
    
    # Defining Main Loop for the game
    def main_loop(self) -> None:
        while True:
            self._handle_input()
            self._process_game_logic()
            self._draw()
    
    # Initializing Pygame
    def _init_pygame(self) -> None:
        pygame.init()
        pygame.display.set_caption('Asteroid Clash')
    
    # Defining function for handling input
    def _handle_input(self) -> None:
        for event in pygame.event.get():

            # Exiting the game
            if event.type == pygame.QUIT or (
                event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE
                ):
                quit()

    # Defining function for processing game logic
    def _process_game_logic(self) -> None:
        pass

    # Defining function for drawing elements on screen
    def _draw(self) -> None:
        self.screen.fill((0, 0, 255))
        pygame.display.flip()

Let’s walk through the code:

CodeExplanation
import pygameImporting library
AsteroidClashDefining a custom class that will handle basic game functions
__init__Initialize the class by:
– calling an _init_pygame function, which is later defined
– screen, setting the display size and an initial screen where we can render our objects
main_loopMain loop for the game where we call multiple functions for handling the input, processing game logic, and drawing elements onto the screen
_init_pygameInitialize pygame module and set the caption for our game
_handle_inputFunction for handling user inputs. Initially, we only look for either the close button or Escape key press, which causes the game to quit
_process_game_logicFunction for the game logic, we initially just pass this function
_drawFunction for drawing elements onto the screen, we initially just draw a solid colo

Adding images

In computer game development, images are usually called sprites. Since we will need to import sprites multiple times in our program, it makes sense to create a separate module to handle that functionality.

Defining Function for loading the images
# Importing Library
from pygame.image import load

def load_sprite(name: str, with_alpha: bool = True) -> object:
    path = f'assets/sprites/{name}.png'
    loaded_sprite = load(path)

    if with_alpha:
        return loaded_sprite.convert_alpha()
    else:
        return loaded_sprite.convert()

Let’s walk through the code:

  • We import load method for our pygame.image module. This will help us load images as pygame objects.
  • We then define our load_sprite function, which takes two parameters:
    • name: name of the image
    • with_alpha: wether or not we want to use transparency or not
  • The function assumes that all your images are stored in the same location
Updating our main code to add a background
# Importing Libraries
...
from utils import load_sprite

# Defining SpaceRocks Class
class AsteroidClash:
    # Initializing
    def __init__(self) -> None:
        ...
        self.background = load_sprite(name='space', with_alpha=False)
   
    ...

    # Defining function for drawing elements on screen
    def _draw(self) -> None:
        self.screen.fill((0, 0, 255))
        self.screen.blit(self.background, (0, 0))
        ...

Let’s walk through the code:

  • We first import our load_sprite function from our utils module
  • We then initialize our class with background. Using the load_sprite function, we load a background image.
  • In our _draw function, we then remove the line of code which was filling a solid color and replace with another line of code which draws the desired element.

Adding game objects

We would use a custom game object class to add our elements like spaceship and asteroids since we will need more data than just an image.

Defining our class
# Importing Libraries
from pygame.math import Vector2

class GameObject:
    def __init__(self, position, sprite, velocity) -> None:
        self.position = Vector2(position)
        self.sprite = sprite
        self.radius = sprite.get_width() / 2
        self.velocity = Vector2(velocity)
    
    def draw(self, surface):
        blit_position = self.position - Vector2(self.radius)
        surface.blit(self.sprite, blit_position)
    
    def move(self):
        self.position = self.position + self.velocity
    
    def collides_with(self, other_obj):
        distance = self.position.distance_to(other_obj.position)
        return distance < self.radius + other_obj.radius

Let’s walk through the code:

  • In order to ease our calculations, pygame offers a Vector2 module which can be used to handle all the various vector calculations.
  • We define our class GameObject, that takes in 3 arguments:
    • position: the center of the object
    • sprite: image which will be drawn
    • velocity: updates the position of the object
  • Initializing the class
    • We convert our position coordinates as well as our velocity to vector.
    • We define radius as half of the width of our image
  • Defining a function for drawing the image:
    • In order to draw/blit an image onto the screen, we need coordinate values specifically the top left coordinates. Hence we can use vector substraction to get that value
    • We then draw the image with the correct coordinates
  • Defining a function to move objects:
    • We can simply add our position vector with the velocity vector to get a new vector which includes the speed and direction for our object
  • Defining a function for collision detection:
    • This function takes another object as its parameter and using the distance value of our current object with other object’s postion.
    • It returns a boolean value depending on the distance wether or not its greater than the sum of radius of both objects.
Updating the main code to add in our objects

Let’s add spaceship and asteroid into our game using the newly created GameObject class

# Importing Libraries
...
from models import GameObject

# Defining SpaceRocks Class
class AsteroidClash:
    # Initializing
    def __init__(self) -> None:
        ...
        self.spaceship = GameObject(
            position=(400, 300),
            sprite=load_sprite('spaceship'),
            velocity=(0, 0)
            )
        self.asteroid = GameObject(
            position=(400, 300),
            sprite=load_sprite('asteroid'),
            velocity=(1, 0)
            )
    ...

    # Defining function for processing game logic
    def _process_game_logic(self) -> None:
        pass
        self.spaceship.move()
        self.asteroid.move()

    # Defining function for drawing elements on screen
    def _draw(self) -> None:
        ...
        self.spaceship.draw(self.screen)
        self.asteroid.draw(self.screen)
        ...

Let’s walk through the code:

  • We import our class to the code.
  • Then we initialize our class with asteroid and spaceship using the newly created GameObject class.
    • We use load_sprite function to load the images and customize our GameObject instances
  • Then we add functionality to our _move function by calling GameObject move method
  • Finally, we draw our elements onto the screen
Using frames per second for consistency

We need to set our frames per second to a fixed value for consistency. For this, pygame offers pygame.time.clock class with a tick method. Let’s update our code:

# Defining SpaceRocks Class
class AsteroidClash:
    # Initializing
    def __init__(self) -> None:
        ...
        self.clock = pygame.time.Clock()
    ...

    # Defining function for drawing elements on screen
    def _draw(self) -> None:
        ...
        self.clock.tick(60)

Let’s walk through the code:

  • We initialze our class with clock using the pygame.time.Clock class.
  • In our _draw method, we then set the FPS of our game to 60 by using the tick method.

Spaceship class

The class GameObject is perfect for drawing a general object and storing some of the data related to it. Each game object will also implement its own logic, so we can use the GameObject class and add more functions to it.

Creating Spaceship class
class Spaceship(GameObject):
    def __init__(self, position):
        super().__init__(position, load_sprite('spaceship'), Vector2(0))

Let’s walk through the code:

  • We create a class Spaceship which inherits from GameObject
  • We then initialize the class as well as inherited class
  • Note: Spaceship takes position as an argument which is then passed to GameObject
  • Our class doesn’t do much at the moment but we will add functionalities later.
Updating main code:

# Importing Libraries
...
from models import Spaceship

class AsteroidClash:
    # Initializing
    def __init__(self) -> None:
        ...
        self.spaceship = GameObject(
            position=(400, 300),
            sprite=load_sprite('spaceship'),
            velocity=(0, 0)
            )
        self.spaceship = Spaceship((400, 300))

    ...

Let’s walk through the code:

  • We import our new Spaceship class
  • Then we replace the spaceship GameObject initialization with the new Spaceship class
  • We removed our GameObject for now so that we can focus on our spaceship and come to asteroids later.
Rotating the Spaceship

By default, our spaceship is facing upwards. We need to provide our users the ability to rotate the ship. Pygame does provide built-in methods for this, but it isn’t that straightforward. When we rotate an image, the pixels need to be recalculated in the new image. During this process, the information about the original pixels is lost and the image is deformed a bit. Because of this, we will need to store the original sprite in the Spaceship class and have another sprite, which will be updated every time the spaceship rotates.

For this, we can keep the vector representing the direction the spaceship is facing and calculate the angle using that vector. Vector2 class can be rotated quite easily, and the result won’t be deformed as well.

from pygame.transform import rotozoom

UP = Vector2(0, -1)

class Spaceship(GameObject):

    MANEUVERABILITY = 3

    def __init__(self, position):
        self.direction = Vector2(UP)
        super().__init__(position, load_sprite('spaceship'), Vector2(0))

    def rotate(self, clockwise=True):
        sign = 1 if clockwise else -1
        angle = self.MANEUVERABILITY * sign
        self.direction.rotate_ip(angle)

    def draw(self, surface):
        angle = self.direction.angle_to(UP)
        rotated_surface = rotozoom(self.sprite, angle, 1.0)
        rotated_surface_size = Vector2(rotated_surface.get_size())
        blit_position = self.position - rotated_surface_size * 0.5
        surface.blit(rotated_surface, blit_position)

Let’s walk through the code:

  • We create a variable UP with a vector facing upwards for reference.
  • We then define a class variable MANEUVERABILITY and set it to 3, this defines the angle by which the ship will rotate everytime.
  • Added rotate function with an argument clockwise
    • We use the clockwise argument to decide the rotation direction for our ship, sign.
    • We then calculate angle by simply multiplying our MANEUVERABILITY constant with the sign.
    • Then we use rotate_ip method to rotate our direction vector in place.
  • We then overide the draw method in the Spaceship class
    • In order to rotate the image, we need to rotate the surface as well. For that we will import rotozoom method.
    • We calculate the angle using our direction vector and our reference UP vector.
    • We then rotate our surface using the rotozoom method. It takes in 3 arguments, the sprite, angle by which it should be rotated, and the scaling factor.
    • rotozoom returns a new surface with a rotated image. However, in order to keep all contents of the orginal sprite, the new image might have a different size. In that case, Pygame will add some addtional, transparent background. The size of the new image can be significantly different than that of the original image. That’s why we need to recalculate the blit position of rotated surface. Since blit starts in the upper-left corner, we also need to move the blit position by half the size of the image.
Rotated surface with different dimensions
Adding user inputs for rotation
    # Defining function for handling input
    def _handle_input(self) -> None:
        ...

        # Handling Inputs
        is_key_pressed = pygame.key.get_pressed()

        if self.spaceship:
            if is_key_pressed[pygame.K_RIGHT]:
                self.spaceship.rotate(clockwise=True)
            elif is_key_pressed[pygame.K_LEFT]:
                self.spaceship.rotate(clockwise=False)

Let’s walk through the code:

  • We need to rotate our ship for as long as the key is pressed, unlike our quit functionality which occurs only on a KEYDOWN event. In order to do so, we can utilize the key.get_pressed method which returns a dictionary with key constants as keys, and the value is True if the key is pressed or False otherwise.
  • We then add condtions for both RIGHT and LEFT arrow keys.
Acceleraing the spaceship

The idea is to accelerate the ship while the UP key is pressed in the direction in which the ship is facing. When the key is released, we need to keep that acceleration constant to mimic space physics. So to slow it down, we will need to rotate the ship in the opposite direction and then accelerate in that direction. We can simply note down the three important things here:

  • direction: a vector describing where the spaceship is facing
  • velocity: a vector describing where the spaceship moves each frame
  • acceleration: a constant number describing how fast the spaceship can speed up each frame
class Spaceship(GameObject):

    ...
    ACCELERATION = 0.25
   ...
    def accelerate(self):
        self.velocity += self.direction * self.ACCELERATION
   ...

Let’s walk through the code:

  • We add a constant value, ACCELERATION and set it to 0.25. (Feel free to change it as per your liking)
  • We then define a function accelerate, which updates the value of velocity by simply multiplying direction vector with the ACCELERATION constant.
Adding user inputs for acceleration
# Defining function for handling input
    def _handle_input(self) -> None:
        ...
        if self.spaceship:
            ...
            elif is_key_pressed[pygame.K_UP]:
                self.spaceship.accelerate()

Let’s walk through the code:

  • Similar to the RIGHT and LEFT key inputs, we add UP key input and assign the accelerate method to it.
  • If you are wondering how is the spaceship accelerting without us updating the draw or move method, then recall that the move method in our GameObject class was defined as sum of both position and velocity, this causes our spaceship to accelerate.
Wrapping objects around the screeen

Right now, we can see that once our ship can go outside of our screen and that is something we don’t want. We can either have the ship bounce back from the edge or wrap it onto the other side. We will implement that latter for our project.

def wrap_position(position, surface):
    x, y = position
    w, h = surface.get_size()
    return Vector2(x % w, y % h)

Let’s walk through the code:

  • We add another function, wrap_position along side with our load_sprite function. This is resposible to provide us with new vector whenever our current position goes out of bounds.
  • It order to do so, we simply use the mod division on our x and y coordinates of the current position and the screen size.
Updating our main code to reflect the above change
    GAMEOBJECT CLASS:

    def move(self, surface):
        self.position = self.postion + self.velocity
        self.position = wrap_position(self.position + self.velocity, surface)

    ASTEROIDCLASH CLASS:
    
    def _process_game_logic(self) -> None:
        self.spaceship.move()
        self.asteroid.move()
        self.spaceship.move(self.screen)
        self.asteroid.move(self.screen)

Let’s walk through the code:

  • In our GameObject class, we updated our move method so that the final position now is wrapped based on the logic we defined earlier.
  • We also update the _process_game_logic method within our AsteroidClash clash, since now our move method require another argument, screen.

asteroid class

Similar to the Spaceship class, we can create an Asteroid class that will inherit from GameObject class.

Defining our class
class Asteroid(GameObject):

    def __init__(self, position):
        super().__init__(position, load_sprite('asteroid'), (0,0))

Let’s walk through the code:

  • Similar to Spaceship class we defined the Asteroid class.
Updating our main code
class AsteroidClash:
    # Initializing
    def __init__(self) -> None:
        ...
        self.asteroid = [Asteroid((0, 0)) for _ in range(6)]
        ...

    ...

    # Defining function for getting all game objects
    def _get_game_objects(self):
        return [*self.asteroid, self.spaceship]

    # Defining function for processing game logic
    def _process_game_logic(self) -> None:
        for game_object in self._get_game_objects():
            game_object.move(self.screen)
        self.spaceship.move(self.screen)
        self.asteroid.move(self.screen)


    # Defining function for drawing elements on screen
    def _draw(self) -> None:
        self.screen.blit(self.background, (0, 0))
        for game_object in self._get_game_objects():
            game_object.draw(self.screen)
        self.spaceship.draw(self.screen)
        self.asteroid.draw(self.screen)
        ...

Let’s walk through the code:

  • We initialized our AsteroidClash class using the newly created Asteroid class.
    • We need 6 asteroids on the screen at any given time, hence instead of calling them multiple times we used list comprehension.
  • Defining a new method: _get_game_objects
    • Now we have multiple object we need to display on screen, so it makes sense to create a function to get all the objects at once.
  • Updated _process_game_logic and _draw methods:
    • Now we don’t have individual game objects, instead we have a list of game objects. So we updated both our methods to accomodate that change.
Randomizing the postion of asteroids
def get_random_position(surface):
    return Vector2(
        random.randrange(surface.get_width()),
        random.randrange(surface.get_height()),
    )

class AsteroidClash:
    # Initializing
    def __init__(self) -> None:
        ...
        self.asteroid = [Asteroid((0, 0)) for _ in range(6)]
        self.asteroid = [Asteroid(get_random_position(self.screen)) for _ in range(6)]
        ...

Let’s walk through the code:

  • We added a new function, get_random_position, to get random x and y coordinates for our asteroids.
  • Using the new function, we update the initialization within our AsteroidClash class.

The issue with the above code is that when the game starts our spaceship may/may not overlap our asteroid positions which will result in a game over. One solution is to check if the initial position of the asteroids is too close to the ship then generate a new one until a valid position is found.
Let’s update our code accordingly:

# Defining SpaceRocks Class
class AsteroidClash:

    MIN_ASTEROID_DISTANCE = 250

    # Initializing
    def __init__(self) -> None:
        ...
        self.asteroid = [Asteroid(get_random_position(self.screen)) for _ in range(6)]
        self.asteroid = []
        for _ in range(6):
            while True:
                position = get_random_position(self.screen)
                if (
                    position.distance_to(self.spaceship.position) > self.MIN_ASTEROID_DISTANCE
                    ):
                    break
            self.asteroid.append(Asteroid(position))
        ...

Let’s walk through the code:

  • We define a variable, MIN_ASTEROID_DISTANCE, and set it to 250. This is the minimum distance between the spaceship and initial asteroid poistions.
  • We then update the initializaiton of our asteroids.
    • We find a new position for our asteroids and use the distance between our spaceship postion and the randomly generated asteroid position and compare it with the MIN_ASTEROID_DISTANCE. If it’s greater than that then we find the postion of second asteroid, if not we find a new random position which satisfies our condition.
  • We keep appending the asteroids into an emply list.
Moving asteroids

The velocity of our asteroids should be random, which means the direction, as well as the speed of all the asteroids, should be random.

def get_random_velocity(min_speed, max_speed):
    speed = random.randint(min_speed, max_speed)
    angle = random.randrange(0, 360)
    return Vector2(speed, 0).rotate(angle)

class Asteroid(GameObject):

    def __init__(self, position):
        super().__init__(position, load_sprite('asteroid'), (0,0))
        super().__init__(position, load_sprite('asteroid'), get_random_velocity(1, 3))

Let’s walk through the code:

  • Similar to get_random_position, we define a function, get_random_velocity, which returns a Vector2 object wtih random speed and rotation angle.
  • We then update our initialization wtihin our Asteroid class. Using the get_random_velocity, we initialize the inherited class with it and get a random velocity for all of our asteroids.
Collision detection

So now, we have our spaceship and 6 asteroids roaming around in space. We still have not implemented any logic for collision detection. So let’s work on that.

We do have a method that returns true if our objects collide, collides_with method of our GameObject class. So we will be using that for our purpose. The general idea is that if an asteroid collides with our spaceship we can represent by setting our spaceship to None.

    def _handle_input(self) -> None:
        ...
        if self.spaceship:
            if is_key_pressed[pygame.K_RIGHT]:
                self.spaceship.rotate(clockwise=True)
            elif is_key_pressed[pygame.K_LEFT]:
                self.spaceship.rotate(clockwise=False)
            elif is_key_pressed[pygame.K_UP]:
                self.spaceship.accelerate()

    def _process_game_logic(self) -> None:
        ...
        if self.spaceship:
            for asteroid in self.asteroids:
                if asteroid.collides_with(self.spaceship):
                    self.spaceship = None
                    break

    def _get_game_objects(self):
        game_objects = [*self.asteroids]
        if self.spaceship:
            game_objects.append(self.spaceship)

        return game_objects
        return [*self.asteroids, self.spaceship]

Let’s walk through the code:

  • _process_game_logic:
    • We set our spaceship to None if our asteroid collides with the ship.
    • We also check for spaceship at the start of the code, since if the spaceship is destroyed then there is no reason to check any collisions with it.
  • _get_game_objects:
    • We add in logic to only return asteroids if spaceship in None.
  • _handle_input:
    • We only check for RIGHT, LEFT, or UP only if spaceship is not None.

Bullets

Now let’s equip our spaceships with some firepower! As you may have guessed, we will create a new class that will inherit our GameObject class. We will then add various functionalities to our Bullet class.

Defining class
class Bullet(GameObject):

    def __init__(self, position, velocity):
        super().__init__(position, load_sprite('bullet'), velocity)

Let’s walk through the code:

  • Same as Asteroid and Spaceship classes, we create a Bullet class which inherits from GameObject.
  • Unlike Asteroid and Spaceship, our Bullet class will require both position and velocity when called.
Updating our main code
class AsteroidClash:
    ...
    # Initializing
    def __init__(self) -> None:
        ...
        self.bullets = []
        ...


    def _get_game_objects(self):
        game_objects = [*self.asteroids]
        game_objects = [*self.asteroids, *self.bullets]
        ...

Let’s walk through the code:

  • We initialize our AsteroidClash class with an empty list of bullets.
  • We also update our _get_game_objects method and append bullets to the game_objects
Shooting bullets

Now let’s have our spaceship shoot bullets. The only issue is that bullets are stored in the main game object, represented by AsteroidClash class. However, the shooting logic should be determined by the spaceship. It’s the spaceship that knows how to create a new bullet, but it’s the game that stores and later animates the bullet. The Spaceship class needs a way to inform the AsteroidClash class that a bullet has been created and should be tracked.

class Spaceship(GameObject):

    ...
    BULLET_SPEED = 3

    def __init__(self, position, create_bullet_callback):
        self.create_bullet_callback = create_bullet_callback
        ...
    def shoot(self):
        bullet_velocity = self.direction * self.BULLET_SPEED + self.velocity
        bullet = Bullet(self.position, bullet_velocity)
        self.create_bullet_callback(bullet)

class AsteroidClash:
    ...
    # Initializing
    def __init__(self) -> None:
        ...
        self.bullets = []
        self.spaceship = Spaceship((400, 300), self.bullets.append)
        ...

    def _handle_input(self) -> None:
        for event in pygame.event.get():

            # Exiting the game
            if event.type == pygame.QUIT or (
                event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE
                ):
                quit()
            elif (
                self.spaceship and 
                event.type == pygame.KEYDOWN and
                event.key == pygame.K_SPACE
            ):
                self.spaceship.shoot()
        ...

Let’s walk through the code:

  • We added a callback function to the Spaceship class. That function will be provided by the AsteroidClash class when the spaceship is initialized. Every time the spaceship creates a bullet, it will initialize a Bullet object and then call the callback. The callback will add the bullet to the list of all the bullets stored by the game.
  • We then define a variable, BULLET_SPEED, that defines the default velocity of our bullet.
  • We create a method, shoot in the Spaceship class.
    • We calculate the bullet velocity by simply multiplying our BULLET_SPEED with the direction vector (since our bullets will always move in the direction the ship is facing at that moment) and add the velocity vector to it (to keep the momentum of the ship)
  • In our AsteroidClash class
    • We initialize the Spaceship class with our callback which appends the bullets created by our Spaceship to the list of bullets in the AsteroidClash class.
    • Then we add in functionality to capture the key event, in our case it’s the space bar, to shoot our bullets.
Resolving wrapping bullets

Right now, we can shoot the bullets but the bullets follow the same wrapping pattern as our spaceship and asteroids. That is something we don’t want, instead what we want is that as soon as our bullet gets out of our screen bound it should be removed.

class Bullet(GameObject):
    ...
    def move(self, surface):
        self.position = self.position + self.velocity

class AsteroidClash:
    ...

    # Defining function for processing game logic
    def _process_game_logic(self) -> None:
        ...

        for bullet in self.bullets[:]:
            if not self.screen.get_rect().collidepoint(bullet.position):
                self.bullets.remove(bullet)

Let’s walk through the code:

  • We overide the move method for our Bullet class, so now instead of wrapping our bullets keeps on going in the set direction. That is something we don’t want as even if we can’t see those bullets, eventually our game will have to render all the bullets we shoot and also the ones which are not present on the screen, causing the game to drop it’s performance.
  • We avoid the above situation by adding a code in our _process_game_logic method within our AsteroidClash class
    • We create a copy of our self.bullets list and using that we can remove the bullets which are not present within our screen.
Colliding with asteroids

Now let’s give the bullets their power of actually removing asteroids from the screen

class AsteroidClash:

    ...

    # Defining function for processing game logic
    def _process_game_logic(self) -> None:
        ...
        for bullet in self.bullets[:]:
            for asteroid in self.asteroids[:]:
                if asteroid.collides_with(bullet):
                    self.asteroids.remove(asteroid)
                    self.bullets.remove(bullet)
                    break

Let’s walk through the code:

  • Using a similar logic as of removing bullets going outside of the screen, we can now remove asteroids as well as the bullet which collides with each other.
Breaking asteroids into smaller ones

Let’s make the game a little bit challenging by breaking the asteroids into 2 smaller asteroids when hit by a bullet.

class Asteroid(GameObject):

    def __init__(self, position, create_asteroid_callback, size=3):
        self.create_asteroid_callback = create_asteroid_callback
        self.size = size
        size_to_scale = {
            3: 1,
            2: 0.5,
            1: 0.25
        }
        scale = size_to_scale[size]
        sprite = rotozoom(load_sprite('asteroid'), 0, scale)
        super().__init__(position, sprite, get_random_velocity(1, 3))
    
    def split(self):
        if self.size > 1:
            for _ in range(2):
                asteroid = Asteroid(self.position, self.create_asteroid_callback, self.size-1)
                self.create_asteroid_callback(asteroid)

class AsteroidClash:

    ...

    # Initializing
    def __init__(self) -> None:
        ...
        for _ in range(6):
            while True:
                position = get_random_position(self.screen)
                if (
                    position.distance_to(self.spaceship.position) > self.MIN_ASTEROID_DISTANCE
                    ):
                    break
            self.asteroids.append(Asteroid(position, self.asteroids.append))
        ...

    # Defining function for processing game logic
    def _process_game_logic(self) -> None:
        ...

        for bullet in self.bullets[:]:
            for asteroid in self.asteroids[:]:
                if asteroid.collides_with(bullet):
                    self.asteroids.remove(asteroid)
                    self.bullets.remove(bullet)
                    asteroid.split()
                    break

Let’s walk through the code:

  • In our Asteroid class:
    • We added size as an argument in our Asteroid class, which is set to 3 by default.
    • We also provide the key value pair with the size and scales for the asteroid.
    • We then use rotozoom method to scale our sprites depending on the size.
    • Now, we need our Asteroid to create new asteroids. The situation is similar to the spaceship and bullets, so we can use a similar solution: a callback method.
    • We initialize our Asteroid class with a callback method: create_asteroid_callback, which can be used in our AsteroidClash class
    • We also add a method: split, which creates new asteroid if the size of asteroid is greater than 1
  • In our AsteroidClash class:
    • We update our Asteroid initialization using the newly created call back method.
    • We also call the split method whenever the bullet collides with an asteroid.

Adding sounds

Let’s now add a sound when our ship shoots a bullet.

def load_sound(name: str) -> object:
    path = f"assets/sounds/{name}.wav"
    return Sound(path)

class Spaceship(GameObject):

    ...

    def __init__(self, position, create_bullet_callback):
        ...
        self.laser_sound = load_sound('laser')
        ...

    ...

    def shoot(self):
        ...
        self.laser_sound.play()

Let’s walk through the code:

  • We add a new function, load_sound which is responsible to load a sound file as a Pygame object.
  • We then update our Spaceship class:
    • Initialize the laser sound.
    • In our shoot method, we call function to play the loaded sound.

Ending the game

Now we need to inform the user when the game ends. If all the asteroids are destroyed then it should say YOU WON! else if the ship is destroyed it should say YOU LOST!

Pygame doesn’t have any advanced tools for drawing text. Rendered text is represented by a surface with a transparent background. You can manipulate it similar to any surface using blit

def print_text(surface, text, font, color=Color('tomato')):
    text_surface = font.render(text, True, color)
    rect = text_surface.get_rect()
    rect.center = Vector2(surface.get_size()) / 2
    surface.blit(text_surface, rect)

class AsteroidClash:
    ...
    # Initializing
    def __init__(self) -> None:
        ...
        self.font = pygame.font.Font(None, 64)
        self.message = ''

    
    ...

    # Defining function for processing game logic
    def _process_game_logic(self) -> None:
        ...

        if self.spaceship:
            for asteroid in self.asteroids:
                if asteroid.collides_with(self.spaceship):
                    self.spaceship = None
                    self.message = 'YOU LOST!'
                    break
        ...
   
        if not self.asteroids and self.spaceship:
            self.message = 'YOU WON!'

    ...

    # Defining function for drawing elements on screen
    def _draw(self) -> None:
        ...
        if self.message:
            print_text(self.screen, self.message, self.font)
        ...

Let’s walk through the code:

  • We define a new function, print_text, which loads our text and blits it onto the surface
  • In AsteroidClash class:
    • We initialize the font and message.
    • We then update the _process_game_logic method to update the message depending on the situation.
    • Then we update the _draw method to draw the text on the screen.

conclusion

There are some more functionalities that I may/may not add in the future like keeping a track of scores, restarting the game, etc.

You can find the code on my Github repository here.


Spread the love

Leave a Reply

Your email address will not be published. Required fields are marked *