diff --git a/myriapod-master/music/theme.ogg b/myriapod-master/music/theme.ogg new file mode 100644 index 0000000..585eb9f Binary files /dev/null and b/myriapod-master/music/theme.ogg differ diff --git a/myriapod-master/myriapod.py b/myriapod-master/myriapod.py new file mode 100644 index 0000000..035b172 --- /dev/null +++ b/myriapod-master/myriapod.py @@ -0,0 +1,911 @@ +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() diff --git a/myriapod-master/sounds/gameover.ogg b/myriapod-master/sounds/gameover.ogg new file mode 100644 index 0000000..13e6569 Binary files /dev/null and b/myriapod-master/sounds/gameover.ogg differ diff --git a/myriapod-master/sounds/hit0.ogg b/myriapod-master/sounds/hit0.ogg new file mode 100644 index 0000000..d8f1849 Binary files /dev/null and b/myriapod-master/sounds/hit0.ogg differ diff --git a/myriapod-master/sounds/hit1.ogg b/myriapod-master/sounds/hit1.ogg new file mode 100644 index 0000000..ad6f2d6 Binary files /dev/null and b/myriapod-master/sounds/hit1.ogg differ diff --git a/myriapod-master/sounds/hit2.ogg b/myriapod-master/sounds/hit2.ogg new file mode 100644 index 0000000..ff821cd Binary files /dev/null and b/myriapod-master/sounds/hit2.ogg differ diff --git a/myriapod-master/sounds/hit3.ogg b/myriapod-master/sounds/hit3.ogg new file mode 100644 index 0000000..93ae898 Binary files /dev/null and b/myriapod-master/sounds/hit3.ogg differ diff --git a/myriapod-master/sounds/laser0.ogg b/myriapod-master/sounds/laser0.ogg new file mode 100644 index 0000000..dab5e93 Binary files /dev/null and b/myriapod-master/sounds/laser0.ogg differ diff --git a/myriapod-master/sounds/level_clear.ogg b/myriapod-master/sounds/level_clear.ogg new file mode 100644 index 0000000..b253ad2 Binary files /dev/null and b/myriapod-master/sounds/level_clear.ogg differ diff --git a/myriapod-master/sounds/meanie_explode0.ogg b/myriapod-master/sounds/meanie_explode0.ogg new file mode 100644 index 0000000..74a8a15 Binary files /dev/null and b/myriapod-master/sounds/meanie_explode0.ogg differ diff --git a/myriapod-master/sounds/player_explode0.ogg b/myriapod-master/sounds/player_explode0.ogg new file mode 100644 index 0000000..f4983c7 Binary files /dev/null and b/myriapod-master/sounds/player_explode0.ogg differ diff --git a/myriapod-master/sounds/player_move1.ogg b/myriapod-master/sounds/player_move1.ogg new file mode 100644 index 0000000..eb3c109 Binary files /dev/null and b/myriapod-master/sounds/player_move1.ogg differ diff --git a/myriapod-master/sounds/player_move2.ogg b/myriapod-master/sounds/player_move2.ogg new file mode 100644 index 0000000..0373cb1 Binary files /dev/null and b/myriapod-master/sounds/player_move2.ogg differ diff --git a/myriapod-master/sounds/player_move3.ogg b/myriapod-master/sounds/player_move3.ogg new file mode 100644 index 0000000..9b85d20 Binary files /dev/null and b/myriapod-master/sounds/player_move3.ogg differ diff --git a/myriapod-master/sounds/player_move4.ogg b/myriapod-master/sounds/player_move4.ogg new file mode 100644 index 0000000..17592e2 Binary files /dev/null and b/myriapod-master/sounds/player_move4.ogg differ diff --git a/myriapod-master/sounds/rock_create0.ogg b/myriapod-master/sounds/rock_create0.ogg new file mode 100644 index 0000000..0313cf9 Binary files /dev/null and b/myriapod-master/sounds/rock_create0.ogg differ diff --git a/myriapod-master/sounds/rock_destroy0.ogg b/myriapod-master/sounds/rock_destroy0.ogg new file mode 100644 index 0000000..2bcf43d Binary files /dev/null and b/myriapod-master/sounds/rock_destroy0.ogg differ diff --git a/myriapod-master/sounds/segment_explode0.ogg b/myriapod-master/sounds/segment_explode0.ogg new file mode 100644 index 0000000..7ee1d5d Binary files /dev/null and b/myriapod-master/sounds/segment_explode0.ogg differ diff --git a/myriapod-master/sounds/segment_turn0.ogg b/myriapod-master/sounds/segment_turn0.ogg new file mode 100644 index 0000000..81dc56f Binary files /dev/null and b/myriapod-master/sounds/segment_turn0.ogg differ diff --git a/myriapod-master/sounds/totem_create0.ogg b/myriapod-master/sounds/totem_create0.ogg new file mode 100644 index 0000000..989251c Binary files /dev/null and b/myriapod-master/sounds/totem_create0.ogg differ diff --git a/myriapod-master/sounds/totem_destroy0.ogg b/myriapod-master/sounds/totem_destroy0.ogg new file mode 100644 index 0000000..b2d2e24 Binary files /dev/null and b/myriapod-master/sounds/totem_destroy0.ogg differ diff --git a/myriapod-master/sounds/wave0.ogg b/myriapod-master/sounds/wave0.ogg new file mode 100644 index 0000000..736baa5 Binary files /dev/null and b/myriapod-master/sounds/wave0.ogg differ