Infinite Bunner

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

Attribution

Code the Classics – Volume 1, Chapter 3 Top-down Platformer, page 095.

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

Original Python code


# If the window is too tall to fit on the screen, check your operating system display settings and reduce display
# scaling if it is enabled.
import pgzero, pgzrun, pygame, sys
from random import *
from enum import Enum

# 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()

WIDTH = 480
HEIGHT = 800
TITLE = "Infinite Bunner"

ROW_HEIGHT = 40

# See what happens when you change this to True
DEBUG_SHOW_ROW_BOUNDARIES = False

# The MyActor class extends Pygame Zero's Actor class by allowing an object to have a list of child objects,
# which are drawn relative to the parent object.
class MyActor(Actor):
    def __init__(self, image, pos, anchor=("center", "bottom")):
        super().__init__(image, pos, anchor)

        self.children = []

    def draw(self, offset_x, offset_y):
        self.x += offset_x
        self.y += offset_y

        super().draw()
        for child_obj in self.children:
            child_obj.draw(self.x, self.y)

        self.x -= offset_x
        self.y -= offset_y

    def update(self):
        for child_obj in self.children:
            child_obj.update()

# The eagle catches the rabbit if it goes off the bottom of the screen
class Eagle(MyActor):
    def __init__(self, pos):
        super().__init__("eagles", pos)

        self.children.append(MyActor("eagle", (0, -32)))

    def update(self):
        self.y += 12

class PlayerState(Enum):
    ALIVE = 0
    SPLAT = 1
    SPLASH = 2
    EAGLE = 3

# Constants representing directions
DIRECTION_UP = 0
DIRECTION_RIGHT = 1
DIRECTION_DOWN = 2
DIRECTION_LEFT = 3

direction_keys = [keys.UP, keys.RIGHT, keys.DOWN, keys.LEFT]

# X and Y directions indexed into by in_edge and out_edge in Segment
# The indices correspond to the direction numbers above, i.e. 0 = up, 1 = right, 2 = down, 3 = left
# Numbers 0 to 3 correspond to up, right, down, left
DX = [0,4,0,-4]
DY = [-4,0,4,0]

class Bunner(MyActor):
    MOVE_DISTANCE = 10

    def __init__(self, pos):
        super().__init__("blank", pos)

        self.state = PlayerState.ALIVE

        self.direction = 2
        self.timer = 0

        # If a control input is pressed while the rabbit is in the middle of jumping, it's added to the input queue
        self.input_queue = []

        # Keeps track of the furthest distance we've reached so far in the level, for scoring
        # (Level Y coordinates decrease as the screen scrolls)
        self.min_y = self.y

    def handle_input(self, dir):
        # Find row that player is trying to move to. This may or may not be the row they're currently standing on,
        # depending on whether the proposed movement would take them onto a different row
        for row in game.rows:
            if row.y == self.y + Bunner.MOVE_DISTANCE * DY[dir]:
                # Found the target row
                # Can the player move to the new location? Can't move if there's something in the way
                # (or if the new location is off the screen)
                if row.allow_movement(self.x + Bunner.MOVE_DISTANCE * DX[dir]):
                    # It's okay to move here, so set direction and timer. Player will move one pixel per frame
                    # for the specified number of frames
                    self.direction = dir
                    self.timer = Bunner.MOVE_DISTANCE
                    game.play_sound("jump", 1)

                # No need to continue searching
                return

    def update(self):
        # Check each control direction
        for direction in range(4):
            if key_just_pressed(direction_keys[direction]):
                self.input_queue.append(direction)

        if self.state == PlayerState.ALIVE:
            # While the player is alive, the timer variable is used for movement. If it's zero, the player is on
            # the ground. If it's above zero, they're currently jumping to a new location.

            # Are we on the ground, and are there inputs to process?
            if self.timer == 0 and len(self.input_queue) > 0:
                # Take the next input off the queue and process it
                self.handle_input(self.input_queue.pop(0))

            land = False
            if self.timer > 0:
                # Apply movement
                self.x += DX[self.direction]
                self.y += DY[self.direction]
                self.timer -= 1
                land = self.timer == 0      # If timer reaches zero, we've just landed

            current_row = None
            for row in game.rows:
                if row.y == self.y:
                    current_row = row
                    break

            if current_row:
                # Row.check receives the player's X coordinate and returns the new state the player should be in
                # (normally ALIVE, but SPLAT or SPLASH if they've collided with a vehicle or if they've fallen in
                # the water). It also returns a second result which is only used if there was a collision, and even
                # then only for certain collisions. When the new state is SPLAT, we will add a new child object to the
                # current row, with the appropriate 'splat' image. In this case, the second result returned from
                # check_collision is a Y offset which affects the position of this new child object. If the player is
                # hit by a car the Y offset is zero, but if they are hit by a train the returned offset is 8 as this
                # positioning looks a little better.
                self.state, dead_obj_y_offset = current_row.check_collision(self.x)
                if self.state == PlayerState.ALIVE:
                    # Water rows move the player along the X axis, if standing on a log
                    self.x += current_row.push()

                    if land:
                        # Just landed - play sound effect appropriate to the current row
                        current_row.play_sound()
                else:
                    if self.state == PlayerState.SPLAT:
                        # Add 'splat' graphic to current row with the specified position and Y offset
                        current_row.children.insert(0, MyActor("splat" + str(self.direction), (self.x, dead_obj_y_offset)))
                    self.timer = 100
            else:
                # There's no current row - either because player is currently changing row, or the row they were on
                # has been deleted. Has the player gone off the bottom of the screen?
                if self.y > game.scroll_pos + HEIGHT + 80:
                    # Create eagle
                    game.eagle = Eagle((self.x, game.scroll_pos))
                    self.state = PlayerState.EAGLE
                    self.timer = 150
                    game.play_sound("eagle")

            # Limit x position so player doesn't go off the screen. The player movement code doesn't allow jumping off
            # the screen, but without this line, the player could be carried off the screen by a log
            self.x = max(16, min(WIDTH - 16, self.x))
        else:
            # Not alive - timer now counts down prior to game over screen
            self.timer -= 1

        # Keep track of the furthest we've got in the level
        self.min_y = min(self.min_y, self.y)

        # Choose sprite image
        self.image = "blank"
        if self.state == PlayerState.ALIVE:
            if self.timer > 0:
                self.image = "jump" + str(self.direction)
            else:
                self.image = "sit" + str(self.direction)
        elif self.state == PlayerState.SPLASH and self.timer > 84:
            # Display appropriate 'splash' animation frame. Note that we use a different technique to display the
            # 'splat' image - see: comments earlier in this method. The reason two different techniques are used is
            # that the splash image should be drawn on top of other objects, whereas the splat image must be drawn
            # underneath other objects. Since the player is always drawn on top of other objects, changing the player
            # sprite is a suitable method of displaying the splash image.
            self.image = "splash" + str(int((100 - self.timer) / 2))

# Mover is the base class for Car, Log and Train
# The thing they all have in common, besides inheriting from MyActor, is that they need to store whether they're
# moving left or right and update their X position each frame
class Mover(MyActor):
    def __init__(self, dx, image, pos):
        super().__init__(image, pos)

        self.dx = dx

    def update(self):
        self.x += self.dx

class Car(Mover):
    # These correspond to the indicies of the lists self.sounds and self.played. Used in Car.update to trigger
    # playing of the corresponding sound effects.
    SOUND_ZOOM = 0
    SOUND_HONK = 1

    def __init__(self, dx, pos):
        image = "car" + str(randint(0, 3)) + ("0" if dx < 0 else "1")
        super().__init__(dx, image, pos)

        # Cars have two sound effects. Each can only play once. We use this
        # list to keep track of which has already played.
        self.played = [False, False]
        self.sounds = [("zoom", 6), ("honk", 4)]

    def play_sound(self, num):
        if not self.played[num]:
            # Select a sound and pass the name and count to Game.play_sound.
            # The asterisk operator unpacks the two items and passes them to play_sound as separate arguments
            game.play_sound(*self.sounds[num])
            self.played[num] = True

class Log(Mover):
    def __init__(self, dx, pos):
        image = "log" + str(randint(0, 1))
        super().__init__(dx, image, pos)

class Train(Mover):
    def __init__(self, dx, pos):
        image = "train"  +str(randint(0, 2)) + ("0" if dx < 0 else "1")
        super().__init__(dx, image, pos)

# Row is the base class for Pavement, Grass, Dirt, Rail and ActiveRow
# Each row corresponds to one of the 40 pixel high images which make up sections of grass, road, etc.
# The last row of each section is 60 pixels high and overlaps with the row above
class Row(MyActor):
    def __init__(self, base_image, index, y):
        # base_image and index form the name of the image file to use
        # Last argument is the anchor point to use
        super().__init__(base_image + str(index), (0, y), ("left", "bottom"))

        self.index = index

        # X direction of moving elements on this row
        # Zero by default - only ActiveRows (see below) and Rail have moving elements
        self.dx = 0

    def next(self):
        # Overridden in child classes. See comments in Game.update
        return

    def collide(self, x, margin=0):
        # Check to see if the given X coordinate is in contact with any of this row's child objects (e.g. logs, cars,
        # hedges). A negative margin makes the collideable area narrower than the child object's sprite, while a
        # positive margin makes the collideable area wider.
        for child_obj in self.children:
            if x >= child_obj.x - (child_obj.width / 2) - margin and x < child_obj.x + (child_obj.width / 2) + margin:
                return child_obj

        return None

    def push(self):
        return 0

    def check_collision(self, x):
        # Returns the new state the player should be in, based on whether or not the player collided with anything on
        # this road. As this class is the base class for other types of row, this method defines the default behaviour
        # - i.e. unless a subclass overrides this method, the player can walk around on a row without dying.
        return PlayerState.ALIVE, 0

    def allow_movement(self, x):
        # Ensure the player can't walk off the left or right sides of the screen
        return x >= 16 and x <= WIDTH-16

class ActiveRow(Row):
    def __init__(self, child_type, dxs, base_image, index, y):
        super().__init__(base_image, index, y)

        self.child_type = child_type    # Class to be used for child objects (e.g. Car)
        self.timer = 0
        self.dx = choice(dxs)   # Randomly choose a direction for cars/logs to move

        # Populate the row with child objects (cars or logs). Without this, the row would initially be empty.
        x = -WIDTH / 2 - 70
        while x < WIDTH / 2 + 70:
            x += randint(240, 480)
            pos = (WIDTH / 2 + (x if self.dx > 0 else -x), 0)
            self.children.append(self.child_type(self.dx, pos))

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

        # Recreate the children list, excluding any which are too far off the edge of the screen to be visible
        self.children = [c for c in self.children if c.x > -70 and c.x < WIDTH + 70]

        self.timer -= 1

        # Create new child objects on a random interval
        if self.timer < 0:
            pos = (WIDTH + 70 if self.dx < 0 else -70, 0)
            self.children.append(self.child_type(self.dx, pos))
            # 240 is minimum distance between the start of one child object and the start of the next, assuming its
            # speed is 1. If the speed is 2, they can occur twice as frequently without risk of overlapping with
            # each other. The maximum distance is double the minimum distance (1 + random value of 1)
            self.timer = (1 + random()) * (240 / abs(self.dx))

# Grass rows sometimes contain hedges
class Hedge(MyActor):
    def __init__(self, x, y, pos):
        super().__init__("bush"+str(x)+str(y), pos)

def generate_hedge_mask():
    # In this context, a mask is a series of boolean values which allow or prevent parts of an underlying image from showing through.
    # This function creates a mask representing the presence or absence of hedges in a Grass row. False means a hedge
    # is present, True represents a gap. Initially we create a list of 12 elements. For each element there is a small
    # chance of a gap, but normally all element will be False, representing a hedge. We then randomly set one item to
    # True, to ensure that there is always at least one gap that the player can get through
    mask = [random() < 0.01 for i in range(12)]
    mask[randint(0, 11)] = True # force there to be one gap

    # We then widen gaps to a minimum of 3 tiles. This happens in two steps.
    # First, we recreate the mask list, except this time whether a gap is present is based on whether there was a gap
    # in either the original element or its neighbouring elements. When using Python's built-in sum function, a value
    # of True is treated as 1 and False as 0. We must use the min/max functions to ensure that we don't try to look
    # at a neighbouring element which doesn't exist (e.g. there is no neighbour to the right of the last element)
    mask = [sum(mask[max(0, i-1):min(12, i+2)]) > 0 for i in range(12)]

    # We want to ensure gaps are a minimum of 3 tiles wide, but the previous line only ensures a minimum gap of 2 tiles
    # at the edges. The last step is to return a new list consisting of the old list with the first and last elements duplicated
    return [mask[0]] + mask + 2 * [mask[-1]]

def classify_hedge_segment(mask, previous_mid_segment):
    # This function helps determine which sprite should be used by a particular hedge segment. Hedge sprites are numbered
    # 00, 01, 10, 11, 20, 21 - up to 51. The second number indicates whether it's a bottom (0) or top (1) segment,
    # but this method is concerned only with the first number. 0 represents a single-tile-width hedge. 1 and 2 represent
    # the left-most or right-most sprites in a multi-tile-width hedge. 3, 4 and 5 all represent middle pieces in hedges
    # which are 3 or more tiles wide.

    # mask is a list of 4 boolean values - a slice from the list generated by generate_hedge_mask. True represents a gap
    # and False represents a hedge. mask[1] is the item we're currently looking at.
    if mask[1]:
        # mask[1] == True represents a gap, so there will be no hedge sprite at this location
        sprite_x = None
    else:
        # There's a hedge here - need to check either side of it to see if it's a single-width, left-most, right-most
        # or middle piece. The calculation generates a number from 0 to 3 accordingly. Note that when boolean values
        # are used in arithmetic in Python, False is treated as being 0 and True as 1.
        sprite_x = 3 - 2 * mask[0] - mask[2]

    if sprite_x == 3:
        # If this is a middle piece, to ensure the piece tiles correctly, we alternate between sprites 3 and 4.
        # If the next piece is going to be the last of this hedge section (sprite 2), we need to make sure that sprite 3
        # does not precede it, as the two do not tile together correctly. In this case we should use sprite 5.
        # mask[3] tells us whether there's a gap 2 tiles to the right - which means the next tile will be sprite 2
        if previous_mid_segment == 4 and mask[3]:
            return 5, None
        else:
            # Alternate between 3 and 4
            if previous_mid_segment == None or previous_mid_segment == 4:
                sprite_x = 3
            elif previous_mid_segment == 3:
                sprite_x = 4
            return sprite_x, sprite_x
    else:
        # Not a middle piece
        return sprite_x, None

class Grass(Row):
    def __init__(self, predecessor, index, y):
        super().__init__("grass", index, y)

        # In computer graphics, a mask is a series of boolean (true or false) values indicating which parts of an image
        # will be transparent. Grass rows may contain hedges which block the player's movement, and we use a similar
        # mechanism here. In our hedge mask, values of False mean a hedge is present, while True means there is a gap
        # in the hedges. Hedges are two rows high - once hedges have been created on a row, the pattern will be
        # duplicated on the next row (although the sprites will be different - e.g. there are separate sprites
        # for the top-left and bottom-left corners of a hedge). Note that the upper sprites overlap with the row above.
        self.hedge_row_index = None     # 0 or 1, or None if no hedges on this row
        self.hedge_mask = None

        if not isinstance(predecessor, Grass) or predecessor.hedge_row_index == None:
            # Create a brand-new set of hedges? We will only create hedges if the previous row didn't have any.
            # We also only want hedges to appear on certain types of grass row, and on only a random selection
            # of rows
            if random() < 0.5 and index > 7 and index < 14:
                self.hedge_mask = generate_hedge_mask()
                self.hedge_row_index = 0
        elif predecessor.hedge_row_index == 0:
            self.hedge_mask = predecessor.hedge_mask
            self.hedge_row_index = 1

        if self.hedge_row_index != None:
            # See comments in classify_hedge_segment for explanation of previous_mid_segment
            previous_mid_segment = None
            for i in range(1, 13):
                sprite_x, previous_mid_segment = classify_hedge_segment(self.hedge_mask[i - 1:i + 3], previous_mid_segment)
                if sprite_x != None:
                    self.children.append(Hedge(sprite_x, self.hedge_row_index, (i * 40 - 20, 0)))

    def allow_movement(self, x):
        # allow_movement in the base class ensures that the player can't walk off the left and right sides of the
        # screen. The call to our own collide method ensures that the player can't walk through hedges. The margin of
        # 8 prevents the player sprite from overlapping with the edge of a hedge.
        return super().allow_movement(x) and not self.collide(x, 8)

    def play_sound(self):
        game.play_sound("grass", 1)

    def next(self):
        if self.index <= 5:
            row_class, index = Grass, self.index + 8
        elif self.index == 6:
            row_class, index = Grass, 7
        elif self.index == 7:
            row_class, index = Grass, 15
        elif self.index >= 8 and self.index <= 14:
            row_class, index = Grass, self.index + 1
        else:
            row_class, index = choice((Road, Water)), 0

        # Create an object of the chosen row class
        return row_class(self, index, self.y - ROW_HEIGHT)

class Dirt(Row):
    def __init__(self, predecessor, index, y):
        super().__init__("dirt", index, y)

    def play_sound(self):
        game.play_sound("dirt", 1)

    def next(self):
        if self.index <= 5:
            row_class, index = Dirt, self.index + 8
        elif self.index == 6:
            row_class, index = Dirt, 7
        elif self.index == 7:
            row_class, index = Dirt, 15
        elif self.index >= 8 and self.index <= 14:
            row_class, index = Dirt, self.index + 1
        else:
            row_class, index = choice((Road, Water)), 0

        # Create an object of the chosen row class
        return row_class(self, index, self.y - ROW_HEIGHT)

class Water(ActiveRow):
    def __init__(self, predecessor, index, y):
        # dxs contains a list of possible directions (and speeds) in which child objects (in this case, logs) on this
        # row could move. We pass the lists to the constructor of the base class, which randomly chooses one of the
        # directions. We want logs on alternate rows to move in opposite directions, so we take advantage of the fact
        # that that in Python, multiplying a list by True or False results in either the same list, or an empty list.
        # So by looking at the direction of child objects on the previous row (predecessor.dx), we can decide whether
        # child objects on this row should move left or right. If this is the first of a series of Water rows,
        # predecessor.dx will be zero, so child objects could move in either direction.
        dxs = [-2,-1]*(predecessor.dx >= 0) + [1,2]*(predecessor.dx <= 0)
        super().__init__(Log, dxs, "water", index, y)

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

        for log in self.children:
            # Child (log) object positions are relative to the parent row. If the player exists, and the player is at the
            # same Y position, and is colliding with the current log, make the log dip down into the water slightly
            if game.bunner and self.y == game.bunner.y and log == self.collide(game.bunner.x, -4):
                log.y = 2
            else:
                log.y = 0

    def push(self):
        # Called when the player is standing on a log on this row, so player object can be moved at the same speed and
        # in the same direction as the log
        return self.dx

    def check_collision(self, x):
        # If we're colliding with a log, that's a good thing!
        # margin of -4 ensures we can't stand right on the edge of a log
        if self.collide(x, -4):
            return PlayerState.ALIVE, 0
        else:
            game.play_sound("splash")
            return PlayerState.SPLASH, 0

    def play_sound(self):
        game.play_sound("log", 1)

    def next(self):
        # After 2 water rows, there's a 50-50 chance of the next row being either another water row, or a dirt row
        if self.index == 7 or (self.index >= 1 and random() < 0.5):
            row_class, index = Dirt, randint(4,6)
        else:
            row_class, index = Water, self.index + 1

        # Create an object of the chosen row class
        return row_class(self, index, self.y - ROW_HEIGHT)

class Road(ActiveRow):
    def __init__(self, predecessor, index, y):
        # Specify the possible directions and speeds from which the movement of cars on this row will be chosen
        # We use Python's set data structure to specify that the car velocities on this row will be any of the numbers
        # from -5 to 5, except for zero or the velocity of the cars on the previous row
        dxs = list(set(range(-5, 6)) - set([0, predecessor.dx]))
        super().__init__(Car, dxs, "road", index, y)

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

        # Trigger car sound effects. The zoom effect should play when the player is on the row above or below the car,
        # the honk effect should play when the player is on the same row.
        for y_offset, car_sound_num in [(-ROW_HEIGHT, Car.SOUND_ZOOM), (0, Car.SOUND_HONK), (ROW_HEIGHT, Car.SOUND_ZOOM)]:
            # Is the player on the appropriate row?
            if game.bunner and game.bunner.y == self.y + y_offset:
                for child_obj in self.children:
                    # The child object must be a car
                    if isinstance(child_obj, Car):
                        # The car must be within 100 pixels of the player on the x-axis, and moving towards the player
                        # child_obj.dx < 0 is True or False depending on whether the car is moving left or right, and
                        # dx < 0 is True or False depending on whether the player is to the left or right of the car.
                        # If the results of these two comparisons are different, the car is moving towards the player.
                        # Also, for the zoom sound, the car must be travelling faster than one pixel per frame
                        dx = child_obj.x - game.bunner.x
                        if abs(dx) < 100 and ((child_obj.dx < 0) != (dx < 0)) and (y_offset == 0 or abs(child_obj.dx) > 1):
                            child_obj.play_sound(car_sound_num)

    def check_collision(self, x):
        if self.collide(x):
            game.play_sound("splat", 1)
            return PlayerState.SPLAT, 0
        else:
            return PlayerState.ALIVE, 0

    def play_sound(self):
        game.play_sound("road", 1)

    def next(self):
        if self.index == 0:
            row_class, index = Road, 1
        elif self.index < 5:
            # 80% chance of another road
            r = random()
            if r < 0.8:
                row_class, index = Road, self.index + 1
            elif r < 0.88:
                row_class, index = Grass, randint(0,6)
            elif r < 0.94:
                row_class, index = Rail, 0
            else:
                row_class, index = Pavement, 0
        else:
            # We've reached maximum of 5 roads in a row, so choose something else
            r = random()
            if r < 0.6:
                row_class, index = Grass, randint(0,6)
            elif r < 0.9:
                row_class, index = Rail, 0
            else:
                row_class, index = Pavement, 0

        # Create an object of the chosen row class
        return row_class(self, index, self.y - ROW_HEIGHT)

class Pavement(Row):
    def __init__(self, predecessor, index, y):
        super().__init__("side", index, y)

    def play_sound(self):
        game.play_sound("sidewalk", 1)

    def next(self):
        if self.index < 2:
            row_class, index = Pavement, self.index + 1
        else:
            row_class, index = Road, 0

        # Create an object of the chosen row class
        return row_class(self, index, self.y - ROW_HEIGHT)

# Note that Rail does not inherit from ActiveRow
class Rail(Row):
    def __init__(self, predecessor, index, y):
        super().__init__("rail", index, y)

        self.predecessor = predecessor

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

        # Only Rail rows with index 1 have trains on them
        if self.index == 1:
            # Recreate the children list, excluding any which are too far off the edge of the screen to be visible
            self.children = [c for c in self.children if c.x > -1000 and c.x < WIDTH + 1000]

            # If on-screen, and there is currently no train, and with a 1% chance every frame, create a train
            if self.y < game.scroll_pos+HEIGHT and len(self.children) == 0 and random() < 0.01:
                # Randomly choose a direction for trains to move. This can be different for each train created
                dx = choice([-20, 20])
                self.children.append(Train(dx, (WIDTH + 1000 if dx < 0 else -1000, -13)))
                game.play_sound("bell")
                game.play_sound("train", 2)

    def check_collision(self, x):
        if self.index == 2 and self.predecessor.collide(x):
            game.play_sound("splat", 1)
            return PlayerState.SPLAT, 8     # For the meaning of the second return value, see comments in Bunner.update
        else:
            return PlayerState.ALIVE, 0

    def play_sound(self):
        game.play_sound("grass", 1)

    def next(self):
        if self.index < 3:
            row_class, index = Rail, self.index + 1
        else:
            item = choice( ((Road, 0), (Water, 0)) )
            row_class, index = item[0], item[1]

        # Create an object of the chosen row class
        return row_class(self, index, self.y - ROW_HEIGHT)

class Game:
    def __init__(self, bunner=None):
        self.bunner = bunner
        self.looped_sounds = {}

        try:
            if bunner:
                music.set_volume(0.4)
            else:
                music.play("theme")
                music.set_volume(1)
        except:
            pass

        self.eagle = None
        self.frame = 0

        # First (bottom) row is always grass
        self.rows = [Grass(None, 0, 0)]

        self.scroll_pos = -HEIGHT

    def update(self):
        if self.bunner:
            # Scroll faster if the player is close to the top of the screen. Limit scroll speed to
            # between 1 and 3 pixels per frame.
            self.scroll_pos -= max(1, min(3, float(self.scroll_pos + HEIGHT - self.bunner.y) / (HEIGHT // 4)))
        else:
            self.scroll_pos -= 1

        # Recreate the list of rows, excluding any which have scrolled off the bottom of the screen
        self.rows = [row for row in self.rows if row.y < int(self.scroll_pos) + HEIGHT + ROW_HEIGHT * 2]

        # In Python, a negative index into a list gives you items in reverse order, e.g. my_list[-1] gives you the
        # last element of a list. Here, we look at the last row in the list - which is the top row - and check to see
        # if it has scrolled sufficiently far down that we need to add a new row above it. This may need to be done
        # multiple times - particularly when the game starts, as only one row is added to begin with.
        while self.rows[-1].y > int(self.scroll_pos)+ROW_HEIGHT:
            new_row = self.rows[-1].next()
            self.rows.append(new_row)

        # Update all rows, and the player and eagle (if present)
        for obj in self.rows + [self.bunner, self.eagle]:
            if obj:
                obj.update()

        # Play river and traffic sound effects, and adjust volume each frame based on the player's proximity to rows
        # of the appropriate types. For each such row, a number is generated representing how much the row should
        # contribute to the volume of the sound effect. These numbers are added together by Python's sum function.
        # On the following line we ensure that the volume can never be above 40% of the maximum possible volume.
        if self.bunner:
            for name, count, row_class in [("river", 2, Water), ("traffic", 3, Road)]:
                # The first line uses a list comprehension to get each row of the appropriate type, e.g. Water rows
                # if we're currently updating the "river" sound effect.
                volume = sum([16.0 / max(16.0, abs(r.y - self.bunner.y)) for r in self.rows if isinstance(r, row_class)]) - 0.2
                volume = min(0.4, volume)
                self.loop_sound(name, count, volume)

        return self

    def draw(self):
        # Create a list of all objects which need to be drawn. This includes all rows, plus the player
        # Using list(s.rows) means we're creating a copy of that list to use - we don't want to create a reference
        # to it as that would mean we're modifying the original list's contents
        all_objs = list(self.rows)

        if self.bunner:
            all_objs.append(self.bunner)

        # We want to draw objects in order based on their Y position. In general, objects further down the screen should be drawn
        # after (and therefore in front of) objects higher up the screen. We can use Python's built-in sort function
        # to put the items in the desired order, before we draw the  The following function specifies the criteria
        # used to decide how the objects are sorted.
        def sort_key(obj):
            # Adding 39 and then doing an integer divide by 40 (the height of each row) deals with the situation where
            # the player sprite would otherwise be drawn underneath the row below. This could happen when the player
            # is moving up or down. If you assume that it occupies a 40x40 box which can be at an arbitrary y offset,
            # it generates the row number of the bottom row that that box overlaps. If the player happens to be
            # perfectly aligned to a row, adding 39 and dividing by 40 has no effect on the result. If it isn't, even
            # by a single pixel, the +39 causes it to be drawn one row later.
            return (obj.y + 39) // ROW_HEIGHT

        # Sort list using the above function to determine order
        all_objs.sort(key=sort_key)

        # Always draw eagle on top of everything
        all_objs.append(self.eagle)

        for obj in all_objs:
            if obj:
                # Draw the object, taking the scroll position into account
                obj.draw(0, -int(self.scroll_pos))

        if DEBUG_SHOW_ROW_BOUNDARIES:
            for obj in all_objs:
                if obj and isinstance(obj, Row):
                    pygame.draw.rect(screen.surface, (255, 255, 255), pygame.Rect(obj.x, obj.y - int(self.scroll_pos), screen.surface.get_width(), ROW_HEIGHT), 1)
                    screen.draw.text(str(obj.index), (obj.x, obj.y - int(self.scroll_pos) - ROW_HEIGHT))

    def score(self):
        return int(-320 - game.bunner.min_y) // 40

    def play_sound(self, name, count=1):
        try:
            # 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.bunner:
                # 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:
            # If a sound fails to play, ignore the error
            pass

    def loop_sound(self, name, count, volume):
        try:
            # Similar to play_sound above, but for looped sounds we need to keep a reference to the sound so that we can
            # later modify its volume or turn it off. We use the dictionary self.looped_sounds for this - the sound
            # effect name is the key, and the value is the corresponding sound reference.
            if volume > 0 and not name in self.looped_sounds:
                full_name = name + str(randint(0, count - 1))
                sound = getattr(sounds, full_name)      # see play_sound method above for explanation
                sound.play(-1)  # -1 means sound will loop indefinitely
                self.looped_sounds[name] = sound

            if name in self.looped_sounds:
                sound = self.looped_sounds[name]
                if volume > 0:
                    sound.set_volume(volume)
                else:
                    sound.stop()
                    del self.looped_sounds[name]
        except:
            # If a sound fails to play, ignore the error
            pass


    def stop_looped_sounds(self):
        try:
            for sound in self.looped_sounds.values():
                sound.stop()
            self.looped_sounds.clear()
        except:
            # If sound system is not working/present, ignore the error
            pass

# Dictionary to keep track of which keys are currently being held down
key_status = {}

# Was the given key just pressed? (i.e. is it currently down, but wasn't down on the previous frame?)
def key_just_pressed(key):
    result = False

    # Get key's previous status from the key_status dictionary. The dictionary.get method allows us to check for a given
    # entry without giving an error if that entry is not present in the dictionary. False is the default value returned
    # when the key is not present.
    prev_status = key_status.get(key, False)

    # If the key wasn't previously being pressed, but it is now, we're going to return True
    if not prev_status and keyboard[key]:
        result = True

    # Before we return, we need to update the key's entry in the key_status dictionary (or create an entry if there
    # wasn't one already
    key_status[key] = keyboard[key]

    return result

def display_number(n, colour, x, align):
    # align: 0 for left, 1 for right
    n = str(n)  # Convert number to string
    for i in range(len(n)):
        screen.blit("digit" + str(colour) + n[i], (x + (i - len(n) * align) * 25, 0))


# 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, high_score

    if state == State.MENU:
        if key_just_pressed(keys.SPACE):
            state = State.PLAY
            game = Game(Bunner((240, -320)))
        else:
            game.update()

    elif state == State.PLAY:
        # Is it game over?
        if game.bunner.state != PlayerState.ALIVE and game.bunner.timer < 0:
            # Update high score
            high_score = max(high_score, game.score())

            # Write high score file
            try:
                with open("high.txt", "w") as file:
                    file.write(str(high_score))
            except:
                # If an error occurs writing the file, just ignore it and carry on, rather than crashing
                pass

            state = State.GAME_OVER
        else:
            game.update()

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

def draw():
    game.draw()

    if state == State.MENU:
        screen.blit("title", (0, 0))
        screen.blit("start" + str([0, 1, 2, 1][game.scroll_pos // 6 % 4]), ((WIDTH - 270) // 2, HEIGHT - 240))

    elif state == State.PLAY:
        # Display score and high score
        display_number(game.score(), 0, 0, 0)
        display_number(high_score, 1, WIDTH - 10, 1)

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

# Set up sound system
try:
    pygame.mixer.quit()
    pygame.mixer.init(44100, -16, 2, 512)
    pygame.mixer.set_num_channels(16)
except:
    # If an error occurs, just ignore it
    pass

# Load high score from file
try:
    with open("high.txt", "r") as f:
        high_score = int(f.read())
except:
    # If opening the file fails (likely because it hasn't yet been created), set high score to 0
    high_score = 0

# Set the initial game state
state = State.MENU

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

pgzrun.go()