Pygame Zero

Download as pdf or txt
Download as pdf or txt
You are on page 1of 41

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

You might also like