2019-12-13 08:53:57 +00:00

912 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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