Pygame Zero
Pygame Zero
Pygame Zero
# 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.
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
# We're using a Python feature called list comprehension - this is explained in the Bubble
Bobble/Cavern chapter.
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
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.
# 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.
class Explosion(Actor):
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
class Player(Actor):
INVULNERABILITY_TIME = 100
RESPAWN_TIME = 100
RELOAD_TIME = 10
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
self.timer = 0
self.fire_timer = 0
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
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.
self.move(dx, 0, 3 - abs(dy))
# 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
# 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.
#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
# # 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
# We first calculate the difference between the desired direction and the current
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 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
# If the difference is three, the symmetry of the player sprites means that we can
reach the desired
rotation = rotation_table[difference % 4]
self.fire_timer -= 1
if self.frame == 0:
# Create a bullet
game.play_sound("laser")
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]
# The flying enemy might not exist, in which case its value
game.play_sound("player_explode")
game.explosions.append(Explosion(self.pos, 1))
self.alive = False
self.timer = 0
self.lives -= 1
else:
# Not alive
# Respawn
self.alive = True
self.timer = 0
# 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
else:
self.image = "blank"
class FlyingEnemy(Actor):
# Choose which side of the screen we start from. Don't start right next to the player as
that would be
side = 1 if player_x < 160 else 0 if player_x > 320 else randint(0, 1)
# Always moves in the same X direction, but randomly pauses to just fly straight up or
down
self.dx = 1 - 2 * side # Move left or right depending on which side of the screen we're on
self.health = 1
self.timer = 0
def update(self):
self.timer += 1
# Move
self.moving_x = randint(0, 1)
self.dy = -self.dy
class Rock(Actor):
# Use a custom anchor point for totem rocks, which are taller than other rocks
self.type = randint(0, 3)
if totem:
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
# 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
game.play_sound("totem_destroy")
game.score += 100
else:
game.play_sound("rock_destroy")
else:
game.play_sound("hit", 4)
self.health -= amount
self.show_health = self.health
def update(self):
self.timer += 1
self.show_health += 1
# Totem rocks turn into normal rocks if not shot within 200 frames
self.damage(1)
colour = str(max(game.wave, 0) % 3)
class Bullet(Actor):
super().__init__("bullet", pos)
self.done = False
def update(self):
self.y -= 24
# game.damage checks to see if there is a rock at the given position - if so, it damages
# 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):
self.done = True
else:
# Check each myriapod segment, and the flying enemy, to see if this bullet collides with
them
# Is this a valid object reference, and if so, does this bullet's location overlap with
the
# Create explosion
game.explosions.append(Explosion(obj.pos, 2))
obj.health -= 1
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
game.play_sound("segment_explode")
game.score += 10
else:
game.play_sound("meanie_explode")
game.score += 20
return
# SEGMENT MOVEMENT
# The code below creates several constants used in the Segment class in relation to movement
and directions
# 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.
# 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.
# The code below creates a list of 16 elements, where each element is the sum of all the
equivalent elements in the
# It is equivalent to writing:
# This list stores the total secondary axis movement that will have occurred at each phase in
the segment's movement
DIRECTION_UP = 0
DIRECTION_RIGHT = 1
DIRECTION_DOWN = 2
DIRECTION_LEFT = 3
# 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
return DIRECTION_LEFT
return DIRECTION_UP
return DIRECTION_RIGHT
def is_horizontal(dir):
super().__init__("blank")
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
self.fast = fast
# Each myriapod segment moves in a defined pattern within its current cell, before
moving to the next one.
# Several frames after entering a cell, it chooses which edge to leave through - stored in
out_edge
self.out_edge = DIRECTION_RIGHT
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):
# 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)
# The order of the factors in the returned tuple determines their importance in
deciding which way to go,
# 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
# 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)
rock = None
else:
rock = game.grid[new_cell_y][new_cell_x]
# If there are rocks both horizontally and vertically, prefer to move vertically
if rock_present:
horizontal_blocked = is_horizontal(proposed_out_edge)
else:
# 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
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.
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
# 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.
# 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)
self.previous_x_direction = self.out_edge
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
game.occupied.add((new_cell_x, new_cell_y))
# turn_idx tells us whether the segment is going to be making a 90 degree turn in the
current cell, or moving
# 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.
# will be 2. So 2 - turn_idx will be zero. Multiplying anything by zero gives zero, so we end
up with no
# 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
# 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
# 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
# is stealing movement from the primary axis - hence the name 'stolen_y_movement'
# 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]
# Finally, we can calculate the segment's position on the screen. See cell2pos function
above.
# 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
# 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
# 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
# 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,
# 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.
# Converting a boolean value to an integer gives 0 for False and 1 for True. We then need
to convert the
class Game:
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'
self.explosions = []
self.segments = []
self.flying_enemy = None
self.score = 0
# 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
if rock.damage(amount, from_bullet):
self.grid[cell_y][cell_x] = None
# 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
return False
return False
return True
# Destroy any rocks that might be overlapping with the player when they respawn
self.damage(xi, yi, 5)
def update(self):
# Increment time - used by segments. Time moves twice as fast every fourth wave.
# 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
# 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.
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
# Recreate the explosions list, which will contain all existing explosions except those which
have completed their animations
# Recreate the segments list, which will contain all existing segments except those whose
health is zero
if self.flying_enemy:
# Destroy flying enemy if it goes off the left or right sides of the screen, or health is
zero
self.flying_enemy = None
elif random() < .01: # If there is no flying enemy, small chance of creating one each
frame
if self.segments == []:
# create one per frame. Initially there should be 30 rocks - each wave, this goes up by
one.
num_rocks = 0
if element != None:
num_rocks += 1
while True:
if self.grid[y][x] == None:
self.grid[y][x] = Rock(x, y)
break
else:
game.play_sound("wave")
self.wave += 1
self.time = 0
self.segments = []
for i in range(num_segments):
if DEBUG_TEST_RANDOM_POSITIONS:
# 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
return self
def draw(self):
# 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)
# 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
# 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'
all_objs.sort(key=sort_key)
all_objs.append(self.flying_enemy)
if obj:
obj.draw()
# 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:
# This automatically loads and plays a file named 'explosion.wav' (or .ogg) from the
sounds folder (if
# 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.play()
except Exception as e:
print(e)
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:
return False
else:
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():
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()
state = State.GAME_OVER
else:
game.update()
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
game.draw()
if state == State.MENU:
# Display logo
# Display score
score = str(game.score)
# In Python, a negative index into a list (or in this case, into a string) gives you items in
reverse order,
digit = score[-i]
try:
pygame.mixer.quit()
music.play("theme")
music.set_volume(0.4)
except:
pass
# Set the initial game state
state = State.MENU
game = Game()
pgzrun.go()