Myriapod
Attribution
Code the Classics – Volume 1, Chapter 4 Fixed Shooter, page 129.
Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.
Original Python code
import pgzero, pgzrun, pygame, sys
from random import choice, randint, random
from enum import Enum
# Check Python version number. sys.version_info gives version as a tuple, e.g. if (3,7,2,'final',0) for version 3.7.2.
# Unlike many languages, Python can compare two tuples in the same way that you can compare numbers.
if sys.version_info < (3,5):
print("This game requires at least version 3.5 of Python. Please download it from www.python.org")
sys.exit()
# Check Pygame Zero version. This is a bit trickier because Pygame Zero only lets us get its version number as a string.
# So we have to split the string into a list, using '.' as the character to split on. We convert each element of the
# version number into an integer - but only if the string contains numbers and nothing else, because it's possible for
# a component of the version to contain letters as well as numbers (e.g. '2.0.dev0')
# We're using a Python feature called list comprehension - this is explained in the Bubble Bobble/Cavern chapter.
pgzero_version = [int(s) if s.isnumeric() else s for s in pgzero.__version__.split('.')]
if pgzero_version < [1,2]:
print("This game requires at least version 1.2 of Pygame Zero. You have version {0}. Please upgrade using the command 'pip3 install --upgrade pgzero'".format(pgzero.__version__))
sys.exit()
WIDTH = 480
HEIGHT = 800
TITLE = "Myriapod"
DEBUG_TEST_RANDOM_POSITIONS = False
# Pygame Zero allows you to access and change sprite positions based on various
# anchor points
CENTRE_ANCHOR = ("center", "center")
num_grid_rows = 25
num_grid_cols = 14
# Convert a position in pixel units to a position in grid units. In this game, a grid square is 32 pixels.
def pos2cell(x, y):
return ((int(x)-16)//32, int(y)//32)
# Convert grid cell position to pixel coordinates, with a given offset
def cell2pos(cell_x, cell_y, x_offset=0, y_offset=0):
# If the requested offset is zero, returns the centre of the requested cell, hence the +16. In the case of the
# X axis, there's a 16 pixel border at the left and right of the screen, hence +16 becomes +32.
return ((cell_x * 32) + 32 + x_offset, (cell_y * 32) + 16 + y_offset)
class Explosion(Actor):
def __init__(self, pos, type):
super().__init__("blank", pos)
self.type = type
self.timer = 0
def update(self):
self.timer += 1
# Set sprite based on explosion type and timer - update to a new image
# every four frames
self.image = "exp" + str(self.type) + str(self.timer // 4)
class Player(Actor):
INVULNERABILITY_TIME = 100
RESPAWN_TIME = 100
RELOAD_TIME = 10
def __init__(self, pos):
super().__init__("blank", pos)
# These determine which frame of animation the player sprite will use
self.direction = 0
self.frame = 0
self.lives = 3
self.alive = True
# timer is used for animation, respawning and for ensuring the player is
# invulnerable immediately after respawning
self.timer = 0
# When the player shoots, this is set to RELOAD_TIME - it then counts
# down - when it reaches zero the player can shoot again
self.fire_timer = 0
def move(self, dx, dy, speed):
# dx and dy will each be either 0, -1 or 1. speed is an integer indicating
# how many pixels we should move in the specified direction.
for i in range(speed):
# For each pixel we want to move, we must first check if it's a valid place to move to
if game.allow_movement(self.x + dx, self.y + dy):
self.x += dx
self.y += dy
def update(self):
self.timer += 1
if self.alive:
# Get keyboard input. dx and dy represent the direction the player is facing on each axis
dx = 0
if keyboard.left:
dx = -1
elif keyboard.right:
dx = 1
dy = 0
if keyboard.up:
dy = -1
elif keyboard.down:
dy = 1
# Move in the relevant directions by the specified number of pixels. The purpose of 3 - abs(dy) is to
# generate vectors which look either like (3,0) (which is 3 units long) or (2, 2) (which is sqrt(8) long)
# so we move roughly the same distance regardless of whether we're travelling straight along the x or y axis.
# or at 45 degrees. Without this, we would move noticeably faster when travelling diagonally.
self.move(dx, 0, 3 - abs(dy))
self.move(0, dy, 3 - abs(dx))
# When the player presses a key to start handing in a new direction, we don't want the sprite to just
# instantly change to facing in that new direction. That would look wrong, since in the real world vehicles
# can't just suddenly change direction in the blink of an eye.
# Instead, we want the vehicle to turn to face the new direction over several frames. If the vehicle is
# currently facing down, and the player presses the left arrow key, the vehicle should first turn to face
# diagonally down and to the left, and then turn to face left.
# Each number in the following list corresponds to a direction - 0 is up, 1 is up and to the right, and
# so on in clockwise order. -1 means no direction.
# Think of it as a grid, as follows:
# 7 0 1
# 6 -1 2
# 5 4 3
directions = [7,0,1,6,-1,2,5,4,3]
# But! If you look at the values that self.direction actually takes on during the game, you only see
# numbers from 0 to 3. This is because although there are eight possible directions of travel, there are
# only four orientations of the player vehicle. The same sprite, for example, is used if the player is
# travelling either left or right. This is why the direction is ultimately clamped to a range of 0 to 4.
# 0 = facing up or down
# 1 = facing top right or bottom left
# 2 = facing left or right
# 3 = facing bottom right or top left
# # It can be useful to think of the vehicle as being able to drive both forwards and backwards.
# Choose the relevant direction from the above list, based on dx and dy
dir = directions[dx+3*dy+4]
# Every other frame, if the player is pressing a key to move in a particular direction, update the current
# direction to rotate towards facing the new direction
if self.timer % 2 == 0 and dir >= 0:
# We first calculate the difference between the desired direction and the current direction.
difference = (dir - self.direction)
# We use the following list to decide how much to rotate by each frame, based on difference.
# It's easiest to think about this by just considering the first four direction values - 0 to 3,
# corresponding to facing up, to fit into the bottom right. However, because of the symmetry of the
# player sprites as described above, these calculations work for all possible directions.
# If there is no difference, no rotation is required.
# If the difference is 1, we rotate by 1 (clockwise)
# If the difference is 2, then the target direction is at right angles to the current direction,
# so we have a free choice as to whether to turn clockwise or anti-clockwise to align with the
# target direction. We choose clockwise.
# If the difference is three, the symmetry of the player sprites means that we can reach the desired
# animation frame by rotating one unit anti-clockwise.
rotation_table = [0, 1, 1, -1]
rotation = rotation_table[difference % 4]
self.direction = (self.direction + rotation) % 4
self.fire_timer -= 1
# Fire cannon (or allow firing animation to finish)
if self.fire_timer < 0 and (self.frame > 0 or keyboard.space):
if self.frame == 0:
# Create a bullet
game.play_sound("laser")
game.bullets.append(Bullet((self.x, self.y - 8)))
self.frame = (self.frame + 1) % 3
self.fire_timer = Player.RELOAD_TIME
# Check to see if any enemy segments collide with the player, as well as the flying enemy.
# We create a list consisting of all enemy segments, and append another list containing only the
# flying enemy.
all_enemies = game.segments + [game.flying_enemy]
for enemy in all_enemies:
# The flying enemy might not exist, in which case its value
# will be None. We cannot call a method or access any attributes
# of a 'None' object, so we must first check for that case.
# "if object:" is shorthand for "if object != None".
if enemy and enemy.collidepoint(self.pos):
# Collision has occurred, check to see whether player is invulnerable
if self.timer > Player.INVULNERABILITY_TIME:
game.play_sound("player_explode")
game.explosions.append(Explosion(self.pos, 1))
self.alive = False
self.timer = 0
self.lives -= 1
else:
# Not alive
# Wait a while before respawning
if self.timer > Player.RESPAWN_TIME:
# Respawn
self.alive = True
self.timer = 0
self.pos = (240, 768)
game.clear_rocks_for_respawn(*self.pos) # Ensure there are no rocks at the player's respawn position
# Display the player sprite if alive - BUT, if player is currently invulnerable, due to having just respawned,
# switch between showing and not showing the player sprite on alternate frames
invulnerable = self.timer > Player.INVULNERABILITY_TIME
if self.alive and (invulnerable or self.timer % 2 == 0):
self.image = "player" + str(self.direction) + str(self.frame)
else:
self.image = "blank"
class FlyingEnemy(Actor):
def __init__(self, player_x):
# Choose which side of the screen we start from. Don't start right next to the player as that would be
# unfair - if not near player, start on a random side
side = 1 if player_x < 160 else 0 if player_x > 320 else randint(0, 1)
super().__init__("blank", (550*side-35, 688))
# Always moves in the same X direction, but randomly pauses to just fly straight up or down
self.moving_x = 1 # 0 if we're currently moving only vertically, 1 if moving along x axis (as well as y axis)
self.dx = 1 - 2 * side # Move left or right depending on which side of the screen we're on
self.dy = choice([-1, 1]) # Start moving either up or down
self.type = randint(0, 2) # 3 different colours
self.health = 1
self.timer = 0
def update(self):
self.timer += 1
# Move
self.x += self.dx * self.moving_x * (3 - abs(self.dy))
self.y += self.dy * (3 - abs(self.dx * self.moving_x))
if self.y < 592 or self.y > 784:
# Gone too high or low - reverse y direction
self.moving_x = randint(0, 1)
self.dy = -self.dy
anim_frame = str([0, 2, 1, 2][(self.timer // 4) % 4])
self.image = "meanie" + str(self.type) + anim_frame
class Rock(Actor):
def __init__(self, x, y, totem=False):
# Use a custom anchor point for totem rocks, which are taller than other rocks
anchor = (24, 60) if totem else CENTRE_ANCHOR
super().__init__("blank", cell2pos(x, y), anchor=anchor)
self.type = randint(0, 3)
if totem:
# Totem rocks take five hits and give bonus points
game.play_sound("totem_create")
self.health = 5
self.show_health = 5
else:
# Non-totem rocks are initially displayed as if they have one health, and animate until they
# show the actualy sprite for their health level - resulting in a 'growing' animation.
self.health = randint(3, 4)
self.show_health = 1
self.timer = 1
def damage(self, amount, damaged_by_bullet=False):
# Damage can occur by being hit by bullets, or by being destroyed by a segment, or by being cleared from the
# player's respawn location. Points can be earned by hitting special "totem" rocks, which have 5 health, but
# this should only happen when they are hit by a bullet.
if damaged_by_bullet and self.health == 5:
game.play_sound("totem_destroy")
game.score += 100
else:
if amount > self.health - 1:
game.play_sound("rock_destroy")
else:
game.play_sound("hit", 4)
game.explosions.append(Explosion(self.pos, 2 * (self.health == 5)))
self.health -= amount
self.show_health = self.health
self.anchor, self.pos = CENTRE_ANCHOR, self.pos
# Return False if we've lost all our health, otherwise True
return self.health < 1
def update(self):
self.timer += 1
# Every other frame, update the growing animation
if self.timer % 2 == 1 and self.show_health < self.health:
self.show_health += 1
if self.health == 5 and self.timer > 200:
# Totem rocks turn into normal rocks if not shot within 200 frames
self.damage(1)
colour = str(max(game.wave, 0) % 3)
health = str(max(self.show_health - 1, 0))
self.image = "rock" + colour + str(self.type) + health
class Bullet(Actor):
def __init__(self, pos):
super().__init__("bullet", pos)
self.done = False
def update(self):
# Move up the screen, 24 pixels per frame
self.y -= 24
# game.damage checks to see if there is a rock at the given position - if so, it damages
# the rock and returns True
# An asterisk before a list or tuple will unpack the contents into separate values
grid_cell = pos2cell(*self.pos)
if game.damage(*grid_cell, 1, True):
# Hit a rock - destroy self
self.done = True
else:
# Didn't hit a rock
# Check each myriapod segment, and the flying enemy, to see if this bullet collides with them
for obj in game.segments + [game.flying_enemy]:
# Is this a valid object reference, and if so, does this bullet's location overlap with the
# object's rectangle? (collidepoint is a method from Pygame's Rect class)
if obj and obj.collidepoint(self.pos):
# Create explosion
game.explosions.append(Explosion(obj.pos, 2))
obj.health -= 1
# Is the object an instance of the Segment class?
if isinstance(obj, Segment):
# Should we create a new rock in the segment's place? Health must be zero, there must be no
# rock there already, and the player sprite must not overlap with the location
if obj.health == 0 and not game.grid[obj.cell_y][obj.cell_x] and game.allow_movement(game.player.x, game.player.y, obj.cell_x, obj.cell_y):
# Create new rock - 20% chance of being a totem
game.grid[obj.cell_y][obj.cell_x] = Rock(obj.cell_x, obj.cell_y, random() < .2)
game.play_sound("segment_explode")
game.score += 10
else:
# If it's not a segment, it must be the flying enemy
game.play_sound("meanie_explode")
game.score += 20
self.done = True # Destroy self
# Don't continue the for loop, this bullet has hit something so shouldn't hit anything else
return
# SEGMENT MOVEMENT
# The code below creates several constants used in the Segment class in relation to movement and directions
# Each myriapod segment moves in relation to its current grid cell.
# A segment enters a cell from a particular edge (stored in 'in_edge' in the Segment class)
# After five frames it decides which edge it's going leave that cell through (stored in out_edge).
# For example, it might carry straight on and leave through the opposite edge from the one it started at.
# Or it might turn 90 degrees and leave through an edge to its left or right.
# In this case it initially turn 45 degrees and continues along that path for 8 frames. It then turns another
# 45 degrees, at which point they are heading directly towards the next grid cell.
# A segment spends a total of 16 frames in each cell. Within the update method, the variable 'phase' refers to
# where it is in that cycle - 0 meaning it's just entered a grid cell, and 15 meaning it's about to leave it.
# Let's imagine the case where a segment enters from the left edge of a cell and then turns to leave from the
# bottom edge. The segment will initially move along the horizontal (X) axis, and will end up moving along the
# vertical (Y) axis. In this case we'll call the X axis the primary axis, and the Y axis the secondary axis.
# The lists SECONDARY_AXIS_SPEED and SECONDARY_AXIS_POSITIONS are used to determine the movement of the segment.
# This is explained in more detail in the Segment.update method.
# In Python, multiplying a list by a number creates a list where the contents
# are repeated the specified number of times. So the code below is equivalent to:
# SECONDARY_AXIS_SPEED = [0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1 , 1, 2, 2, 2, 2]
# This list represents how much the segment moves along the secondary axis, in situations where it makes two 45° turns
# as described above. For the first four frames it doesn't move at all along the secondary axis. For the next eight
# frames it moves at one pixel per frame, then for the last four frames it moves at two pixels per frame.
SECONDARY_AXIS_SPEED = [0]*4 + [1]*8 + [2]*4
# The code below creates a list of 16 elements, where each element is the sum of all the equivalent elements in the
# SECONDARY_AXIS_SPEED list up to that point.
# It is equivalent to writing:
# SECONDARY_AXIS_POSITIONS = [0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14]
# This list stores the total secondary axis movement that will have occurred at each phase in the segment's movement
# through the current grid cell (if the segment is turning)
SECONDARY_AXIS_POSITIONS = [sum(SECONDARY_AXIS_SPEED[:i]) for i in range(16)]
# Constants representing directions
DIRECTION_UP = 0
DIRECTION_RIGHT = 1
DIRECTION_DOWN = 2
DIRECTION_LEFT = 3
# X and Y directions indexed into by in_edge and out_edge in Segment
# The indices correspond to the direction numbers above, i.e. 0 = up, 1 = right, 2 = down, 3 = left
DX = [0,1,0,-1]
DY = [-1,0,1,0]
def inverse_direction(dir):
if dir == DIRECTION_UP:
return DIRECTION_DOWN
elif dir == DIRECTION_RIGHT:
return DIRECTION_LEFT
elif dir == DIRECTION_DOWN:
return DIRECTION_UP
elif dir == DIRECTION_LEFT:
return DIRECTION_RIGHT
def is_horizontal(dir):
return dir == DIRECTION_LEFT or dir == DIRECTION_RIGHT
class Segment(Actor):
def __init__(self, cx, cy, health, fast, head):
super().__init__("blank")
# Grid cell positions
self.cell_x = cx
self.cell_y = cy
self.health = health
# Determines whether the 'fast' version of the sprite is used. Note that the actual speed of the myriapod is
# determined by how much time is included in the State.update method
self.fast = fast
self.head = head # Should this segment use the head sprite?
# Each myriapod segment moves in a defined pattern within its current cell, before moving to the next one.
# It will start at one of the edges - represented by a number, where 0=down,1=right,2=up,3=left
# self.in_edge stores the edge through which it entered the cell.
# Several frames after entering a cell, it chooses which edge to leave through - stored in out_edge
# The path it follows is explained in the update and rank methods
self.in_edge = DIRECTION_LEFT
self.out_edge = DIRECTION_RIGHT
self.disallow_direction = DIRECTION_UP # Prevents segment from moving in a particular direction
self.previous_x_direction = 1 # Used to create winding/snaking motion
def rank(self):
# The rank method creates and returns a function. Don't worry if this seems a strange concept - it is
# fairly advanced stuff. The returned function is passed to Python's 'min' function in the update method,
# as the 'key' optional parameter. min then calls this function with the numbers 0 to 3, representing the four
# directions
def inner(proposed_out_edge):
# proposed_out_edge is a number between 0 and 3, representing a possible direction to move - see DIRECTION_UP etc and DX/DY above
# This function returns a tuple consisting of a series of factors determining which grid cell the segment should try to move into next.
# These are not absolute rules - rather they are used to rank the four directions in order of preference,
# i.e. which direction is the best (or at least, least bad) to move in. The factors are boolean (True or False)
# values. A value of False is preferable to a value of True.
# The order of the factors in the returned tuple determines their importance in deciding which way to go,
# with the most important factor coming first.
new_cell_x = self.cell_x + DX[proposed_out_edge]
new_cell_y = self.cell_y + DY[proposed_out_edge]
# Does this direction take us to a cell which is outside the grid?
# Note: when the segments start, they are all outside the grid so this would be True, except for the case of
# walking onto the top-left cell of the grid. But the end result of this and the following factors is that
# it will still be allowed to continue walking forwards onto the screen.
out = new_cell_x < 0 or new_cell_x > num_grid_cols - 1 or new_cell_y < 0 or new_cell_y > num_grid_rows - 1
# We don't want it to to turn back on itself..
turning_back_on_self = proposed_out_edge == self.in_edge
# ..or go in a direction that's disallowed (see comments in update method)
direction_disallowed = proposed_out_edge == self.disallow_direction
# Check to see if there's a rock at the proposed new grid cell.
# rock will either be the Rock object at the new grid cell, or None.
# It will be set to None if there is no Rock object is at the new location, or if the new location is
# outside the grid. We also have to account for the special case where the segment is off the left-hand
# side of the screen on the first row, where it is initially created. We mustn't try to access that grid
# cell (unlike most languages, in Python trying to access a list index with negative value won't necessarily
# result in a crash, but it's still not a good idea)
if out or (new_cell_y == 0 and new_cell_x < 0):
rock = None
else:
rock = game.grid[new_cell_y][new_cell_x]
rock_present = rock != None
# Is new cell already occupied by another segment, or is another segment trying to enter my cell from
# the opposite direction?
occupied_by_segment = (new_cell_x, new_cell_y) in game.occupied or (self.cell_x, self.cell_y, proposed_out_edge) in game.occupied
# Prefer to move horizontally, unless there's a rock in the way.
# If there are rocks both horizontally and vertically, prefer to move vertically
if rock_present:
horizontal_blocked = is_horizontal(proposed_out_edge)
else:
horizontal_blocked = not is_horizontal(proposed_out_edge)
# Prefer not to go in the previous horizontal direction after we move up/down
same_as_previous_x_direction = proposed_out_edge == self.previous_x_direction
# Finally we create and return a tuple of factors determining which cell segment should try to move into next.
# Most important first - e.g. we shouldn't enter a new cell if if's outside the grid
return (out, turning_back_on_self, direction_disallowed, occupied_by_segment, rock_present, horizontal_blocked, same_as_previous_x_direction)
return inner
def update(self):
# Segments take either 16 or 8 frames to pass through each grid cell, depending on the amount by which
# game.time is updated each frame. phase will be a number between 0 and 15 indicating where we're at
# in that cycle.
phase = game.time % 16
if phase == 0:
# At this point, the segment is entering a new grid cell. We first update our current grid cell coordinates.
self.cell_x += DX[self.out_edge]
self.cell_y += DY[self.out_edge]
# We then need to update in_edge. If, for example, we left the previous cell via its right edge, that means
# we're entering the new cell via its left edge.
self.in_edge = inverse_direction(self.out_edge)
# During normal gameplay, once a segment reaches the bottom of the screen, it starts moving up again.
# Once it reaches row 18, it starts moving down again, so that it remains a threat to the player.
# During the title screen, we allow segments to go all the way back up to the top of the screen.
if self.cell_y == (18 if game.player else 0):
self.disallow_direction = DIRECTION_UP
if self.cell_y == num_grid_rows-1:
self.disallow_direction = DIRECTION_DOWN
elif phase == 4:
# At this point we decide which new cell we're going to go into (and therefore, which edge of the current
# cell we will leave via - to be stored in out_edge)
# range(4) generates all the numbers from 0 to 3 (corresponding to DIRECTION_UP etc)
# Python's built-in 'min' function usually chooses the lowest number, so would usually return 0 as the result.
# But if the optional 'key' argument is specified, this changes how the function determines the result.
# The rank function (see above) returns a function (named 'inner' in rank), which min calls to decide
# how the items should be ordered. The argument to inner represents a possible direction to move in.
# The 'inner' function returns a tuple of boolean values - for example: (True,False,False,True,etc..)
# When Python compares two such tuples, it considers values of False to be less than values of True,
# and values that come earlier in the sequence are more significant than later values. So (False,True)
# would be considered less than (True,False).
self.out_edge = min(range(4), key = self.rank())
if is_horizontal(self.out_edge):
self.previous_x_direction = self.out_edge
new_cell_x = self.cell_x + DX[self.out_edge]
new_cell_y = self.cell_y + DY[self.out_edge]
# Destroy any rock that might be in the new cell
if new_cell_x >= 0 and new_cell_x < num_grid_cols:
game.damage(new_cell_x, new_cell_y, 5)
# Set new cell as occupied. It's a case of whichever segment is processed first, gets first dibs on a cell
# The second line deals with the case where two segments are moving towards each other and are in
# neighbouring cells. It allows a segment to tell if another segment trying to enter its cell from
# the opposite direction
game.occupied.add((new_cell_x, new_cell_y))
game.occupied.add((new_cell_x, new_cell_y, inverse_direction(self.out_edge)))
# turn_idx tells us whether the segment is going to be making a 90 degree turn in the current cell, or moving
# in a straight line. 1 = anti-clockwise turn, 2 = straight ahead, 3 = clockwise turn, 0 = leaving through same
# edge from which we entered (unlikely to ever happen in practice)
turn_idx = (self.out_edge - self.in_edge) % 4
# Calculate segment offset in the cell, measured from the cell's centre
# We start off assuming that the segment is starting from the top of the cell - i.e. self.in_edge being DIRECTION_UP,
# corresponding to zero. The primary and secondary axes, as described under "SEGMENT MOVEMENT" above, are Y and X.
# We then apply a calculation to rotate these X and Y offsets, based on the actual direction the segment is coming from.
# Let's take as an example the case where the segment is moving in a straight line from top to bottom.
# We calculate offset_x by multiplying SECONDARY_AXIS_POSITIONS[phase] by 2-turn_idx. In this case, turn_idx
# will be 2. So 2 - turn_idx will be zero. Multiplying anything by zero gives zero, so we end up with no
# movement on the X axis - which is what we want in this case.
# The starting point for the offset_y calculation is that the segment starts at an offset of -16 and must cover
# 32 pixels over the 16 phases - therefore we must multiply phase by 2. We then subtract the result of the
# previous line, in which stolen_y_movement was calculated by multiplying SECONDARY_AXIS_POSITIONS[phase] by
# turn_idx % 2. mod 2 gives either zero (if turn_idx is 0 or 2), or 1 if it's 1 or 3. In the case we're looking
# at, turn_idx is 2, so stolen_y_movement is zero.
# The end result of all this is that in the case where the segment is moving in a straight line through a cell,
# it just moves at 2 pixels per frame along the primary axis. If it's turning, it starts out moving at 2px
# per frame on the primary axis, but then starts moving along the secondary axis based on the values in
# SECONDARY_AXIS_POSITIONS. In this case we don't want it to continue moving along the primary axis - it should
# initially slow to moving at 1px per phase, and then stop moving completely. Effectively, the secondary axis
# is stealing movement from the primary axis - hence the name 'stolen_y_movement'
offset_x = SECONDARY_AXIS_POSITIONS[phase] * (2 - turn_idx)
stolen_y_movement = (turn_idx % 2) * SECONDARY_AXIS_POSITIONS[phase]
offset_y = -16 + (phase * 2) - stolen_y_movement
# A rotation matrix is a set of numbers which, when multiplied by a set of coordinates, result in those
# coordinates being rotated. Recall that the code above makes the assumption that segment is starting from the
# top edge of the cell and moving down. The code below chooses the appropriate rotation matrix based on the
# actual edge the segment started from, and then modifies offset_x and offset_y based on this rotation matrix.
rotation_matrix = [[1,0,0,1],[0,-1,1,0],[-1,0,0,-1],[0,1,-1,0]][self.in_edge]
offset_x, offset_y = offset_x * rotation_matrix[0] + offset_y * rotation_matrix[1], offset_x * rotation_matrix[2] + offset_y * rotation_matrix[3]
# Finally, we can calculate the segment's position on the screen. See cell2pos function above.
self.pos = cell2pos(self.cell_x, self.cell_y, offset_x, offset_y)
# We now need to decide which image the segment should use as its sprite.
# Images for segment sprites follow the format 'segABCDE' where A is 0 or 1 depending on whether this is a
# fast-moving segment, B is 0 or 1 depending on whether we currently have 1 or 2 health, C is whether this
# is the head segment of a myriapod, D represents the direction we're facing (0 = up, 1 = top right,
# up to 7 = top left) and E is how far we are through the walking animation (0 to 3)
# Three variables go into the calculation of the direction. turn_idx tells us if we're making a turn in this
# cell - and if so, whether we're turning clockwise or anti-clockwise. self.in_edge tells us which side of the
# grid cell we entered from. And we can use SECONDARY_AXIS_SPEED[phase] to find out whether we should be facing
# along the primary axis, secondary axis or diagonally between them.
# (turn_idx - 2) gives 0 if straight, -1 if turning anti-clockwise, 1 if turning clockwise
# Multiplying this by SECONDARY_AXIS_SPEED[phase] gives 0 if we're not doing a turn in this cell, or if
# we are going to be turning but have not yet begun to turn. If we are doing a turn in this cell, and we're
# at a phase where we should be showing a sprite with a new rotation, the result will be -1 or 1 if we're
# currently in the first (45°) part of a turn, or -2 or 2 if we have turned 90°.
# The next part of the calculation multiplies in_edge by 2 and then adds the result to the result of the previous
# part. in_edge will be a number from 0 to 3, representing all possible directions in 90° increments.
# It must be multiplied by two because the direction value we're calculating will be a number between 0 and 7,
# representing all possible directions in 45° increments.
# In the sprite filenames, the penultimate number represents the direction the sprite is facing, where a value
# of zero means it's facing up. But in this code, if, for example, in_edge were zero, this means the segment is
# coming from the top edge of its cell, and therefore should be facing down. So we add 4 to account for this.
# After all this, we may have ended up with a number outside the desired range of 0 to 7. So the final step
# is to MOD by 8.
direction = ((SECONDARY_AXIS_SPEED[phase] * (turn_idx - 2)) + (self.in_edge * 2) + 4) % 8
leg_frame = phase // 4 # 16 phase cycle, 4 frames of animation
# Converting a boolean value to an integer gives 0 for False and 1 for True. We then need to convert the
# result to a string, as an integer can't be appended to a string.
self.image = "seg" + str(int(self.fast)) + str(int(self.health == 2)) + str(int(self.head)) + str(direction) + str(leg_frame)
class Game:
def __init__(self, player=None):
self.wave = -1
self.time = 0
self.player = player
# Create empty grid of 14 columns, 25 rows, each element intially just containing the value 'None'
# Rocks will be added to the grid later
self.grid = [[None] * num_grid_cols for y in range(num_grid_rows)]
self.bullets = []
self.explosions = []
self.segments = []
self.flying_enemy = None
self.score = 0
def damage(self, cell_x, cell_y, amount, from_bullet=False):
# Find the rock at this grid cell (or None if no rock here)
rock = self.grid[cell_y][cell_x]
if rock != None:
# rock.damage returns False if the rock has lost all its health - in this case, the grid cell will be set
# to None, overwriting the rock object reference
if rock.damage(amount, from_bullet):
self.grid[cell_y][cell_x] = None
# Return whether or not there was a rock at this position
return rock != None
def allow_movement(self, x, y, ax=-1, ay=-1):
# ax/ay are only supplied when a segment is being destroyed, and we check to see if we should create a new
# rock in the segment's place. They indicate a grid cell location where we're planning to create the new rock,
# we need to ensure the new rock would not overlap with the player sprite
# Don't go off edge of screen or above the player zone
if x < 40 or x > 440 or y < 592 or y > 784:
return False
# Get coordinates of corners of player sprite's collision rectangle
x0, y0 = pos2cell(x-18, y-10)
x1, y1 = pos2cell(x+18, y+10)
# Check each corner against grid
for yi in range(y0, y1+1):
for xi in range(x0, x1+1):
if self.grid[yi][xi] or xi == ax and yi == ay:
return False
return True
def clear_rocks_for_respawn(self, x, y):
# Destroy any rocks that might be overlapping with the player when they respawn
# Could be more than one rock, hence the loop
x0, y0 = pos2cell(x-18, y-10)
x1, y1 = pos2cell(x+18, y+10)
for yi in range(y0, y1+1):
for xi in range(x0, x1+1):
self.damage(xi, yi, 5)
def update(self):
# Increment time - used by segments. Time moves twice as fast every fourth wave.
self.time += (2 if self.wave % 4 == 3 else 1)
# At the start of each frame, we reset occupied to be an empty set. As each individual myriapod segment is
# updated, it will create entries in the occupied set to indicate that other segments should not attempt to
# enter its current grid cell. There are two types of entries that are created in the occupied set. One is a
# tuple consisting of a pair of numbers, representing grid cell coordinates. The other is a tuple consisting of
# three numbers - the first two being grid cell coordinates, the third representing an edge through which a
# segment is trying to enter a cell.
# It is only used for myriapod segments - not rocks. Those are stored in self.grid.
self.occupied = set()
# Call update method on all objects. grid is a list of lists, equivalent to a 2-dimensional array,
# so sum can be used to produce a single list containing all grid objects plus the contents of the other
# Actor lists. The player and flying enemy, which are object references rather than lists, are appended as single-item lists.
all_objects = sum(self.grid, self.bullets + self.segments + self.explosions + [self.player] + [self.flying_enemy])
for obj in all_objects:
if obj:
obj.update()
# Recreate the bullets list, which will contain all existing bullets except those which have gone off the screen or have hit something
self.bullets = [b for b in self.bullets if b.y > 0 and not b.done]
# Recreate the explosions list, which will contain all existing explosions except those which have completed their animations
self.explosions = [e for e in self.explosions if not e.timer == 31]
# Recreate the segments list, which will contain all existing segments except those whose health is zero
self.segments = [s for s in self.segments if s.health > 0]
if self.flying_enemy:
# Destroy flying enemy if it goes off the left or right sides of the screen, or health is zero
if self.flying_enemy.health <= 0 or self.flying_enemy.x < -35 or self.flying_enemy.x > 515:
self.flying_enemy = None
elif random() < .01: # If there is no flying enemy, small chance of creating one each frame
self.flying_enemy = FlyingEnemy(self.player.x if self.player else 240)
if self.segments == []:
# No myriapod segments - start a new wave
# First, ensure there are enough rocks. Count the number of rocks in the grid and if there aren't enough,
# create one per frame. Initially there should be 30 rocks - each wave, this goes up by one.
num_rocks = 0
for row in self.grid:
for element in row:
if element != None:
num_rocks += 1
if num_rocks < 31+self.wave:
while True:
x, y = randint(0, num_grid_cols-1), randint(1, num_grid_rows-3) # Leave last 2 rows rock-free
if self.grid[y][x] == None:
self.grid[y][x] = Rock(x, y)
break
else:
# New wave and enough rocks - create a new myriapod
game.play_sound("wave")
self.wave += 1
self.time = 0
self.segments = []
num_segments = 8 + self.wave // 4 * 2 # On the first four waves there are 8 segments - then 10, and so on
for i in range(num_segments):
if DEBUG_TEST_RANDOM_POSITIONS:
cell_x, cell_y = randint(1, 7), randint(1, 7)
else:
cell_x, cell_y = -1-i, 0
# Determines whether segments take one or two hits to kill, based on the wave number.
# e.g. on wave 0 all segments take one hit; on wave 1 they alternate between one and two hits
health = [[1,1],[1,2],[2,2],[1,1]][self.wave % 4][i % 2]
fast = self.wave % 4 == 3 # Every fourth myriapod moves faster than usual
head = i == 0 # The first segment of each myriapod is the head
self.segments.append(Segment(cell_x, cell_y, health, fast, head))
return self
def draw(self):
screen.blit("bg" + str(max(self.wave, 0) % 3), (0, 0))
# Create a list of all grid locations and other objects which need to be drawn
# (Most grid locations will be set to None as they are unoccupied, hence the check "if obj:" further down)
all_objs = sum(self.grid, self.bullets + self.segments + self.explosions + [self.player])
# We want to draw objects in order based on their Y position. Objects further down the screen should be drawn
# after (and therefore in front of) objects higher up the screen. We can use Python's built-in sort function
# to put the items in the desired order, before we draw them. The following function specifies the criteria
# used to decide how the objects are sorted.
def sort_key(obj):
# Returns a tuple consisting of two elements. The first is whether the object is an instance of the
# Explosion class (True or False). A value of true means it will be displayed in front of other objects.
# The second element is a number - either the objects why position, or zero if obj is 'None'
return (isinstance(obj, Explosion), obj.y if obj else 0)
# Sort list using the above function to determine order
all_objs.sort(key=sort_key)
# Draw the flying enemy on top of everything else
all_objs.append(self.flying_enemy)
# Draw the objects
for obj in all_objs:
if obj:
obj.draw()
def play_sound(self, name, count=1):
# Some sounds have multiple varieties. If count > 1, we'll randomly choose one from those
# We don't play any sounds if there is no player (e.g. if we're on the menu)
if self.player:
try:
# Pygame Zero allows you to write things like 'sounds.explosion.play()'
# This automatically loads and plays a file named 'explosion.wav' (or .ogg) from the sounds folder (if
# such a file exists)
# But what if you have files named 'explosion0.ogg' to 'explosion5.ogg' and want to randomly choose
# one of them to play? You can generate a string such as 'explosion3', but to use such a string
# to access an attribute of Pygame Zero's sounds object, we must use Python's built-in function getattr
sound = getattr(sounds, name + str(randint(0, count - 1)))
sound.play()
except Exception as e:
# If no such sound file exists, print the name
print(e)
# Is the space bar currently being pressed down?
space_down = False
# Has the space bar just been pressed? i.e. gone from not being pressed, to being pressed
def space_pressed():
global space_down
if keyboard.space:
if space_down:
# Space was down previous frame, and is still down
return False
else:
# Space wasn't down previous frame, but now is
space_down = True
return True
else:
space_down = False
return False
# Pygame Zero calls the update and draw functions each frame
class State(Enum):
MENU = 1
PLAY = 2
GAME_OVER = 3
def update():
global state, game
if state == State.MENU:
if space_pressed():
state = State.PLAY
game = Game(Player((240, 768))) # Create new Game object, with a Player object
game.update()
elif state == State.PLAY:
if game.player.lives == 0 and game.player.timer == 100:
sounds.gameover.play()
state = State.GAME_OVER
else:
game.update()
elif state == State.GAME_OVER:
if space_pressed():
# Switch to menu state, and create a new game object without a player
state = State.MENU
game = Game()
def draw():
# Draw the game, which covers both the game during gameplay but also the game displaying in the background
# during the main menu and game over screens
game.draw()
if state == State.MENU:
# Display logo
screen.blit("title", (0, 0))
# 14 frames of animation for "Press space to start", updating every 4 frames
screen.blit("space" + str((game.time // 4) % 14), (0, 420))
elif state == State.PLAY:
# Display number of lives
for i in range(game.player.lives):
screen.blit("life", (i*40+8, 4))
# Display score
score = str(game.score)
for i in range(1, len(score)+1):
# In Python, a negative index into a list (or in this case, into a string) gives you items in reverse order,
# e.g. 'hello'[-1] gives 'o', 'hello'[-2] gives 'l', etc.
digit = score[-i]
screen.blit("digit"+digit, (468-i*24, 5))
elif state == State.GAME_OVER:
# Display "Game Over" image
screen.blit("over", (0, 0))
# Set up music on game start
try:
pygame.mixer.quit()
pygame.mixer.init(44100, -16, 2, 1024)
music.play("theme")
music.set_volume(0.4)
except:
# If an error occurs, just ignore it
pass
# Set the initial game state
state = State.MENU
# Create a new Game object, without a Player object
game = Game()
pgzrun.go()