0% found this document useful (0 votes)
40 views

Pygame Zero

Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
40 views

Pygame Zero

Copyright
© © All Rights Reserved
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 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