Pygame zero: Lander by Daniel Pope

The Game

To see what can be done with Pygame zero, let’s take a look at an example of a classic game by the eighties ported in Python by Daniel Pope.

I made some little changes in this game that is one of the example of the use of Pygame zero. Take a look at the code and the game itself in the video.

The original code is in the repository of Pygame zero (read the last article about Pygame zero).

import pgzrun
import random
import math

"""
Pi Lander
 * A basic Lunar Lander style game in Pygame Zero
 * Run with 'pgzrun pi_lander.py', control with the LEFT, RIGHT and UP arrow keys
 * Author Tim Martin: www.Tim-Martin.co.uk
 * Licence: Creative Commons Attribution-ShareAlike 4.0 International
 * http://creativecommons.org/licenses/by-sa/4.0/
"""

WIDTH = 1000  # Screen width
HEIGHT = 600  # Screen height

sound1 = tone.create('A3', 0.01)


class LandingSpotClass:
    """ Each instance defines a landing spot by where it starts, how big it is and how many points it's worth """
    landing_spot_sizes = ["small", "medium", "large"]

    def __init__(self, starting_step):
        self.starting = starting_step
        random_size = random.choice(LandingSpotClass.landing_spot_sizes)  # And randomly choose size
        if random_size == "small":
            self.size = 4
            self.bonus = 8
        elif random_size == "medium":
            self.size = 10
            self.bonus = 4
        else:  # Large
            self.size = 20
            self.bonus = 2

    def get_within_landing_spot(self, step):
        if (step >= self.starting) and (step < self.starting + self.size):
            return True
        return False


class LandscapeClass:
    """ Stores and generates the landscape, landing spots and star field """
    step_size = 3  # Landscape is broken down into steps. Define number of pixels on the x axis per step.
    world_steps = int(WIDTH / step_size)  # How many steps can we fit horizontally on the screen
    small_height_change = 3  # Controls how bumpy the landscape is
    large_height_change = 10  # Controls how steep the landscape is
    features = ["mountain", "valley", "field"]  # What features to generate
    n_stars = 30  # How many stars to put in the background
    n_spots = 4  # Max number of landing spots to generate

    def __init__(self):
        self.world_height = []  # Holds the height of the landscape at each step
        self.star_locations = []  # Holds the x and y location of the stars
        self.landing_spots = []  # Holds the landing spots

    def get_within_landing_spot(self, step):
        """ Calculate if a given step is within any of the landing spots """
        for spot in self.landing_spots:
            if spot.get_within_landing_spot(step) == True:
                return True
        return False

    def get_landing_spot_bonus(self, step):
        for spot in self.landing_spots:
            if spot.get_within_landing_spot(step) == True:
                return spot.bonus
        return 0

    def reset(self):
        """ Generates a new landscape """
        # First: Choose which steps of the landscape will be landing spots
        del self.landing_spots[:]  # Delete any previous LandingSpotClass objects
        next_spot_start = 0
        # Move from left to right adding new landing spots until either
        # n_spots spots have been placed or we run out of space in the world
        while len(self.landing_spots) < LandscapeClass.n_spots and next_spot_start < LandscapeClass.world_steps:
            next_spot_start += random.randint(10, 50)  # Randomly choose location to start landing spot
            new_landing_spot = LandingSpotClass(next_spot_start)  # Make a new landing object at this spot
            self.landing_spots.append(new_landing_spot)  # And store it in our list
            next_spot_start += new_landing_spot.size  # Then take into account its size before choosing the next
        # Second: Randomise the world map
        del self.world_height[:]  # Clear any previous world height data
        feature_steps = 0  # Keep track of how many steps we are into a feature
        self.world_height.append(random.randint(300, 500))  # Start the landscape between 300 and 500 pixels down
        for step in range(1, LandscapeClass.world_steps):
            # If feature_step is zero, we need to choose a new feature and how long it goes on for
            if feature_steps == 0:
                feature_steps = random.randint(25, 75)
                current_feature = random.choice(LandscapeClass.features)
            # Generate the world by setting the range of random numbers, must be flat if in a landing spot
            if self.get_within_landing_spot(step) == True:
                max_up = 0  # Flat
                max_down = 0  # Flat
            elif current_feature == "mountain":
                max_up = LandscapeClass.small_height_change
                max_down = -LandscapeClass.large_height_change
            elif current_feature == "valley":
                max_up = LandscapeClass.large_height_change
                max_down = -LandscapeClass.small_height_change
            elif current_feature == "field":
                max_up = LandscapeClass.small_height_change
                max_down = -LandscapeClass.small_height_change
            # Generate the next piece of the landscape
            current_height = self.world_height[step - 1]
            next_height = current_height + random.randint(max_down, max_up)
            self.world_height.append(next_height)
            feature_steps -= 1
            # Stop mountains getting too high, or valleys too low
            if next_height > 570:
                current_feature = "mountain"  # Too low! Force a mountain
            elif next_height < 200:
                current_feature = "valley"  # Too high! Force a valley
        # Third: Randomise the star field
        del self.star_locations[:]
        for star in range(0, LandscapeClass.n_stars):
            star_step = random.randint(0, LandscapeClass.world_steps - 1)
            star_x = star_step * LandscapeClass.step_size
            star_y = random.randint(0, self.world_height[star_step])  # Keep the stars above the landscape
            self.star_locations.append((star_x, star_y))


class ShipClass:
    """ Holds the state of the player's ship and handles movement """
    max_fuel = 1000  # How much fuel the player starts with
    booster_power = 0.05  # Power of the ship's thrusters
    rotate_speed = 10  # How fast the ship rotates in degrees per frame
    gravity = [0., 0.01]  # Strength of gravity in the x and y directions

    def __init__(self):
        """ Create the variables which will describe the players ship """
        self.angle = 0  # The angle the ship is facing 0 - 360 degrees
        self.altitude = 0  # The number of pixels the ship is above the ground
        self.booster = False  # True if the player is firing their booster
        self.fuel = 0  # Amount of fuel remaining
        self.position = [0, 0]  # The x and y coordinates of the players ship
        self.velocity = [0, 0]  # The x and y velocity of the players ship
        self.acceleration = [0, 0]  # The x and y acceleration of the players ship

    def reset(self):
        """ Set the ships position, velocity and angle to their new-game values """
        self.position = [750., 100.]  # Always start at the same spot
        self.velocity = [-random.random(), random.random()]  # But with some initial speed
        self.acceleration = [0., 0.]  # No initial acceleration (except gravity of course)
        self.angle = random.randint(0, 360)  # And pointing in a random direction
        self.fuel = ShipClass.max_fuel  # Fill up fuel tanks

    def rotate(self, direction):
        """ Rotate the players ship and keep the angle within the range 0 - 360 degrees """
        if direction == "left":
            self.angle -= ShipClass.rotate_speed
        elif direction == "right":
            self.angle += ShipClass.rotate_speed
        if self.angle > 360:  # Remember than adding or subtracting 360 degrees does not change the angle
            self.angle -= 360
        elif self.angle < 0:
            self.angle += 360

    def booster_on(self):
        """ When booster is firing we accelerate in the opposite direction, 180 degrees, from the way the ship is facing """
        sound1.play()
        self.booster = True
        self.acceleration[0] = ShipClass.booster_power * math.sin(math.radians(self.angle + 180))
        self.acceleration[1] = ShipClass.booster_power * math.cos(math.radians(self.angle + 180))
        self.fuel -= 2

    def booster_off(self):
        """ When the booster is not firing we do not accelerate """
        self.booster = False
        self.acceleration[0] = 0.
        self.acceleration[1] = 0.

    def update_physics(self):
        """ Update ship physics in X and Y, apply acceleration (and gravity) to the velocity and velocity to the position """
        for axis in range(0, 2):
            self.velocity[axis] += ShipClass.gravity[axis]
            self.velocity[axis] += self.acceleration[axis]
            self.position[axis] += self.velocity[axis]
        # Update player altitude. Note that (LanscapeClass.step_size * 3) is the length of the ship's legs
        ship_step = int(self.position[0] / LandscapeClass.step_size)
        if ship_step < LandscapeClass.world_steps:
            self.altitude = game.landscape.world_height[ship_step] - self.position[1] - (LandscapeClass.step_size * 3)

    def get_out_of_bounds(self):
        """ Check if the player has hit the ground or gone off the sides """
        if self.altitude <= 0 or self.position[0] <= 0 or self.position[0] >= WIDTH:
            return True
        return False


class GameClass:
    """ Holds main game data, including the ship and landscape objects. Checks for game-over """

    def __init__(self):
        self.time = 0.  # Time spent playing in seconds
        self.score = 0  # Player's score
        self.game_speed = 30  # How fast the game should run in frames per second
        self.time_elapsed = 0.  # Time since the last frame was changed
        self.blink = True  # True if blinking text is to be shown
        self.n_frames = 0  # Number of frames processed
        self.game_on = False  # True if the game is being played
        self.game_message = "PI   LANDER\nPRESS SPACE TO START"  # Start of game message
        self.ship = ShipClass()  # Make a object of the ShipClass type
        self.landscape = LandscapeClass()
        self.reset()  # Start the game with a fresh landscape and ship

    def reset(self):
        self.time = 0.
        self.ship.reset()
        self.landscape.reset()

    def check_game_over(self):
        """ Check if the game is over and update the game state if so """
        if self.ship.get_out_of_bounds() == False:
            return  # Game is not over
        self.game_on = False  # Game has finished. But did we win or loose?
        # Check if the player looses. This is if the ship's angle is > 20 degrees
        # the ship is not over a landing site, is moving too fast or is off the side of the screen
        ship_step = int(self.ship.position[0] / LandscapeClass.step_size)
        if self.ship.position[0] <= 0 \
           or self.ship.position[0] >= WIDTH \
           or self.landscape.get_within_landing_spot(ship_step) == False \
           or abs(self.ship.velocity[0]) > .5 \
           or abs(self.ship.velocity[1]) > .5 \
           or (self.ship.angle > 20 and self.ship.angle < 340):
            self.game_message = "YOU JUST DESTROYED A 100 MEGABUCK LANDER\n\nLOOSE 250 POINTS\n\nPRESS SPACE TO RESTART"
            self.score -= 250
        else:  # If the player has won! Update their score based on the amount of remaining fuel and the landing bonus
            points = self.ship.fuel / 10
            points *= self.landscape.get_landing_spot_bonus(ship_step)
            self.score += points
            self.game_message = "CONGRATULATIONS\nTHAT WAS A GREAT LANDING!\n\n" + str(round(points)) + " POINTS\n\nPRESS SPACE TO RESTART"


# Create the game object
game = GameClass()


def draw():
    """
    Draw the game window on the screen in the following order:
    start message, mountain range, bonus points, stars, statistics, player's ship
    """
    screen.fill("darkblue")
    size = LandscapeClass.step_size

    if game.game_on == False:
        screen.draw.text(game.game_message, center=(WIDTH / 2, HEIGHT / 5), align="center")

    # Get the x and y coordinates of each step of the landscape and draw it as a straight line
    for step in range(0, game.landscape.world_steps - 1):
        x_start = size * step
        x_end = size * (step + 1)
        y_start = game.landscape.world_height[step]
        y_end = game.landscape.world_height[step + 1]
        screen.draw.line((x_start, y_start), (x_end, y_end), "white")
        # Every second we flash the landing spots with a thicker line by drawing a narrow rectangle
        if (game.blink == True or game.game_on == False) and game.landscape.get_within_landing_spot(step) == True:
            screen.draw.filled_rect(Rect(x_start - size, y_start - 3, size, 4), "gold")

    # Draw the bonus point notifier
    if game.blink == True or game.game_on == False:
        for spot in game.landscape.landing_spots:
            x_text = spot.starting * size
            y_text = game.landscape.world_height[spot.starting] + 10  # The extra 10 pixels puts the text below the landscape
            screen.draw.text(str(spot.bonus) + "x", (x_text, y_text), color="white")

    # Draw the stars
    for star in game.landscape.star_locations:
        screen.draw.line(star, star, "white")

    # Draw the stats
    screen.draw.text("SCORE: " + str(round(game.score)), (10, 10), color="white", background="black")
    screen.draw.text("TIME: " + str(round(game.time)), (10, 25), color="white", background="black")
    screen.draw.text("FUEL: " + str(game.ship.fuel), (10, 40), color="white", background="black")
    screen.draw.text("ALTITUDE: " + str(round(game.ship.altitude)), (WIDTH - 230, 10), color="white", background="black")
    screen.draw.text("HORIZONTAL SPEED: {0:.2f}".format(game.ship.velocity[0]), (WIDTH - 230, 25), color="white", background="black")
    screen.draw.text("VERTICAL SPEED: {0:.2f}".format(-game.ship.velocity[1]), (WIDTH - 230, 40), color="white", background="black")

    screen.draw.filled_circle(game.ship.position, size * 2, "yellow")  # Draw the player
    screen.draw.circle(game.ship.position, size * 2, "orange")  # Draw the player
    screen.draw.filled_circle(game.ship.position, size * 1, "orange")
    # Use sin and cosine functions to draw the ship legs and booster at the correct angle
    # Requires the values in radians (0 to 2*pi) rather than in degrees (0 to 360)
    sin_angle = math.sin(math.radians(game.ship.angle - 45))  # Legs are drawn 45 degrees either side of the ship's angle
    cos_angle = math.cos(math.radians(game.ship.angle - 45))
    screen.draw.line(game.ship.position, (game.ship.position[0] + (sin_angle * size * 4), game.ship.position[1] + (cos_angle * size * 4)), "red")
    sin_angle = math.sin(math.radians(game.ship.angle + 45))
    cos_angle = math.cos(math.radians(game.ship.angle + 45))
    screen.draw.line(game.ship.position, (game.ship.position[0] + (sin_angle * size * 4), game.ship.position[1] + (cos_angle * size * 4)), "red")
    if game.ship.booster == True:
        sin_angle = math.sin(math.radians(game.ship.angle))  # Booster is drawn at the same angle as the ship, just under it
        cos_angle = math.cos(math.radians(game.ship.angle))
        screen.draw.filled_circle((game.ship.position[0] + (sin_angle * size * 3), game.ship.position[1] + (cos_angle * size * 3)), size, "orange")


def update(detlatime):
    """ Updates the game physics 30 times every second  """
    game.time_elapsed += detlatime
    if game.time_elapsed < 1. / game.game_speed:
        return  # A 30th of a second has not passed yet
    game.time_elapsed -= 1. / game.game_speed

    # New frame - do all the simulations
    game.n_frames += 1
    if game.n_frames % game.game_speed == 0:  # If n_frames is an exact multiple of the game FPS: so once per second
        game.blink = not game.blink  # Invert blink so True becomes False or False becomes True

    # Start the game if the player presses space when the game is not on
    if keyboard.space and game.game_on == False:
        game.game_on = True
        game.reset()
    elif game.game_on == False:
        return

    # If the game is on, update the movement and the physics
    if keyboard.left:  # Change space ship rotation
        game.ship.rotate("left")
    elif keyboard.right:
        game.ship.rotate("right")

    if keyboard.up and game.ship.fuel > 0:  # Fire boosters if the player has enough fuel
        game.ship.booster_on()
    else:
        game.ship.booster_off()

    game.time += detlatime
    game.ship.update_physics()
    game.check_game_over()


pgzrun.go()

Videogame

Video with music and new sounds

Published by pythonprogramming

Started with basic on the spectrum, loved javascript in the 90ies and python in the 2000, now I am back with python, still making some javascript stuff when needed.