Cavern

The game screen appears here if your browser supports the Canvas API.

Attribution

Code the Classics – Volume 1, Chapter 2 Action Platformer, page 061.

Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.

Original Python code


from random import choice, randint, random, shuffle
from enum import Enum
import pygame, pgzero, pgzrun, sys

# Check Python version number. sys.version_info gives version as a tuple, e.g. if (3,7,2,'final',0) for version 3.7.2.
# Unlike many languages, Python can compare two tuples in the same way that you can compare numbers.
if sys.version_info < (3,5):
    print("This game requires at least version 3.5 of Python. Please download it from www.python.org")
    sys.exit()

# Check Pygame Zero version. This is a bit trickier because Pygame Zero only lets us get its version number as a string.
# So we have to split the string into a list, using '.' as the character to split on. We convert each element of the
# version number into an integer - but only if the string contains numbers and nothing else, because it's possible for
# a component of the version to contain letters as well as numbers (e.g. '2.0.dev0')
# We're using a Python feature called list comprehension - this is explained in the Bubble Bobble/Cavern chapter.
pgzero_version = [int(s) if s.isnumeric() else s for s in pgzero.__version__.split('.')]
if pgzero_version < [1,2]:
    print("This game requires at least version 1.2 of Pygame Zero. You have version {0}. Please upgrade using the command 'pip3 install --upgrade pgzero'".format(pgzero.__version__))
    sys.exit()

# Set up constants
WIDTH = 800
HEIGHT = 480
TITLE = "Cavern"

NUM_ROWS = 18
NUM_COLUMNS = 28

LEVEL_X_OFFSET = 50
GRID_BLOCK_SIZE = 25

ANCHOR_CENTRE = ("center", "center")
ANCHOR_CENTRE_BOTTOM = ("center", "bottom")

LEVELS = [ ["XXXXX     XXXXXXXX     XXXXX",
            "","","","",
            "   XXXXXXX        XXXXXXX   ",
            "","","",
            "   XXXXXXXXXXXXXXXXXXXXXX   ",
            "","","",
            "XXXXXXXXX          XXXXXXXXX",
            "","",""],

           ["XXXX    XXXXXXXXXXXX    XXXX",
            "","","","",
            "    XXXXXXXXXXXXXXXXXXXX    ",
            "","","",
            "XXXXXX                XXXXXX",
            "      X              X      ",
            "       X            X       ",
            "        X          X        ",
            "         X        X         ",
            "","",""],

           ["XXXX    XXXX    XXXX    XXXX",
            "","","","",
            "  XXXXXXXX        XXXXXXXX  ",
            "","","",
            "XXXX      XXXXXXXX      XXXX",
            "","","",
            "    XXXXXX        XXXXXX    ",
            "","",""]]

def block(x,y):
    # Is there a level grid block at these coordinates?
    grid_x = (x - LEVEL_X_OFFSET) // GRID_BLOCK_SIZE
    grid_y = y // GRID_BLOCK_SIZE
    if grid_y > 0 and grid_y < NUM_ROWS:
        row = game.grid[grid_y]
        return grid_x >= 0 and grid_x < NUM_COLUMNS and len(row) > 0 and row[grid_x] != " "
    else:
        return False

def sign(x):
    # Returns -1 or 1 depending on whether number is positive or negative
    return -1 if x < 0 else 1

class CollideActor(Actor):
    def __init__(self, pos, anchor=ANCHOR_CENTRE):
        super().__init__("blank", pos, anchor)

    def move(self, dx, dy, speed):
        new_x, new_y = int(self.x), int(self.y)

        # Movement is done 1 pixel at a time, which ensures we don't get embedded into a wall we're moving towards
        for i in range(speed):
            new_x, new_y = new_x + dx, new_y + dy

            if new_x < 70 or new_x > 730:
                # Collided with edge of level
                return True

            # Normally you don't need brackets surrounding the condition for an if statement (unlike many other
            # languages), but in the case where the condition is split into multiple lines, using brackets removes
            # the need to use the \ symbol at the end of each line.
            # The code below checks to see if we're position we're trying to move into overlaps with a block. We only
            # need to check the direction we're actually moving in. So first, we check to see if we're moving down
            # (dy > 0). If that's the case, we then check to see if the proposed new y coordinate is a multiple of
            # GRID_BLOCK_SIZE. If it is, that means we're directly on top of a place where a block might be. If that's
            # also true, we then check to see if there is actually a block at the given position. If there's a block
            # there, we return True and don't update the object to the new position.
            # For movement to the right, it's the same except we check to ensure that the new x coordinate is a multiple
            # of GRID_BLOCK_SIZE. For moving left, we check to see if the new x coordinate is the last (right-most)
            # pixel of a grid block.
            # Note that we don't check for collisions when the player is moving up.
            if ((dy > 0 and new_y % GRID_BLOCK_SIZE == 0 or
                 dx > 0 and new_x % GRID_BLOCK_SIZE == 0 or
                 dx < 0 and new_x % GRID_BLOCK_SIZE == GRID_BLOCK_SIZE-1)
                and block(new_x, new_y)):
                    return True

            # We only update the object's position if there wasn't a block there.
            self.pos = new_x, new_y

        # Didn't collide with block or edge of level
        return False

class Orb(CollideActor):
    MAX_TIMER = 250

    def __init__(self, pos, dir_x):
        super().__init__(pos)

        # Orbs are initially blown horizontally, then start floating upwards
        self.direction_x = dir_x
        self.floating = False
        self.trapped_enemy_type = None      # Number representing which type of enemy is trapped in this bubble
        self.timer = -1
        self.blown_frames = 6  # Number of frames during which we will be pushed horizontally

    def hit_test(self, bolt):
        # Check for collision with a bolt
        collided = self.collidepoint(bolt.pos)
        if collided:
            self.timer = Orb.MAX_TIMER - 1
        return collided

    def update(self):
        self.timer += 1

        if self.floating:
            # Float upwards
            self.move(0, -1, randint(1, 2))
        else:
            # Move horizontally
            if self.move(self.direction_x, 0, 4):
                # If we hit a block, start floating
                self.floating = True

        if self.timer == self.blown_frames:
            self.floating = True
        elif self.timer >= Orb.MAX_TIMER or self.y <= -40:
            # Pop if our lifetime has run out or if we have gone off the top of the screen
            game.pops.append(Pop(self.pos, 1))
            if self.trapped_enemy_type != None:
                # trapped_enemy_type is either zero or one. A value of one means there's a chance of creating a
                # powerup such as an extra life or extra health
                game.fruits.append(Fruit(self.pos, self.trapped_enemy_type))
            game.play_sound("pop", 4)

        if self.timer < 9:
            # Orb grows to full size over the course of 9 frames - the animation frame updating every 3 frames
            self.image = "orb" + str(self.timer // 3)
        else:
            if self.trapped_enemy_type != None:
                self.image = "trap" + str(self.trapped_enemy_type) + str((self.timer // 4) % 8)
            else:
                self.image = "orb" + str(3 + (((self.timer - 9) // 8) % 4))

class Bolt(CollideActor):
    SPEED = 7

    def __init__(self, pos, dir_x):
        super().__init__(pos)

        self.direction_x = dir_x
        self.active = True

    def update(self):
        # Move horizontally and check to see if we've collided with a block
        if self.move(self.direction_x, 0, Bolt.SPEED):
            # Collided
            self.active = False
        else:
            # We didn't collide with a block - check to see if we collided with an orb or the player
            for obj in game.orbs + [game.player]:
                if obj and obj.hit_test(self):
                    self.active = False
                    break

        direction_idx = "1" if self.direction_x > 0 else "0"
        anim_frame = str((game.timer // 4) % 2)
        self.image = "bolt" + direction_idx + anim_frame

class Pop(Actor):
    def __init__(self, pos, type):
        super().__init__("blank", pos)

        self.type = type
        self.timer = -1

    def update(self):
        self.timer += 1
        self.image = "pop" + str(self.type) + str(self.timer // 2)

class GravityActor(CollideActor):
    MAX_FALL_SPEED = 10

    def __init__(self, pos):
        super().__init__(pos, ANCHOR_CENTRE_BOTTOM)

        self.vel_y = 0
        self.landed = False

    def update(self, detect=True):
        # Apply gravity, without going over the maximum fall speed
        self.vel_y = min(self.vel_y + 1, GravityActor.MAX_FALL_SPEED)

        # The detect parameter indicates whether we should check for collisions with blocks as we fall. Normally we
        # want this to be the case - hence why this parameter is optional, and is True by default. If the player is
        # in the process of losing a life, however, we want them to just fall out of the level, so False is passed
        # in this case.
        if detect:
            # Move vertically in the appropriate direction, at the appropriate speed
            if self.move(0, sign(self.vel_y), abs(self.vel_y)):
                # If move returned True, we must have landed on a block.
                # Note that move doesn't apply any collision detection when the player is moving up - only down
                self.vel_y = 0
                self.landed = True

            if self.top >= HEIGHT:
                # Fallen off bottom - reappear at top
                self.y = 1
        else:
            # Collision detection disabled - just update the Y coordinate without any further checks
            self.y += self.vel_y

# Class for pickups including fruit, extra health and extra life
class Fruit(GravityActor):
    APPLE = 0
    RASPBERRY = 1
    LEMON = 2
    EXTRA_HEALTH = 3
    EXTRA_LIFE = 4

    def __init__(self, pos, trapped_enemy_type=0):
        super().__init__(pos)

        # Choose which type of fruit we're going to be.
        if trapped_enemy_type == Robot.TYPE_NORMAL:
            self.type = choice([Fruit.APPLE, Fruit.RASPBERRY, Fruit.LEMON])
        else:
            # If trapped_enemy_type is 1, it means this fruit came from bursting an orb containing the more dangerous type
            # of enemy. In this case there is a chance of getting an extra help or extra life power up
            # We create a list containing the possible types of fruit, in proportions based on the probability we want
            # each type of fruit to be chosen
            types = 10 * [Fruit.APPLE, Fruit.RASPBERRY, Fruit.LEMON]    # Each of these appear in the list 10 times
            types += 9 * [Fruit.EXTRA_HEALTH]                           # This appears 9 times
            types += [Fruit.EXTRA_LIFE]                                 # This only appears once
            self.type = choice(types)                                   # Randomly choose one from the list

        self.time_to_live = 500 # Counts down to zero

    def update(self):
        super().update()

        # Does the player exist, and are they colliding with us?
        if game.player and game.player.collidepoint(self.center):
            if self.type == Fruit.EXTRA_HEALTH:
                game.player.health = min(3, game.player.health + 1)
                game.play_sound("bonus")
            elif self.type == Fruit.EXTRA_LIFE:
                game.player.lives += 1
                game.play_sound("bonus")
            else:
                game.player.score += (self.type + 1) * 100
                game.play_sound("score")

            self.time_to_live = 0   # Disappear
        else:
            self.time_to_live -= 1

        if self.time_to_live <= 0:
            # Create 'pop' animation
            game.pops.append(Pop((self.x, self.y - 27), 0))

        anim_frame = str([0, 1, 2, 1][(game.timer // 6) % 4])
        self.image = "fruit" + str(self.type) + anim_frame

class Player(GravityActor):
    def __init__(self):
        # Call constructor of parent class. Initial pos is 0,0 but reset is always called straight afterwards which
        # will set the actual starting position.
        super().__init__((0, 0))

        self.lives = 2
        self.score = 0

    def reset(self):
        self.pos = (WIDTH / 2, 100)
        self.vel_y = 0
        self.direction_x = 1            # -1 = left, 1 = right
        self.fire_timer = 0
        self.hurt_timer = 100   # Invulnerable for this many frames
        self.health = 3
        self.blowing_orb = None

    def hit_test(self, other):
        # Check for collision between player and bolt - called from Bolt.update. Also check hurt_timer - after being hurt,
        # there is a period during which the player cannot be hurt again
        if self.collidepoint(other.pos) and self.hurt_timer < 0:
            # Player loses 1 health, is knocked in the direction the bolt had been moving, and can't be hurt again
            # for a while
            self.hurt_timer = 200
            self.health -= 1
            self.vel_y = -12
            self.landed = False
            self.direction_x = other.direction_x
            if self.health > 0:
                game.play_sound("ouch", 4)
            else:
                game.play_sound("die")
            return True
        else:
            return False

    def update(self):
        # Call GravityActor.update - parameter is whether we want to perform collision detection as we fall. If health
        # is zero, we want the player to just fall out of the level
        super().update(self.health > 0)

        self.fire_timer -= 1
        self.hurt_timer -= 1

        if self.landed:
            # Hurt timer starts at 200, but drops to 100 once the player has landed
            self.hurt_timer = min(self.hurt_timer, 100)

        if self.hurt_timer > 100:
            # We've just been hurt. Either carry out the sideways motion from being knocked by a bolt, or if health is
            # zero, we're dropping out of the level, so check for our sprite reaching a certain Y coordinate before
            # reducing our lives count and responding the player. We check for the Y coordinate being the screen height
            # plus 50%, rather than simply the screen height, because the former effectively gives us a short delay
            # before the player respawns.
            if self.health > 0:
                self.move(self.direction_x, 0, 4)
            else:
                if self.top >= HEIGHT*1.5:
                    self.lives -= 1
                    self.reset()
        else:
            # We're not hurt
            # Get keyboard input. dx represents the direction the player is facing
            dx = 0
            if keyboard.left:
                dx = -1
            elif keyboard.right:
                dx = 1

            if dx != 0:
                self.direction_x = dx

                # If we haven't just fired an orb, carry out horizontal movement
                if self.fire_timer < 10:
                    self.move(dx, 0, 4)

            # Do we need to create a new orb? Space must have been pressed and released, the minimum time between
            # orbs must have passed, and there is a limit of 5 orbs.
            if space_pressed() and self.fire_timer <= 0 and len(game.orbs) < 5:
                # x position will be 38 pixels in front of the player position, while ensuring it is within the
                # bounds of the level
                x = min(730, max(70, self.x + self.direction_x * 38))
                y = self.y - 35
                self.blowing_orb = Orb((x,y), self.direction_x)
                game.orbs.append(self.blowing_orb)
                game.play_sound("blow", 4)
                self.fire_timer = 20

            if keyboard.up and self.vel_y == 0 and self.landed:
                # Jump
                self.vel_y = -16
                self.landed = False
                game.play_sound("jump")

        # Holding down space causes the current orb (if there is one) to be blown further
        if keyboard.space:
            if self.blowing_orb:
                # Increase blown distance up to a maximum of 120
                self.blowing_orb.blown_frames += 4
                if self.blowing_orb.blown_frames >= 120:
                    # Can't be blown any further
                    self.blowing_orb = None
        else:
            # If we let go of space, we relinquish control over the current orb - it can't be blown any further
            self.blowing_orb = None

        # Set sprite image. If we're currently hurt, the sprite will flash on and off on alternate frames.
        self.image = "blank"
        if self.hurt_timer <= 0 or self.hurt_timer % 2 == 1:
            dir_index = "1" if self.direction_x > 0 else "0"
            if self.hurt_timer > 100:
                if self.health > 0:
                    self.image = "recoil" + dir_index
                else:
                    self.image = "fall" + str((game.timer // 4) % 2)
            elif self.fire_timer > 0:
                self.image = "blow" + dir_index
            elif dx == 0:
                self.image = "still"
            else:
                self.image = "run" + dir_index + str((game.timer // 8) % 4)

class Robot(GravityActor):
    TYPE_NORMAL = 0
    TYPE_AGGRESSIVE = 1

    def __init__(self, pos, type):
        super().__init__(pos)

        self.type = type

        self.speed = randint(1, 3)
        self.direction_x = 1
        self.alive = True

        self.change_dir_timer = 0
        self.fire_timer = 100

    def update(self):
        super().update()

        self.change_dir_timer -= 1
        self.fire_timer += 1

        # Move in current direction - turn around if we hit a wall
        if self.move(self.direction_x, 0, self.speed):
            self.change_dir_timer = 0

        if self.change_dir_timer <= 0:
            # Randomly choose a direction to move in
            # If there's a player, there's a two thirds chance that we'll move towards them
            directions = [-1, 1]
            if game.player:
                directions.append(sign(game.player.x - self.x))
            self.direction_x = choice(directions)
            self.change_dir_timer = randint(100, 250)

        # The more powerful type of robot can deliberately shoot at orbs - turning to face them if necessary
        if self.type == Robot.TYPE_AGGRESSIVE and self.fire_timer >= 24:
            # Go through all orbs to see if any can be shot at
            for orb in game.orbs:
                # The orb must be at our height, and within 200 pixels on the x axis
                if orb.y >= self.top and orb.y < self.bottom and abs(orb.x - self.x) < 200:
                    self.direction_x = sign(orb.x - self.x)
                    self.fire_timer = 0
                    break

        # Check to see if we can fire at player
        if self.fire_timer >= 12:
            # Random chance of firing each frame. Likelihood increases 10 times if player is at the same height as us
            fire_probability = game.fire_probability()
            if game.player and self.top < game.player.bottom and self.bottom > game.player.top:
                fire_probability *= 10
            if random() < fire_probability:
                self.fire_timer = 0
                game.play_sound("laser", 4)

        elif self.fire_timer == 8:
            #  Once the fire timer has been set to 0, it will count up - frame 8 of the animation is when the actual bolt is fired
            game.bolts.append(Bolt((self.x + self.direction_x * 20, self.y - 38), self.direction_x))

        # Am I colliding with an orb? If so, become trapped by it
        for orb in game.orbs:
            if orb.trapped_enemy_type == None and self.collidepoint(orb.center):
                self.alive = False
                orb.floating = True
                orb.trapped_enemy_type = self.type
                game.play_sound("trap", 4)
                break

        # Choose and set sprite image
        direction_idx = "1" if self.direction_x > 0 else "0"
        image = "robot" + str(self.type) + direction_idx
        if self.fire_timer < 12:
            image += str(5 + (self.fire_timer // 4))
        else:
            image += str(1 + ((game.timer // 4) % 4))
        self.image = image


class Game:
    def __init__(self, player=None):
        self.player = player
        self.level_colour = -1
        self.level = -1

        self.next_level()

    def fire_probability(self):
        # Likelihood per frame of each robot firing a bolt - they fire more often on higher levels
        return 0.001 + (0.0001 * min(100, self.level))

    def max_enemies(self):
        # Maximum number of enemies on-screen at once - increases as you progress through the levels
        return min((self.level + 6) // 2, 8)

    def next_level(self):
        self.level_colour = (self.level_colour + 1) % 4
        self.level += 1

        # Set up grid
        self.grid = LEVELS[self.level % len(LEVELS)]

        # The last row is a copy of the first row
        # Note that we don't do 'self.grid.append(self.grid[0])'. That would alter the original data in the LEVELS list
        # Instead, what this line does is create a brand new list, which is distinct from the list in LEVELS, and
        # consists of the level data plus the first row of the level. It's also interesting to note that you can't
        # do 'self.grid += [self.grid[0]]', because that's equivalent to using append.
        # As an alternative, we could have copied the list on the line below '# Set up grid', by writing
        # 'self.grid = list(LEVELS...', then used append or += on the line below.
        self.grid = self.grid + [self.grid[0]]

        self.timer = -1

        if self.player:
            self.player.reset()

        self.fruits = []
        self.bolts = []
        self.enemies = []
        self.pops = []
        self.orbs = []

        # At the start of each level we create a list of pending enemies - enemies to be created as the level plays out.
        # When this list is empty, we have no more enemies left to create, and the level will end once we have destroyed
        # all enemies currently on-screen. Each element of the list will be either 0 or 1, where 0 corresponds to
        # a standard enemy, and 1 is a more powerful enemy.
        # First we work out how many total enemies and how many of each type to create
        num_enemies = 10 + self.level
        num_strong_enemies = 1 + int(self.level / 1.5)
        num_weak_enemies = num_enemies - num_strong_enemies

        # Then we create the list of pending enemies, using Python's ability to create a list by multiplying a list
        # by a number, and by adding two lists together. The resulting list will consist of a series of copies of
        # the number 1 (the number depending on the value of num_strong_enemies), followed by a series of copies of
        # the number zero, based on num_weak_enemies.
        self.pending_enemies = num_strong_enemies * [Robot.TYPE_AGGRESSIVE] + num_weak_enemies * [Robot.TYPE_NORMAL]

        # Finally we shuffle the list so that the order is randomised (using Python's random.shuffle function)
        shuffle(self.pending_enemies)

        self.play_sound("level", 1)

    def get_robot_spawn_x(self):
        # Find a spawn location for a robot, by checking the top row of the grid for empty spots
        # Start by choosing a random grid column
        r = randint(0, NUM_COLUMNS-1)

        for i in range(NUM_COLUMNS):
            # Keep looking at successive columns (wrapping round if we go off the right-hand side) until
            # we find one where the top grid column is unoccupied
            grid_x = (r+i) % NUM_COLUMNS
            if self.grid[0][grid_x] == ' ':
                return GRID_BLOCK_SIZE * grid_x + LEVEL_X_OFFSET + 12

        # If we failed to find an opening in the top grid row (shouldn't ever happen), just spawn the enemy
        # in the centre of the screen
        return WIDTH/2

    def update(self):
        self.timer += 1

        # Update all objects
        for obj in self.fruits + self.bolts + self.enemies + self.pops + [self.player] + self.orbs:
            if obj:
                obj.update()

        # Use list comprehensions to remove objects which are no longer wanted from the lists. For example, we recreate
        # self.fruits such that it contains all existing fruits except those whose time_to_live counter has reached zero
        self.fruits = [f for f in self.fruits if f.time_to_live > 0]
        self.bolts = [b for b in self.bolts if b.active]
        self.enemies = [e for e in self.enemies if e.alive]
        self.pops = [p for p in self.pops if p.timer < 12]
        self.orbs = [o for o in self.orbs if o.timer < 250 and o.y > -40]

        # Every 100 frames, create a random fruit (unless there are no remaining enemies on this level)
        if self.timer % 100 == 0 and len(self.pending_enemies + self.enemies) > 0:
            # Create fruit at random position
            self.fruits.append(Fruit((randint(70, 730), randint(75, 400))))

        # Every 81 frames, if there is at least 1 pending enemy, and the number of active enemies is below the current
        # level's maximum enemies, create a robot
        if self.timer % 81 == 0 and len(self.pending_enemies) > 0 and len(self.enemies) < self.max_enemies():
            # Retrieve and remove the last element from the pending enemies list
            robot_type = self.pending_enemies.pop()
            pos = (self.get_robot_spawn_x(), -30)
            self.enemies.append(Robot(pos, robot_type))

        # End level if there are no enemies remaining to be created, no existing enemies, no fruit, no popping orbs,
        # and no orbs containing trapped enemies. (We don't want to include orbs which don't contain trapped enemies,
        # as the level would never end if the player kept firing new orbs)
        if len(self.pending_enemies + self.fruits + self.enemies + self.pops) == 0:
            if len([orb for orb in self.orbs if orb.trapped_enemy_type != None]) == 0:
                self.next_level()

    def draw(self):
        # Draw appropriate background for this level
        screen.blit("bg%d" % self.level_colour, (0, 0))

        block_sprite = "block" + str(self.level % 4)

        # Display blocks
        for row_y in range(NUM_ROWS):
            row = self.grid[row_y]
            if len(row) > 0:
                # Initial offset - large blocks at edge of level are 50 pixels wide
                x = LEVEL_X_OFFSET
                for block in row:
                    if block != ' ':
                        screen.blit(block_sprite, (x, row_y * GRID_BLOCK_SIZE))
                    x += GRID_BLOCK_SIZE

        # Draw all objects
        all_objs = self.fruits + self.bolts + self.enemies + self.pops + self.orbs
        all_objs.append(self.player)
        for obj in all_objs:
            if obj:
                obj.draw()

    def play_sound(self, name, count=1):
        # Some sounds have multiple varieties. If count > 1, we'll randomly choose one from those
        # We don't play any sounds if there is no player (e.g. if we're on the menu)
        if self.player:
            try:
                # Pygame Zero allows you to write things like 'sounds.explosion.play()'
                # This automatically loads and plays a file named 'explosion.wav' (or .ogg) from the sounds folder (if
                # such a file exists)
                # But what if you have files named 'explosion0.ogg' to 'explosion5.ogg' and want to randomly choose
                # one of them to play? You can generate a string such as 'explosion3', but to use such a string
                # to access an attribute of Pygame Zero's sounds object, we must use Python's built-in function getattr
                sound = getattr(sounds, name + str(randint(0, count - 1)))
                sound.play()
            except Exception as e:
                # If no such sound file exists, print the name
                print(e)

# Widths of the letters A to Z in the font images
CHAR_WIDTH = [27, 26, 25, 26, 25, 25, 26, 25, 12, 26, 26, 25, 33, 25, 26,
              25, 27, 26, 26, 25, 26, 26, 38, 25, 25, 25]

def char_width(char):
    # Return width of given character. For characters other than the letters A to Z (i.e. space, and the digits 0 to 9),
    # the width of the letter A is returned. ord gives the ASCII/Unicode code for the given character.
    index = max(0, ord(char) - 65)
    return CHAR_WIDTH[index]

def draw_text(text, y, x=None):
    if x == None:
        # If no X pos specified, draw text in centre of the screen - must first work out total width of text
        x = (WIDTH - sum([char_width(c) for c in text])) // 2

    for char in text:
        screen.blit("font0"+str(ord(char)), (x, y))
        x += char_width(char)

IMAGE_WIDTH = {"life":44, "plus":40, "health":40}

def draw_status():
    # Display score, right-justified at edge of screen
    number_width = CHAR_WIDTH[0]
    s = str(game.player.score)
    draw_text(s, 451, WIDTH - 2 - (number_width * len(s)))

    # Display level number
    draw_text("LEVEL " + str(game.level + 1), 451)

    # Display lives and health
    # We only display a maximum of two lives - if there are more than two, a plus symbol is displayed
    lives_health = ["life"] * min(2, game.player.lives)
    if game.player.lives > 2:
        lives_health.append("plus")
    if game.player.lives >= 0:
        lives_health += ["health"] * game.player.health

    x = 0
    for image in lives_health:
        screen.blit(image, (x, 450))
        x += IMAGE_WIDTH[image]

# Is the space bar currently being pressed down?
space_down = False

# Has the space bar just been pressed? i.e. gone from not being pressed, to being pressed
def space_pressed():
    global space_down
    if keyboard.space:
        if space_down:
            # Space was down previous frame, and is still down
            return False
        else:
            # Space wasn't down previous frame, but now is
            space_down = True
            return True
    else:
        space_down = False
        return False

# Pygame Zero calls the update and draw functions each frame

class State(Enum):
    MENU = 1
    PLAY = 2
    GAME_OVER = 3


def update():
    global state, game

    if state == State.MENU:
        if space_pressed():
            # Switch to play state, and create a new Game object, passing it a new Player object to use
            state = State.PLAY
            game = Game(Player())
        else:
            game.update()

    elif state == State.PLAY:
        if game.player.lives < 0:
            game.play_sound("over")
            state = State.GAME_OVER
        else:
            game.update()

    elif state == State.GAME_OVER:
        if space_pressed():
            # Switch to menu state, and create a new game object without a player
            state = State.MENU
            game = Game()

def draw():
    game.draw()

    if state == State.MENU:
        # Draw title screen
        screen.blit("title", (0, 0))

        # Draw "Press SPACE" animation, which has 10 frames numbered 0 to 9
        # The first part gives us a number between 0 and 159, based on the game timer
        # Dividing by 4 means we go to a new animation frame every 4 frames
        # We enclose this calculation in the min function, with the other argument being 9, which results in the
        # animation staying on frame 9 for three quarters of the time. Adding 40 to the game timer is done to alter
        # which stage the animation is at when the game first starts
        anim_frame = min(((game.timer + 40) % 160) // 4, 9)
        screen.blit("space" + str(anim_frame), (130, 280))

    elif state == State.PLAY:
        draw_status()

    elif state == State.GAME_OVER:
        draw_status()
        # Display "Game Over" image
        screen.blit("over", (0, 0))

# Set up sound system and start music
try:
    pygame.mixer.quit()
    pygame.mixer.init(44100, -16, 2, 1024)

    music.play("theme")
    music.set_volume(0.3)
except:
    # If an error occurs, just ignore it
    pass



# Set the initial game state
state = State.MENU

# Create a new Game object, without a Player object
game = Game()

pgzrun.go()