Myriapod

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

Attribution

Code the Classics – Volume 1, Chapter 4 Fixed Shooter, page 129.

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

Original Python code


import pgzero, pgzrun, pygame, sys
from random import choice, randint, random
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 = "Myriapod"

DEBUG_TEST_RANDOM_POSITIONS = False

# Pygame Zero allows you to access and change sprite positions based on various
# anchor points
CENTRE_ANCHOR = ("center", "center")

num_grid_rows = 25
num_grid_cols = 14

# Convert a position in pixel units to a position in grid units. In this game, a grid square is 32 pixels.
def pos2cell(x, y):
    return ((int(x)-16)//32, int(y)//32)

# Convert grid cell position to pixel coordinates, with a given offset
def cell2pos(cell_x, cell_y, x_offset=0, y_offset=0):
    # If the requested offset is zero, returns the centre of the requested cell, hence the +16. In the case of the
    # X axis, there's a 16 pixel border at the left and right of the screen, hence +16 becomes +32.
    return ((cell_x * 32) + 32 + x_offset, (cell_y * 32) + 16 + y_offset)

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

        self.type = type
        self.timer = 0

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

        # Set sprite based on explosion type and timer - update to a new image
        # every four frames
        self.image = "exp" + str(self.type) + str(self.timer // 4)


class Player(Actor):

    INVULNERABILITY_TIME = 100
    RESPAWN_TIME = 100
    RELOAD_TIME = 10

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

        # These determine which frame of animation the player sprite will use
        self.direction = 0
        self.frame = 0

        self.lives = 3
        self.alive = True

        # timer is used for animation, respawning and for ensuring the player is
        # invulnerable immediately after respawning
        self.timer = 0

        # When the player shoots, this is set to RELOAD_TIME - it then counts
        # down - when it reaches zero the player can shoot again
        self.fire_timer = 0

    def move(self, dx, dy, speed):
        # dx and dy will each be either 0, -1 or 1. speed is an integer indicating
        # how many pixels we should move in the specified direction.
        for i in range(speed):
            # For each pixel we want to move, we must first check if it's a valid place to move to
            if game.allow_movement(self.x + dx, self.y + dy):
                self.x += dx
                self.y += dy

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

        if self.alive:
            # Get keyboard input. dx and dy represent the direction the player is facing on each axis
            dx = 0
            if keyboard.left:
                dx = -1
            elif keyboard.right:
                dx = 1

            dy = 0
            if keyboard.up:
                dy = -1
            elif keyboard.down:
                dy = 1

            # Move in the relevant directions by the specified number of pixels. The purpose of 3 - abs(dy) is to
            # generate vectors which look either like (3,0) (which is 3 units long) or (2, 2) (which is sqrt(8) long)
            # so we move roughly the same distance regardless of whether we're travelling straight along the x or y axis.
            # or at 45 degrees. Without this, we would move noticeably faster when travelling diagonally.
            self.move(dx, 0, 3 - abs(dy))
            self.move(0, dy, 3 - abs(dx))

            # When the player presses a key to start handing in a new direction, we don't want the sprite to just
            # instantly change to facing in that new direction. That would look wrong, since in the real world vehicles
            # can't just suddenly change direction in the blink of an eye.
            # Instead, we want the vehicle to turn to face the new direction over several frames. If the vehicle is
            # currently facing down, and the player presses the left arrow key, the vehicle should first turn to face
            # diagonally down and to the left, and then turn to face left.

            # Each number in the following list corresponds to a direction - 0 is up, 1 is up and to the right, and
            # so on in clockwise order. -1 means no direction.
            # Think of it as a grid, as follows:
            # 7  0  1
            # 6 -1  2
            # 5  4  3
            directions = [7,0,1,6,-1,2,5,4,3]

            # But! If you look at the values that self.direction actually takes on during the game, you only see
            # numbers from 0 to 3. This is because although there are eight possible directions of travel, there are
            # only four orientations of the player vehicle. The same sprite, for example, is used if the player is
            # travelling either left or right. This is why the direction is ultimately clamped to a range of 0 to 4.
            # 0 = facing up or down
            # 1 = facing top right or bottom left
            # 2 = facing left or right
            # 3 = facing bottom right or top left

            # # It can be useful to think of the vehicle as being able to drive both forwards and backwards.

            # Choose the relevant direction from the above list, based on dx and dy
            dir = directions[dx+3*dy+4]

            # Every other frame, if the player is pressing a key to move in a particular direction, update the current
            # direction to rotate towards facing the new direction
            if self.timer % 2 == 0 and dir >= 0:

                # We first calculate the difference between the desired direction and the current direction.
                difference = (dir - self.direction)

                # We use the following list to decide how much to rotate by each frame, based on difference.
                # It's easiest to think about this by just considering the first four direction values - 0 to 3,
                # corresponding to facing up, to fit into the bottom right. However, because of the symmetry of the
                # player sprites as described above, these calculations work for all possible directions.
                # If there is no difference, no rotation is required.
                # If the difference is 1, we rotate by 1 (clockwise)
                # If the difference is 2, then the target direction is at right angles to the current direction,
                # so we have a free choice as to whether to turn clockwise or anti-clockwise to align with the
                # target direction. We choose clockwise.
                # If the difference is three, the symmetry of the player sprites means that we can reach the desired
                # animation frame by rotating one unit anti-clockwise.
                rotation_table = [0, 1, 1, -1]

                rotation = rotation_table[difference % 4]
                self.direction = (self.direction + rotation) % 4


            self.fire_timer -= 1

            # Fire cannon (or allow firing animation to finish)
            if self.fire_timer < 0 and (self.frame > 0 or keyboard.space):
                if self.frame == 0:
                    # Create a bullet
                    game.play_sound("laser")
                    game.bullets.append(Bullet((self.x, self.y - 8)))
                self.frame = (self.frame + 1) % 3
                self.fire_timer = Player.RELOAD_TIME

            # Check to see if any enemy segments collide with the player, as well as the flying enemy.
            # We create a list consisting of all enemy segments, and append another list containing only the
            # flying enemy.
            all_enemies = game.segments + [game.flying_enemy]
            for enemy in all_enemies:
                # The flying enemy might not exist, in which case its value
                # will be None. We cannot call a method or access any attributes
                # of a 'None' object, so we must first check for that case.
                # "if object:" is shorthand for "if object != None".
                if enemy and enemy.collidepoint(self.pos):
                    # Collision has occurred, check to see whether player is invulnerable
                    if self.timer > Player.INVULNERABILITY_TIME:
                        game.play_sound("player_explode")
                        game.explosions.append(Explosion(self.pos, 1))
                        self.alive = False
                        self.timer = 0
                        self.lives -= 1
        else:
            # Not alive
            # Wait a while before respawning
            if self.timer > Player.RESPAWN_TIME:
                # Respawn
                self.alive = True
                self.timer = 0
                self.pos = (240, 768)
                game.clear_rocks_for_respawn(*self.pos)     # Ensure there are no rocks at the player's respawn position

        # Display the player sprite if alive - BUT, if player is currently invulnerable, due to having just respawned,
        # switch between showing and not showing the player sprite on alternate frames
        invulnerable = self.timer > Player.INVULNERABILITY_TIME
        if self.alive and (invulnerable or self.timer % 2 == 0):
            self.image = "player" + str(self.direction) + str(self.frame)
        else:
            self.image = "blank"

class FlyingEnemy(Actor):
    def __init__(self, player_x):
        # Choose which side of the screen we start from. Don't start right next to the player as that would be
        # unfair - if not near player, start on a random side
        side = 1 if player_x < 160 else 0 if player_x > 320 else randint(0, 1)

        super().__init__("blank", (550*side-35, 688))

        # Always moves in the same X direction, but randomly pauses to just fly straight up or down
        self.moving_x = 1       # 0 if we're currently moving only vertically, 1 if moving along x axis (as well as y axis)
        self.dx = 1 - 2 * side  # Move left or right depending on which side of the screen we're on
        self.dy = choice([-1, 1])   # Start moving either up or down
        self.type = randint(0, 2)   # 3 different colours

        self.health = 1

        self.timer = 0

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

        # Move
        self.x += self.dx * self.moving_x * (3 - abs(self.dy))
        self.y += self.dy * (3 - abs(self.dx * self.moving_x))

        if self.y < 592 or self.y > 784:
            # Gone too high or low - reverse y direction
            self.moving_x = randint(0, 1)
            self.dy = -self.dy

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


class Rock(Actor):
    def __init__(self, x, y, totem=False):
        # Use a custom anchor point for totem rocks, which are taller than other rocks
        anchor = (24, 60) if totem else CENTRE_ANCHOR
        super().__init__("blank", cell2pos(x, y), anchor=anchor)

        self.type = randint(0, 3)

        if totem:
            # Totem rocks take five hits and give bonus points
            game.play_sound("totem_create")
            self.health = 5
            self.show_health = 5
        else:
            # Non-totem rocks are initially displayed as if they have one health, and animate until they
            # show the actualy sprite for their health level - resulting in a 'growing' animation.
            self.health = randint(3, 4)
            self.show_health = 1

        self.timer = 1

    def damage(self, amount, damaged_by_bullet=False):
        # Damage can occur by being hit by bullets, or by being destroyed by a segment, or by being cleared from the
        # player's respawn location. Points can be earned by hitting special "totem" rocks, which have 5 health, but
        # this should only happen when they are hit by a bullet.
        if damaged_by_bullet and self.health == 5:
            game.play_sound("totem_destroy")
            game.score += 100
        else:
            if amount > self.health - 1:
                game.play_sound("rock_destroy")
            else:
                game.play_sound("hit", 4)

        game.explosions.append(Explosion(self.pos, 2 * (self.health == 5)))
        self.health -= amount
        self.show_health = self.health

        self.anchor, self.pos = CENTRE_ANCHOR, self.pos

        # Return False if we've lost all our health, otherwise True
        return self.health < 1

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

        # Every other frame, update the growing animation
        if self.timer % 2 == 1 and self.show_health < self.health:
            self.show_health += 1

        if self.health == 5 and self.timer > 200:
            # Totem rocks turn into normal rocks if not shot within 200 frames
            self.damage(1)

        colour = str(max(game.wave, 0) % 3)
        health = str(max(self.show_health - 1, 0))
        self.image = "rock" + colour + str(self.type) + health


class Bullet(Actor):
    def __init__(self, pos):
        super().__init__("bullet", pos)

        self.done = False

    def update(self):
        # Move up the screen, 24 pixels per frame
        self.y -= 24

        # game.damage checks to see if there is a rock at the given position - if so, it damages
        # the rock and returns True
        # An asterisk before a list or tuple will unpack the contents into separate values
        grid_cell = pos2cell(*self.pos)
        if game.damage(*grid_cell, 1, True):
            # Hit a rock - destroy self
            self.done = True
        else:
            # Didn't hit a rock
            # Check each myriapod segment, and the flying enemy, to see if this bullet collides with them
            for obj in game.segments + [game.flying_enemy]:
                # Is this a valid object reference, and if so, does this bullet's location overlap with the
                # object's rectangle? (collidepoint is a method from Pygame's Rect class)
                if obj and obj.collidepoint(self.pos):
                    # Create explosion
                    game.explosions.append(Explosion(obj.pos, 2))

                    obj.health -= 1

                    # Is the object an instance of the Segment class?
                    if isinstance(obj, Segment):
                        # Should we create a new rock in the segment's place? Health must be zero, there must be no
                        # rock there already, and the player sprite must not overlap with the location
                        if obj.health == 0 and not game.grid[obj.cell_y][obj.cell_x] and game.allow_movement(game.player.x, game.player.y, obj.cell_x, obj.cell_y):
                            # Create new rock - 20% chance of being a totem
                            game.grid[obj.cell_y][obj.cell_x] = Rock(obj.cell_x, obj.cell_y, random() < .2)

                        game.play_sound("segment_explode")
                        game.score += 10
                    else:
                        # If it's not a segment, it must be the flying enemy
                        game.play_sound("meanie_explode")
                        game.score += 20

                    self.done = True    # Destroy self

                    # Don't continue the for loop, this bullet has hit something so shouldn't hit anything else
                    return


# SEGMENT MOVEMENT
# The code below creates several constants used in the Segment class in relation to movement and directions

# Each myriapod segment moves in relation to its current grid cell.
# A segment enters a cell from a particular edge (stored in 'in_edge' in the Segment class)
# After five frames it decides which edge it's going leave that cell through (stored in out_edge).
# For example, it might carry straight on and leave through the opposite edge from the one it started at.
# Or it might turn 90 degrees and leave through an edge to its left or right.
# In this case it initially turn 45 degrees and continues along that path for 8 frames. It then turns another
# 45 degrees, at which point they are heading directly towards the next grid cell.
# A segment spends a total of 16 frames in each cell. Within the update method, the variable 'phase' refers to
# where it is in that cycle - 0 meaning it's just entered a grid cell, and 15 meaning it's about to leave it.

# Let's imagine the case where a segment enters from the left edge of a cell and then turns to leave from the
# bottom edge. The segment will initially move along the horizontal (X) axis, and will end up moving along the
# vertical (Y) axis. In this case we'll call the X axis the primary axis, and the Y axis the secondary axis.
# The lists SECONDARY_AXIS_SPEED and SECONDARY_AXIS_POSITIONS are used to determine the movement of the segment.
# This is explained in more detail in the Segment.update method.


# In Python, multiplying a list by a number creates a list where the contents
# are repeated the specified number of times. So the code below is equivalent to:
# SECONDARY_AXIS_SPEED = [0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1 , 1, 2, 2, 2, 2]
# This list represents how much the segment moves along the secondary axis, in situations where it makes two 45° turns
# as described above. For the first four frames it doesn't move at all along the secondary axis. For the next eight
# frames it moves at one pixel per frame, then for the last four frames it moves at two pixels per frame.
SECONDARY_AXIS_SPEED = [0]*4 + [1]*8 + [2]*4


# The code below creates a list of 16 elements, where each element is the sum of all the equivalent elements in the
# SECONDARY_AXIS_SPEED list up to that point.
# It is equivalent to writing:
# SECONDARY_AXIS_POSITIONS = [0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14]
# This list stores the total secondary axis movement that will have occurred at each phase in the segment's movement
# through the current grid cell (if the segment is turning)
SECONDARY_AXIS_POSITIONS = [sum(SECONDARY_AXIS_SPEED[:i]) for i in range(16)]


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

# 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
DX = [0,1,0,-1]
DY = [-1,0,1,0]

def inverse_direction(dir):
    if dir == DIRECTION_UP:
        return DIRECTION_DOWN
    elif dir == DIRECTION_RIGHT:
        return DIRECTION_LEFT
    elif dir == DIRECTION_DOWN:
        return DIRECTION_UP
    elif dir == DIRECTION_LEFT:
        return DIRECTION_RIGHT

def is_horizontal(dir):
    return dir == DIRECTION_LEFT or dir == DIRECTION_RIGHT


class Segment(Actor):
    def __init__(self, cx, cy, health, fast, head):
        super().__init__("blank")

        # Grid cell positions
        self.cell_x = cx
        self.cell_y = cy

        self.health = health

        # Determines whether the 'fast' version of the sprite is used. Note that the actual speed of the myriapod is
        # determined by how much time is included in the State.update method
        self.fast = fast

        self.head = head        # Should this segment use the head sprite?

        # Each myriapod segment moves in a defined pattern within its current cell, before moving to the next one.
        # It will start at one of the edges - represented by a number, where 0=down,1=right,2=up,3=left
        # self.in_edge stores the edge through which it entered the cell.
        # Several frames after entering a cell, it chooses which edge to leave through - stored in out_edge
        # The path it follows is explained in the update and rank methods
        self.in_edge = DIRECTION_LEFT
        self.out_edge = DIRECTION_RIGHT

        self.disallow_direction = DIRECTION_UP      # Prevents segment from moving in a particular direction
        self.previous_x_direction = 1               # Used to create winding/snaking motion

    def rank(self):
        # The rank method creates and returns a function. Don't worry if this seems a strange concept - it is
        # fairly advanced stuff. The returned function is passed to Python's 'min' function in the update method,
        # as the 'key' optional parameter. min then calls this function with the numbers 0 to 3, representing the four
        # directions

        def inner(proposed_out_edge):
            # proposed_out_edge is a number between 0 and 3, representing a possible direction to move - see DIRECTION_UP etc and DX/DY above
            # This function returns a tuple consisting of a series of factors determining which grid cell the segment should try to move into next.
            # These are not absolute rules - rather they are used to rank the four directions in order of preference,
            # i.e. which direction is the best (or at least, least bad) to move in. The factors are boolean (True or False)
            # values. A value of False is preferable to a value of True.
            # The order of the factors in the returned tuple determines their importance in deciding which way to go,
            # with the most important factor coming first.
            new_cell_x = self.cell_x + DX[proposed_out_edge]
            new_cell_y = self.cell_y + DY[proposed_out_edge]

            # Does this direction take us to a cell which is outside the grid?
            # Note: when the segments start, they are all outside the grid so this would be True, except for the case of
            # walking onto the top-left cell of the grid. But the end result of this and the following factors is that
            # it will still be allowed to continue walking forwards onto the screen.
            out = new_cell_x < 0  or new_cell_x > num_grid_cols - 1 or new_cell_y < 0 or new_cell_y > num_grid_rows - 1

            # We don't want it to to turn back on itself..
            turning_back_on_self = proposed_out_edge == self.in_edge

            # ..or go in a direction that's disallowed (see comments in update method)
            direction_disallowed = proposed_out_edge == self.disallow_direction

            # Check to see if there's a rock at the proposed new grid cell.
            # rock will either be the Rock object at the new grid cell, or None.
            # It will be set to None if there is no Rock object is at the new location, or if the new location is
            # outside the grid. We also have to account for the special case where the segment is off the left-hand
            # side of the screen on the first row, where it is initially created. We mustn't try to access that grid
            # cell (unlike most languages, in Python trying to access a list index with negative value won't necessarily
            # result in a crash, but it's still not a good idea)
            if out or (new_cell_y == 0 and new_cell_x < 0):
                rock = None
            else:
                rock = game.grid[new_cell_y][new_cell_x]

            rock_present = rock != None

            # Is new cell already occupied by another segment, or is another segment trying to enter my cell from
            # the opposite direction?
            occupied_by_segment = (new_cell_x, new_cell_y) in game.occupied or (self.cell_x, self.cell_y, proposed_out_edge) in game.occupied

            # Prefer to move horizontally, unless there's a rock in the way.
            # If there are rocks both horizontally and vertically, prefer to move vertically
            if rock_present:
                horizontal_blocked = is_horizontal(proposed_out_edge)
            else:
                horizontal_blocked = not is_horizontal(proposed_out_edge)

            # Prefer not to go in the previous horizontal direction after we move up/down
            same_as_previous_x_direction = proposed_out_edge == self.previous_x_direction

            # Finally we create and return a tuple of factors determining which cell segment should try to move into next.
            # Most important first - e.g. we shouldn't enter a new cell if if's outside the grid
            return (out, turning_back_on_self, direction_disallowed, occupied_by_segment, rock_present, horizontal_blocked, same_as_previous_x_direction)

        return inner

    def update(self):
        # Segments take either 16 or 8 frames to pass through each grid cell, depending on the amount by which
        # game.time is updated each frame. phase will be a number between 0 and 15 indicating where we're at
        # in that cycle.
        phase = game.time % 16

        if phase == 0:
            # At this point, the segment is entering a new grid cell. We first update our current grid cell coordinates.
            self.cell_x += DX[self.out_edge]
            self.cell_y += DY[self.out_edge]

            # We then need to update in_edge. If, for example, we left the previous cell via its right edge, that means
            # we're entering the new cell via its left edge.
            self.in_edge = inverse_direction(self.out_edge)

            # During normal gameplay, once a segment reaches the bottom of the screen, it starts moving up again.
            # Once it reaches row 18, it starts moving down again, so that it remains a threat to the player.
            # During the title screen, we allow segments to go all the way back up to the top of the screen.
            if self.cell_y == (18 if game.player else 0):
                self.disallow_direction = DIRECTION_UP
            if self.cell_y == num_grid_rows-1:
                self.disallow_direction = DIRECTION_DOWN

        elif phase == 4:
            # At this point we decide which new cell we're going to go into (and therefore, which edge of the current
            # cell we will leave via - to be stored in out_edge)
            # range(4) generates all the numbers from 0 to 3 (corresponding to DIRECTION_UP etc)
            # Python's built-in 'min' function usually chooses the lowest number, so would usually return 0 as the result.
            # But if the optional 'key' argument is specified, this changes how the function determines the result.
            # The rank function (see above) returns a function (named 'inner' in rank), which min calls to decide
            # how the items should be ordered. The argument to inner represents a possible direction to move in.
            # The 'inner' function returns a tuple of boolean values - for example: (True,False,False,True,etc..)
            # When Python compares two such tuples, it considers values of False to be less than values of True,
            # and values that come earlier in the sequence are more significant than later values. So (False,True)
            # would be considered less than (True,False).
            self.out_edge = min(range(4), key = self.rank())

            if is_horizontal(self.out_edge):
                self.previous_x_direction = self.out_edge

            new_cell_x = self.cell_x + DX[self.out_edge]
            new_cell_y = self.cell_y + DY[self.out_edge]

            # Destroy any rock that might be in the new cell
            if new_cell_x >= 0 and new_cell_x < num_grid_cols:
                game.damage(new_cell_x, new_cell_y, 5)

            # Set new cell as occupied. It's a case of whichever segment is processed first, gets first dibs on a cell
            # The second line deals with the case where two segments are moving towards each other and are in
            # neighbouring cells. It allows a segment to tell if another segment trying to enter its cell from
            # the opposite direction
            game.occupied.add((new_cell_x, new_cell_y))
            game.occupied.add((new_cell_x, new_cell_y, inverse_direction(self.out_edge)))

        # turn_idx tells us whether the segment is going to be making a 90 degree turn in the current cell, or moving
        # in a straight line. 1 = anti-clockwise turn, 2 = straight ahead, 3 = clockwise turn, 0 = leaving through same
        # edge from which we entered (unlikely to ever happen in practice)
        turn_idx = (self.out_edge - self.in_edge) % 4

        # Calculate segment offset in the cell, measured from the cell's centre
        # We start off assuming that the segment is starting from the top of the cell - i.e. self.in_edge being DIRECTION_UP,
        # corresponding to zero. The primary and secondary axes, as described under "SEGMENT MOVEMENT" above, are Y and X.
        # We then apply a calculation to rotate these X and Y offsets, based on the actual direction the segment is coming from.
        # Let's take as an example the case where the segment is moving in a straight line from top to bottom.
        # We calculate offset_x by multiplying SECONDARY_AXIS_POSITIONS[phase] by 2-turn_idx. In this case, turn_idx
        # will be 2.  So 2 - turn_idx will be zero. Multiplying anything by zero gives zero, so we end up with no
        # movement on the X axis - which is what we want in this case.
        # The starting point for the offset_y calculation is that the segment starts at an offset of -16 and must cover
        # 32 pixels over the 16 phases - therefore we must multiply phase by 2. We then subtract the result of the
        # previous line, in which stolen_y_movement was calculated by multiplying SECONDARY_AXIS_POSITIONS[phase] by
        # turn_idx % 2.  mod 2 gives either zero (if turn_idx is 0 or 2), or 1 if it's 1 or 3. In the case we're looking
        # at, turn_idx is 2, so stolen_y_movement is zero.
        # The end result of all this is that in the case where the segment is moving in a straight line through a cell,
        # it just moves at 2 pixels per frame along the primary axis. If it's turning, it starts out moving at 2px
        # per frame on the primary axis, but then starts moving along the secondary axis based on the values in
        # SECONDARY_AXIS_POSITIONS. In this case we don't want it to continue moving along the primary axis - it should
        # initially slow to moving at 1px per phase, and then stop moving completely. Effectively, the secondary axis
        # is stealing movement from the primary axis - hence the name 'stolen_y_movement'
        offset_x = SECONDARY_AXIS_POSITIONS[phase] * (2 - turn_idx)
        stolen_y_movement = (turn_idx % 2) * SECONDARY_AXIS_POSITIONS[phase]
        offset_y = -16 + (phase * 2) - stolen_y_movement

        # A rotation matrix is a set of numbers which, when multiplied by a set of coordinates, result in those
        # coordinates being rotated. Recall that the code above  makes the assumption that segment is starting from the
        # top edge of the cell and moving down. The code below chooses the appropriate rotation matrix based on the
        # actual edge the segment started from, and then modifies offset_x and offset_y based on this rotation matrix.
        rotation_matrix = [[1,0,0,1],[0,-1,1,0],[-1,0,0,-1],[0,1,-1,0]][self.in_edge]
        offset_x, offset_y = offset_x * rotation_matrix[0] + offset_y * rotation_matrix[1], offset_x * rotation_matrix[2] + offset_y * rotation_matrix[3]

        # Finally, we can calculate the segment's position on the screen. See cell2pos function above.
        self.pos = cell2pos(self.cell_x, self.cell_y, offset_x, offset_y)

        # We now need to decide which image the segment should use as its sprite.
        # Images for segment sprites follow the format 'segABCDE' where A is 0 or 1 depending on whether this is a
        # fast-moving segment, B is 0 or 1 depending on whether we currently have 1 or 2 health, C is whether this
        # is the head segment of a myriapod, D represents the direction we're facing (0 = up, 1 = top right,
        # up to 7 = top left) and E is how far we are through the walking animation (0 to 3)

        # Three variables go into the calculation of the direction. turn_idx tells us if we're making a turn in this
        # cell - and if so, whether we're turning clockwise or anti-clockwise. self.in_edge tells us which side of the
        # grid cell we entered from. And we can use SECONDARY_AXIS_SPEED[phase] to find out whether we should be facing
        # along the primary axis, secondary axis or diagonally between them.
        # (turn_idx - 2) gives 0 if straight, -1 if turning anti-clockwise, 1 if turning clockwise
        # Multiplying this by SECONDARY_AXIS_SPEED[phase] gives 0 if we're not doing a turn in this cell, or if
        # we are going to be turning but have not yet begun to turn. If we are doing a turn in this cell, and we're
        # at a phase where we should be showing a sprite with a new rotation, the result will be -1 or 1 if we're
        # currently in the first (45°) part of a turn, or -2 or 2 if we have turned 90°.
        # The next part of the calculation multiplies in_edge by 2 and then adds the result to the result of the previous
        # part. in_edge will be a number from 0 to 3, representing all possible directions in 90° increments.
        # It must be multiplied by two because the direction value we're calculating will be a number between 0 and 7,
        # representing all possible directions in 45° increments.
        # In the sprite filenames, the penultimate number represents the direction the sprite is facing, where a value
        # of zero means it's facing up. But in this code, if, for example, in_edge were zero, this means the segment is
        # coming from the top edge of its cell, and therefore should be facing down. So we add 4 to account for this.
        # After all this, we may have ended up with a number outside the desired range of 0 to 7. So the final step
        # is to MOD by 8.
        direction = ((SECONDARY_AXIS_SPEED[phase] * (turn_idx - 2)) + (self.in_edge * 2) + 4) % 8

        leg_frame = phase // 4  # 16 phase cycle, 4 frames of animation

        # Converting a boolean value to an integer gives 0 for False and 1 for True. We then need to convert the
        # result to a string, as an integer can't be appended to a string.
        self.image = "seg" + str(int(self.fast)) + str(int(self.health == 2)) + str(int(self.head)) + str(direction) + str(leg_frame)

class Game:
    def __init__(self, player=None):
        self.wave = -1
        self.time = 0

        self.player = player

        # Create empty grid of 14 columns, 25 rows, each element intially just containing the value 'None'
        # Rocks will be added to the grid later
        self.grid = [[None] * num_grid_cols for y in range(num_grid_rows)]

        self.bullets = []
        self.explosions = []
        self.segments = []

        self.flying_enemy = None

        self.score = 0

    def damage(self, cell_x, cell_y, amount, from_bullet=False):
        # Find the rock at this grid cell (or None if no rock here)
        rock = self.grid[cell_y][cell_x]

        if rock != None:
            # rock.damage returns False if the rock has lost all its health - in this case, the grid cell will be set
            # to None, overwriting the rock object reference
            if rock.damage(amount, from_bullet):
                self.grid[cell_y][cell_x] = None

        # Return whether or not there was a rock at this position
        return rock != None

    def allow_movement(self, x, y, ax=-1, ay=-1):
        # ax/ay are only supplied when a segment is being destroyed, and we check to see if we should create a new
        # rock in the segment's place. They indicate a grid cell location where we're planning to create the new rock,
        # we need to ensure the new rock would not overlap with the player sprite

        # Don't go off edge of screen or above the player zone
        if x < 40 or x > 440 or y < 592 or y > 784:
            return False

        # Get coordinates of corners of player sprite's collision rectangle
        x0, y0 = pos2cell(x-18, y-10)
        x1, y1 = pos2cell(x+18, y+10)

        # Check each corner against grid
        for yi in range(y0, y1+1):
            for xi in range(x0, x1+1):
                if self.grid[yi][xi] or xi == ax and yi == ay:
                    return False

        return True

    def clear_rocks_for_respawn(self, x, y):
        # Destroy any rocks that might be overlapping with the player when they respawn
        # Could be more than one rock, hence the loop
        x0, y0 = pos2cell(x-18, y-10)
        x1, y1 = pos2cell(x+18, y+10)

        for yi in range(y0, y1+1):
            for xi in range(x0, x1+1):
                self.damage(xi, yi, 5)

    def update(self):
        # Increment time - used by segments. Time moves twice as fast every fourth wave.
        self.time += (2 if self.wave % 4 == 3 else 1)

        # At the start of each frame, we reset occupied to be an empty set. As each individual myriapod segment is
        # updated, it will create entries in the occupied set to indicate that other segments should not attempt to
        # enter its current grid cell. There are two types of entries that are created in the occupied set. One is a
        # tuple consisting of a pair of numbers, representing grid cell coordinates. The other is a tuple consisting of
        # three numbers - the first two being grid cell coordinates, the third representing an edge through which a
        # segment is trying to enter a cell.
        # It is only used for myriapod segments - not rocks. Those are stored in self.grid.
        self.occupied = set()

        # Call update method on all objects. grid is a list of lists, equivalent to a 2-dimensional array,
        # so sum can be used to produce a single list containing all grid objects plus the contents of the other
        # Actor lists. The player and flying enemy, which are object references rather than lists, are appended as single-item lists.
        all_objects = sum(self.grid, self.bullets + self.segments + self.explosions + [self.player] + [self.flying_enemy])
        for obj in all_objects:
            if obj:
                obj.update()

        # Recreate the bullets list, which will contain all existing bullets except those which have gone off the screen or have hit something
        self.bullets = [b for b in self.bullets if b.y > 0 and not b.done]

        # Recreate the explosions list, which will contain all existing explosions except those which have completed their animations
        self.explosions = [e for e in self.explosions if not e.timer == 31]

        # Recreate the segments list, which will contain all existing segments except those whose health is zero
        self.segments = [s for s in self.segments if s.health > 0]

        if self.flying_enemy:
            # Destroy flying enemy if it goes off the left or right sides of the screen, or health is zero
            if self.flying_enemy.health <= 0 or self.flying_enemy.x < -35 or self.flying_enemy.x > 515:
                self.flying_enemy = None
        elif random() < .01:    # If there is no flying enemy, small chance of creating one each frame
            self.flying_enemy = FlyingEnemy(self.player.x if self.player else 240)

        if self.segments == []:
            # No myriapod segments - start a new wave
            # First, ensure there are enough rocks. Count the number of rocks in the grid and if there aren't enough,
            # create one per frame. Initially there should be 30 rocks - each wave, this goes up by one.
            num_rocks = 0
            for row in self.grid:
                for element in row:
                    if element != None:
                        num_rocks += 1
            if num_rocks < 31+self.wave:
                while True:
                    x, y = randint(0, num_grid_cols-1), randint(1, num_grid_rows-3)     # Leave last 2 rows rock-free
                    if self.grid[y][x] == None:
                        self.grid[y][x] = Rock(x, y)
                        break
            else:
                # New wave and enough rocks - create a new myriapod
                game.play_sound("wave")
                self.wave += 1
                self.time = 0
                self.segments = []
                num_segments = 8 + self.wave // 4 * 2   # On the first four waves there are 8 segments - then 10, and so on
                for i in range(num_segments):
                    if DEBUG_TEST_RANDOM_POSITIONS:
                        cell_x, cell_y = randint(1, 7), randint(1, 7)
                    else:
                        cell_x, cell_y = -1-i, 0
                    # Determines whether segments take one or two hits to kill, based on the wave number.
                    # e.g. on wave 0 all segments take one hit; on wave 1 they alternate between one and two hits
                    health = [[1,1],[1,2],[2,2],[1,1]][self.wave % 4][i % 2]
                    fast = self.wave % 4 == 3   # Every fourth myriapod moves faster than usual
                    head = i == 0           # The first segment of each myriapod is the head
                    self.segments.append(Segment(cell_x, cell_y, health, fast, head))

        return self

    def draw(self):
        screen.blit("bg" + str(max(self.wave, 0) % 3), (0, 0))

        # Create a list of all grid locations and other objects which need to be drawn
        # (Most grid locations will be set to None as they are unoccupied, hence the check "if obj:" further down)
        all_objs = sum(self.grid, self.bullets + self.segments + self.explosions + [self.player])

        # We want to draw objects in order based on their Y position. 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 them. The following function specifies the criteria
        # used to decide how the objects are sorted.
        def sort_key(obj):
            # Returns a tuple consisting of two elements. The first is whether the object is an instance of the
            # Explosion class (True or False). A value of true means it will be displayed in front of other objects.
            # The second element is a number - either the objects why position, or zero if obj is 'None'
            return (isinstance(obj, Explosion), obj.y if obj else 0)

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

        # Draw the flying enemy on top of everything else
        all_objs.append(self.flying_enemy)

        # Draw the objects
        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)

# 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():
            state = State.PLAY
            game = Game(Player((240, 768)))  # Create new Game object, with a Player object

        game.update()

    elif state == State.PLAY:
        if game.player.lives == 0 and game.player.timer == 100:
            sounds.gameover.play()
            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():
    # Draw the game, which covers both the game during gameplay but also the game displaying in the background
    # during the main menu and game over screens
    game.draw()

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

        # 14 frames of animation for "Press space to start", updating every 4 frames
        screen.blit("space" + str((game.time // 4) % 14), (0, 420))

    elif state == State.PLAY:
        # Display number of lives
        for i in range(game.player.lives):
            screen.blit("life", (i*40+8, 4))

        # Display score
        score = str(game.score)
        for i in range(1, len(score)+1):
            # In Python, a negative index into a list (or in this case, into a string) gives you items in reverse order,
            # e.g. 'hello'[-1] gives 'o', 'hello'[-2] gives 'l', etc.
            digit = score[-i]
            screen.blit("digit"+digit, (468-i*24, 5))

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

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

    music.play("theme")
    music.set_volume(0.4)
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()