2019-12-13 08:35:55 +00:00

888 lines
40 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.

# If the window is too tall to fit on the screen, check your operating system display settings and reduce display
# scaling if it is enabled.
import pgzero, pgzrun, pygame, sys
from random import *
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 = "Infinite Bunner"
ROW_HEIGHT = 40
# See what happens when you change this to True
DEBUG_SHOW_ROW_BOUNDARIES = False
# The MyActor class extends Pygame Zero's Actor class by allowing an object to have a list of child objects,
# which are drawn relative to the parent object.
class MyActor(Actor):
def __init__(self, image, pos, anchor=("center", "bottom")):
super().__init__(image, pos, anchor)
self.children = []
def draw(self, offset_x, offset_y):
self.x += offset_x
self.y += offset_y
super().draw()
for child_obj in self.children:
child_obj.draw(self.x, self.y)
self.x -= offset_x
self.y -= offset_y
def update(self):
for child_obj in self.children:
child_obj.update()
# The eagle catches the rabbit if it goes off the bottom of the screen
class Eagle(MyActor):
def __init__(self, pos):
super().__init__("eagles", pos)
self.children.append(MyActor("eagle", (0, -32)))
def update(self):
self.y += 12
class PlayerState(Enum):
ALIVE = 0
SPLAT = 1
SPLASH = 2
EAGLE = 3
# Constants representing directions
DIRECTION_UP = 0
DIRECTION_RIGHT = 1
DIRECTION_DOWN = 2
DIRECTION_LEFT = 3
direction_keys = [keys.UP, keys.RIGHT, keys.DOWN, keys.LEFT]
# 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
# Numbers 0 to 3 correspond to up, right, down, left
DX = [0,4,0,-4]
DY = [-4,0,4,0]
class Bunner(MyActor):
MOVE_DISTANCE = 10
def __init__(self, pos):
super().__init__("blank", pos)
self.state = PlayerState.ALIVE
self.direction = 2
self.timer = 0
# If a control input is pressed while the rabbit is in the middle of jumping, it's added to the input queue
self.input_queue = []
# Keeps track of the furthest distance we've reached so far in the level, for scoring
# (Level Y coordinates decrease as the screen scrolls)
self.min_y = self.y
def handle_input(self, dir):
# Find row that player is trying to move to. This may or may not be the row they're currently standing on,
# depending on whether the proposed movement would take them onto a different row
for row in game.rows:
if row.y == self.y + Bunner.MOVE_DISTANCE * DY[dir]:
# Found the target row
# Can the player move to the new location? Can't move if there's something in the way
# (or if the new location is off the screen)
if row.allow_movement(self.x + Bunner.MOVE_DISTANCE * DX[dir]):
# It's okay to move here, so set direction and timer. Player will move one pixel per frame
# for the specified number of frames
self.direction = dir
self.timer = Bunner.MOVE_DISTANCE
game.play_sound("jump", 1)
# No need to continue searching
return
def update(self):
# Check each control direction
for direction in range(4):
if key_just_pressed(direction_keys[direction]):
self.input_queue.append(direction)
if self.state == PlayerState.ALIVE:
# While the player is alive, the timer variable is used for movement. If it's zero, the player is on
# the ground. If it's above zero, they're currently jumping to a new location.
# Are we on the ground, and are there inputs to process?
if self.timer == 0 and len(self.input_queue) > 0:
# Take the next input off the queue and process it
self.handle_input(self.input_queue.pop(0))
land = False
if self.timer > 0:
# Apply movement
self.x += DX[self.direction]
self.y += DY[self.direction]
self.timer -= 1
land = self.timer == 0 # If timer reaches zero, we've just landed
current_row = None
for row in game.rows:
if row.y == self.y:
current_row = row
break
if current_row:
# Row.check receives the player's X coordinate and returns the new state the player should be in
# (normally ALIVE, but SPLAT or SPLASH if they've collided with a vehicle or if they've fallen in
# the water). It also returns a second result which is only used if there was a collision, and even
# then only for certain collisions. When the new state is SPLAT, we will add a new child object to the
# current row, with the appropriate 'splat' image. In this case, the second result returned from
# check_collision is a Y offset which affects the position of this new child object. If the player is
# hit by a car the Y offset is zero, but if they are hit by a train the returned offset is 8 as this
# positioning looks a little better.
self.state, dead_obj_y_offset = current_row.check_collision(self.x)
if self.state == PlayerState.ALIVE:
# Water rows move the player along the X axis, if standing on a log
self.x += current_row.push()
if land:
# Just landed - play sound effect appropriate to the current row
current_row.play_sound()
else:
if self.state == PlayerState.SPLAT:
# Add 'splat' graphic to current row with the specified position and Y offset
current_row.children.insert(0, MyActor("splat" + str(self.direction), (self.x, dead_obj_y_offset)))
self.timer = 100
else:
# There's no current row - either because player is currently changing row, or the row they were on
# has been deleted. Has the player gone off the bottom of the screen?
if self.y > game.scroll_pos + HEIGHT + 80:
# Create eagle
game.eagle = Eagle((self.x, game.scroll_pos))
self.state = PlayerState.EAGLE
self.timer = 150
game.play_sound("eagle")
# Limit x position so player doesn't go off the screen. The player movement code doesn't allow jumping off
# the screen, but without this line, the player could be carried off the screen by a log
self.x = max(16, min(WIDTH - 16, self.x))
else:
# Not alive - timer now counts down prior to game over screen
self.timer -= 1
# Keep track of the furthest we've got in the level
self.min_y = min(self.min_y, self.y)
# Choose sprite image
self.image = "blank"
if self.state == PlayerState.ALIVE:
if self.timer > 0:
self.image = "jump" + str(self.direction)
else:
self.image = "sit" + str(self.direction)
elif self.state == PlayerState.SPLASH and self.timer > 84:
# Display appropriate 'splash' animation frame. Note that we use a different technique to display the
# 'splat' image see: comments earlier in this method. The reason two different techniques are used is
# that the splash image should be drawn on top of other objects, whereas the splat image must be drawn
# underneath other objects. Since the player is always drawn on top of other objects, changing the player
# sprite is a suitable method of displaying the splash image.
self.image = "splash" + str(int((100 - self.timer) / 2))
# Mover is the base class for Car, Log and Train
# The thing they all have in common, besides inheriting from MyActor, is that they need to store whether they're
# moving left or right and update their X position each frame
class Mover(MyActor):
def __init__(self, dx, image, pos):
super().__init__(image, pos)
self.dx = dx
def update(self):
self.x += self.dx
class Car(Mover):
# These correspond to the indicies of the lists self.sounds and self.played. Used in Car.update to trigger
# playing of the corresponding sound effects.
SOUND_ZOOM = 0
SOUND_HONK = 1
def __init__(self, dx, pos):
image = "car" + str(randint(0, 3)) + ("0" if dx < 0 else "1")
super().__init__(dx, image, pos)
# Cars have two sound effects. Each can only play once. We use this
# list to keep track of which has already played.
self.played = [False, False]
self.sounds = [("zoom", 6), ("honk", 4)]
def play_sound(self, num):
if not self.played[num]:
# Select a sound and pass the name and count to Game.play_sound.
# The asterisk operator unpacks the two items and passes them to play_sound as separate arguments
game.play_sound(*self.sounds[num])
self.played[num] = True
class Log(Mover):
def __init__(self, dx, pos):
image = "log" + str(randint(0, 1))
super().__init__(dx, image, pos)
class Train(Mover):
def __init__(self, dx, pos):
image = "train" +str(randint(0, 2)) + ("0" if dx < 0 else "1")
super().__init__(dx, image, pos)
# Row is the base class for Pavement, Grass, Dirt, Rail and ActiveRow
# Each row corresponds to one of the 40 pixel high images which make up sections of grass, road, etc.
# The last row of each section is 60 pixels high and overlaps with the row above
class Row(MyActor):
def __init__(self, base_image, index, y):
# base_image and index form the name of the image file to use
# Last argument is the anchor point to use
super().__init__(base_image + str(index), (0, y), ("left", "bottom"))
self.index = index
# X direction of moving elements on this row
# Zero by default - only ActiveRows (see below) and Rail have moving elements
self.dx = 0
def next(self):
# Overridden in child classes. See comments in Game.update
return
def collide(self, x, margin=0):
# Check to see if the given X coordinate is in contact with any of this row's child objects (e.g. logs, cars,
# hedges). A negative margin makes the collideable area narrower than the child object's sprite, while a
# positive margin makes the collideable area wider.
for child_obj in self.children:
if x >= child_obj.x - (child_obj.width / 2) - margin and x < child_obj.x + (child_obj.width / 2) + margin:
return child_obj
return None
def push(self):
return 0
def check_collision(self, x):
# Returns the new state the player should be in, based on whether or not the player collided with anything on
# this road. As this class is the base class for other types of row, this method defines the default behaviour
# i.e. unless a subclass overrides this method, the player can walk around on a row without dying.
return PlayerState.ALIVE, 0
def allow_movement(self, x):
# Ensure the player can't walk off the left or right sides of the screen
return x >= 16 and x <= WIDTH-16
class ActiveRow(Row):
def __init__(self, child_type, dxs, base_image, index, y):
super().__init__(base_image, index, y)
self.child_type = child_type # Class to be used for child objects (e.g. Car)
self.timer = 0
self.dx = choice(dxs) # Randomly choose a direction for cars/logs to move
# Populate the row with child objects (cars or logs). Without this, the row would initially be empty.
x = -WIDTH / 2 - 70
while x < WIDTH / 2 + 70:
x += randint(240, 480)
pos = (WIDTH / 2 + (x if self.dx > 0 else -x), 0)
self.children.append(self.child_type(self.dx, pos))
def update(self):
super().update()
# Recreate the children list, excluding any which are too far off the edge of the screen to be visible
self.children = [c for c in self.children if c.x > -70 and c.x < WIDTH + 70]
self.timer -= 1
# Create new child objects on a random interval
if self.timer < 0:
pos = (WIDTH + 70 if self.dx < 0 else -70, 0)
self.children.append(self.child_type(self.dx, pos))
# 240 is minimum distance between the start of one child object and the start of the next, assuming its
# speed is 1. If the speed is 2, they can occur twice as frequently without risk of overlapping with
# each other. The maximum distance is double the minimum distance (1 + random value of 1)
self.timer = (1 + random()) * (240 / abs(self.dx))
# Grass rows sometimes contain hedges
class Hedge(MyActor):
def __init__(self, x, y, pos):
super().__init__("bush"+str(x)+str(y), pos)
def generate_hedge_mask():
# In this context, a mask is a series of boolean values which allow or prevent parts of an underlying image from showing through.
# This function creates a mask representing the presence or absence of hedges in a Grass row. False means a hedge
# is present, True represents a gap. Initially we create a list of 12 elements. For each element there is a small
# chance of a gap, but normally all element will be False, representing a hedge. We then randomly set one item to
# True, to ensure that there is always at least one gap that the player can get through
mask = [random() < 0.01 for i in range(12)]
mask[randint(0, 11)] = True # force there to be one gap
# We then widen gaps to a minimum of 3 tiles. This happens in two steps.
# First, we recreate the mask list, except this time whether a gap is present is based on whether there was a gap
# in either the original element or its neighbouring elements. When using Python's built-in sum function, a value
# of True is treated as 1 and False as 0. We must use the min/max functions to ensure that we don't try to look
# at a neighbouring element which doesn't exist (e.g. there is no neighbour to the right of the last element)
mask = [sum(mask[max(0, i-1):min(12, i+2)]) > 0 for i in range(12)]
# We want to ensure gaps are a minimum of 3 tiles wide, but the previous line only ensures a minimum gap of 2 tiles
# at the edges. The last step is to return a new list consisting of the old list with the first and last elements duplicated
return [mask[0]] + mask + 2 * [mask[-1]]
def classify_hedge_segment(mask, previous_mid_segment):
# This function helps determine which sprite should be used by a particular hedge segment. Hedge sprites are numbered
# 00, 01, 10, 11, 20, 21 - up to 51. The second number indicates whether it's a bottom (0) or top (1) segment,
# but this method is concerned only with the first number. 0 represents a single-tile-width hedge. 1 and 2 represent
# the left-most or right-most sprites in a multi-tile-width hedge. 3, 4 and 5 all represent middle pieces in hedges
# which are 3 or more tiles wide.
# mask is a list of 4 boolean values - a slice from the list generated by generate_hedge_mask. True represents a gap
# and False represents a hedge. mask[1] is the item we're currently looking at.
if mask[1]:
# mask[1] == True represents a gap, so there will be no hedge sprite at this location
sprite_x = None
else:
# There's a hedge here - need to check either side of it to see if it's a single-width, left-most, right-most
# or middle piece. The calculation generates a number from 0 to 3 accordingly. Note that when boolean values
# are used in arithmetic in Python, False is treated as being 0 and True as 1.
sprite_x = 3 - 2 * mask[0] - mask[2]
if sprite_x == 3:
# If this is a middle piece, to ensure the piece tiles correctly, we alternate between sprites 3 and 4.
# If the next piece is going to be the last of this hedge section (sprite 2), we need to make sure that sprite 3
# does not precede it, as the two do not tile together correctly. In this case we should use sprite 5.
# mask[3] tells us whether there's a gap 2 tiles to the right - which means the next tile will be sprite 2
if previous_mid_segment == 4 and mask[3]:
return 5, None
else:
# Alternate between 3 and 4
if previous_mid_segment == None or previous_mid_segment == 4:
sprite_x = 3
elif previous_mid_segment == 3:
sprite_x = 4
return sprite_x, sprite_x
else:
# Not a middle piece
return sprite_x, None
class Grass(Row):
def __init__(self, predecessor, index, y):
super().__init__("grass", index, y)
# In computer graphics, a mask is a series of boolean (true or false) values indicating which parts of an image
# will be transparent. Grass rows may contain hedges which block the player's movement, and we use a similar
# mechanism here. In our hedge mask, values of False mean a hedge is present, while True means there is a gap
# in the hedges. Hedges are two rows high - once hedges have been created on a row, the pattern will be
# duplicated on the next row (although the sprites will be different - e.g. there are separate sprites
# for the top-left and bottom-left corners of a hedge). Note that the upper sprites overlap with the row above.
self.hedge_row_index = None # 0 or 1, or None if no hedges on this row
self.hedge_mask = None
if not isinstance(predecessor, Grass) or predecessor.hedge_row_index == None:
# Create a brand-new set of hedges? We will only create hedges if the previous row didn't have any.
# We also only want hedges to appear on certain types of grass row, and on only a random selection
# of rows
if random() < 0.5 and index > 7 and index < 14:
self.hedge_mask = generate_hedge_mask()
self.hedge_row_index = 0
elif predecessor.hedge_row_index == 0:
self.hedge_mask = predecessor.hedge_mask
self.hedge_row_index = 1
if self.hedge_row_index != None:
# See comments in classify_hedge_segment for explanation of previous_mid_segment
previous_mid_segment = None
for i in range(1, 13):
sprite_x, previous_mid_segment = classify_hedge_segment(self.hedge_mask[i - 1:i + 3], previous_mid_segment)
if sprite_x != None:
self.children.append(Hedge(sprite_x, self.hedge_row_index, (i * 40 - 20, 0)))
def allow_movement(self, x):
# allow_movement in the base class ensures that the player can't walk off the left and right sides of the
# screen. The call to our own collide method ensures that the player can't walk through hedges. The margin of
# 8 prevents the player sprite from overlapping with the edge of a hedge.
return super().allow_movement(x) and not self.collide(x, 8)
def play_sound(self):
game.play_sound("grass", 1)
def next(self):
if self.index <= 5:
row_class, index = Grass, self.index + 8
elif self.index == 6:
row_class, index = Grass, 7
elif self.index == 7:
row_class, index = Grass, 15
elif self.index >= 8 and self.index <= 14:
row_class, index = Grass, self.index + 1
else:
row_class, index = choice((Road, Water)), 0
# Create an object of the chosen row class
return row_class(self, index, self.y - ROW_HEIGHT)
class Dirt(Row):
def __init__(self, predecessor, index, y):
super().__init__("dirt", index, y)
def play_sound(self):
game.play_sound("dirt", 1)
def next(self):
if self.index <= 5:
row_class, index = Dirt, self.index + 8
elif self.index == 6:
row_class, index = Dirt, 7
elif self.index == 7:
row_class, index = Dirt, 15
elif self.index >= 8 and self.index <= 14:
row_class, index = Dirt, self.index + 1
else:
row_class, index = choice((Road, Water)), 0
# Create an object of the chosen row class
return row_class(self, index, self.y - ROW_HEIGHT)
class Water(ActiveRow):
def __init__(self, predecessor, index, y):
# dxs contains a list of possible directions (and speeds) in which child objects (in this case, logs) on this
# row could move. We pass the lists to the constructor of the base class, which randomly chooses one of the
# directions. We want logs on alternate rows to move in opposite directions, so we take advantage of the fact
# that that in Python, multiplying a list by True or False results in either the same list, or an empty list.
# So by looking at the direction of child objects on the previous row (predecessor.dx), we can decide whether
# child objects on this row should move left or right. If this is the first of a series of Water rows,
# predecessor.dx will be zero, so child objects could move in either direction.
dxs = [-2,-1]*(predecessor.dx >= 0) + [1,2]*(predecessor.dx <= 0)
super().__init__(Log, dxs, "water", index, y)
def update(self):
super().update()
for log in self.children:
# Child (log) object positions are relative to the parent row. If the player exists, and the player is at the
# same Y position, and is colliding with the current log, make the log dip down into the water slightly
if game.bunner and self.y == game.bunner.y and log == self.collide(game.bunner.x, -4):
log.y = 2
else:
log.y = 0
def push(self):
# Called when the player is standing on a log on this row, so player object can be moved at the same speed and
# in the same direction as the log
return self.dx
def check_collision(self, x):
# If we're colliding with a log, that's a good thing!
# margin of -4 ensures we can't stand right on the edge of a log
if self.collide(x, -4):
return PlayerState.ALIVE, 0
else:
game.play_sound("splash")
return PlayerState.SPLASH, 0
def play_sound(self):
game.play_sound("log", 1)
def next(self):
# After 2 water rows, there's a 50-50 chance of the next row being either another water row, or a dirt row
if self.index == 7 or (self.index >= 1 and random() < 0.5):
row_class, index = Dirt, randint(4,6)
else:
row_class, index = Water, self.index + 1
# Create an object of the chosen row class
return row_class(self, index, self.y - ROW_HEIGHT)
class Road(ActiveRow):
def __init__(self, predecessor, index, y):
# Specify the possible directions and speeds from which the movement of cars on this row will be chosen
# We use Python's set data structure to specify that the car velocities on this row will be any of the numbers
# from -5 to 5, except for zero or the velocity of the cars on the previous row
dxs = list(set(range(-5, 6)) - set([0, predecessor.dx]))
super().__init__(Car, dxs, "road", index, y)
def update(self):
super().update()
# Trigger car sound effects. The zoom effect should play when the player is on the row above or below the car,
# the honk effect should play when the player is on the same row.
for y_offset, car_sound_num in [(-ROW_HEIGHT, Car.SOUND_ZOOM), (0, Car.SOUND_HONK), (ROW_HEIGHT, Car.SOUND_ZOOM)]:
# Is the player on the appropriate row?
if game.bunner and game.bunner.y == self.y + y_offset:
for child_obj in self.children:
# The child object must be a car
if isinstance(child_obj, Car):
# The car must be within 100 pixels of the player on the x-axis, and moving towards the player
# child_obj.dx < 0 is True or False depending on whether the car is moving left or right, and
# dx < 0 is True or False depending on whether the player is to the left or right of the car.
# If the results of these two comparisons are different, the car is moving towards the player.
# Also, for the zoom sound, the car must be travelling faster than one pixel per frame
dx = child_obj.x - game.bunner.x
if abs(dx) < 100 and ((child_obj.dx < 0) != (dx < 0)) and (y_offset == 0 or abs(child_obj.dx) > 1):
child_obj.play_sound(car_sound_num)
def check_collision(self, x):
if self.collide(x):
game.play_sound("splat", 1)
return PlayerState.SPLAT, 0
else:
return PlayerState.ALIVE, 0
def play_sound(self):
game.play_sound("road", 1)
def next(self):
if self.index == 0:
row_class, index = Road, 1
elif self.index < 5:
# 80% chance of another road
r = random()
if r < 0.8:
row_class, index = Road, self.index + 1
elif r < 0.88:
row_class, index = Grass, randint(0,6)
elif r < 0.94:
row_class, index = Rail, 0
else:
row_class, index = Pavement, 0
else:
# We've reached maximum of 5 roads in a row, so choose something else
r = random()
if r < 0.6:
row_class, index = Grass, randint(0,6)
elif r < 0.9:
row_class, index = Rail, 0
else:
row_class, index = Pavement, 0
# Create an object of the chosen row class
return row_class(self, index, self.y - ROW_HEIGHT)
class Pavement(Row):
def __init__(self, predecessor, index, y):
super().__init__("side", index, y)
def play_sound(self):
game.play_sound("sidewalk", 1)
def next(self):
if self.index < 2:
row_class, index = Pavement, self.index + 1
else:
row_class, index = Road, 0
# Create an object of the chosen row class
return row_class(self, index, self.y - ROW_HEIGHT)
# Note that Rail does not inherit from ActiveRow
class Rail(Row):
def __init__(self, predecessor, index, y):
super().__init__("rail", index, y)
self.predecessor = predecessor
def update(self):
super().update()
# Only Rail rows with index 1 have trains on them
if self.index == 1:
# Recreate the children list, excluding any which are too far off the edge of the screen to be visible
self.children = [c for c in self.children if c.x > -1000 and c.x < WIDTH + 1000]
# If on-screen, and there is currently no train, and with a 1% chance every frame, create a train
if self.y < game.scroll_pos+HEIGHT and len(self.children) == 0 and random() < 0.01:
# Randomly choose a direction for trains to move. This can be different for each train created
dx = choice([-20, 20])
self.children.append(Train(dx, (WIDTH + 1000 if dx < 0 else -1000, -13)))
game.play_sound("bell")
game.play_sound("train", 2)
def check_collision(self, x):
if self.index == 2 and self.predecessor.collide(x):
game.play_sound("splat", 1)
return PlayerState.SPLAT, 8 # For the meaning of the second return value, see comments in Bunner.update
else:
return PlayerState.ALIVE, 0
def play_sound(self):
game.play_sound("grass", 1)
def next(self):
if self.index < 3:
row_class, index = Rail, self.index + 1
else:
item = choice( ((Road, 0), (Water, 0)) )
row_class, index = item[0], item[1]
# Create an object of the chosen row class
return row_class(self, index, self.y - ROW_HEIGHT)
class Game:
def __init__(self, bunner=None):
self.bunner = bunner
self.looped_sounds = {}
try:
if bunner:
music.set_volume(0.4)
else:
music.play("theme")
music.set_volume(1)
except:
pass
self.eagle = None
self.frame = 0
# First (bottom) row is always grass
self.rows = [Grass(None, 0, 0)]
self.scroll_pos = -HEIGHT
def update(self):
if self.bunner:
# Scroll faster if the player is close to the top of the screen. Limit scroll speed to
# between 1 and 3 pixels per frame.
self.scroll_pos -= max(1, min(3, float(self.scroll_pos + HEIGHT - self.bunner.y) / (HEIGHT // 4)))
else:
self.scroll_pos -= 1
# Recreate the list of rows, excluding any which have scrolled off the bottom of the screen
self.rows = [row for row in self.rows if row.y < int(self.scroll_pos) + HEIGHT + ROW_HEIGHT * 2]
# In Python, a negative index into a list gives you items in reverse order, e.g. my_list[-1] gives you the
# last element of a list. Here, we look at the last row in the list - which is the top row - and check to see
# if it has scrolled sufficiently far down that we need to add a new row above it. This may need to be done
# multiple times - particularly when the game starts, as only one row is added to begin with.
while self.rows[-1].y > int(self.scroll_pos)+ROW_HEIGHT:
new_row = self.rows[-1].next()
self.rows.append(new_row)
# Update all rows, and the player and eagle (if present)
for obj in self.rows + [self.bunner, self.eagle]:
if obj:
obj.update()
# Play river and traffic sound effects, and adjust volume each frame based on the player's proximity to rows
# of the appropriate types. For each such row, a number is generated representing how much the row should
# contribute to the volume of the sound effect. These numbers are added together by Python's sum function.
# On the following line we ensure that the volume can never be above 40% of the maximum possible volume.
if self.bunner:
for name, count, row_class in [("river", 2, Water), ("traffic", 3, Road)]:
# The first line uses a list comprehension to get each row of the appropriate type, e.g. Water rows
# if we're currently updating the "river" sound effect.
volume = sum([16.0 / max(16.0, abs(r.y - self.bunner.y)) for r in self.rows if isinstance(r, row_class)]) - 0.2
volume = min(0.4, volume)
self.loop_sound(name, count, volume)
return self
def draw(self):
# Create a list of all objects which need to be drawn. This includes all rows, plus the player
# Using list(s.rows) means we're creating a copy of that list to use - we don't want to create a reference
# to it as that would mean we're modifying the original list's contents
all_objs = list(self.rows)
if self.bunner:
all_objs.append(self.bunner)
# We want to draw objects in order based on their Y position. In general, 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 the The following function specifies the criteria
# used to decide how the objects are sorted.
def sort_key(obj):
# Adding 39 and then doing an integer divide by 40 (the height of each row) deals with the situation where
# the player sprite would otherwise be drawn underneath the row below. This could happen when the player
# is moving up or down. If you assume that it occupies a 40x40 box which can be at an arbitrary y offset,
# it generates the row number of the bottom row that that box overlaps. If the player happens to be
# perfectly aligned to a row, adding 39 and dividing by 40 has no effect on the result. If it isn't, even
# by a single pixel, the +39 causes it to be drawn one row later.
return (obj.y + 39) // ROW_HEIGHT
# Sort list using the above function to determine order
all_objs.sort(key=sort_key)
# Always draw eagle on top of everything
all_objs.append(self.eagle)
for obj in all_objs:
if obj:
# Draw the object, taking the scroll position into account
obj.draw(0, -int(self.scroll_pos))
if DEBUG_SHOW_ROW_BOUNDARIES:
for obj in all_objs:
if obj and isinstance(obj, Row):
pygame.draw.rect(screen.surface, (255, 255, 255), pygame.Rect(obj.x, obj.y - int(self.scroll_pos), screen.surface.get_width(), ROW_HEIGHT), 1)
screen.draw.text(str(obj.index), (obj.x, obj.y - int(self.scroll_pos) - ROW_HEIGHT))
def score(self):
return int(-320 - game.bunner.min_y) // 40
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.bunner:
# 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()
def loop_sound(self, name, count, volume):
# Similar to play_sound above, but for looped sounds we need to keep a reference to the sound so that we can
# later modify its volume or turn it off. We use the dictionary self.looped_sounds for this - the sound
# effect name is the key, and the value is the corresponding sound reference.
if volume > 0 and not name in self.looped_sounds:
full_name = name + str(randint(0, count - 1))
sound = getattr(sounds, full_name) # see play_sound method above for explanation
sound.play(-1) # -1 means sound will loop indefinitely
self.looped_sounds[name] = sound
if name in self.looped_sounds:
sound = self.looped_sounds[name]
if volume > 0:
sound.set_volume(volume)
else:
sound.stop()
del self.looped_sounds[name]
def stop_looped_sounds(self):
for sound in self.looped_sounds.values():
sound.stop()
self.looped_sounds.clear()
# Dictionary to keep track of which keys are currently being held down
key_status = {}
# Was the given key just pressed? (i.e. is it currently down, but wasn't down on the previous frame?)
def key_just_pressed(key):
result = False
# Get key's previous status from the key_status dictionary. The dictionary.get method allows us to check for a given
# entry without giving an error if that entry is not present in the dictionary. False is the default value returned
# when the key is not present.
prev_status = key_status.get(key, False)
# If the key wasn't previously being pressed, but it is now, we're going to return True
if not prev_status and keyboard[key]:
result = True
# Before we return, we need to update the key's entry in the key_status dictionary (or create an entry if there
# wasn't one already
key_status[key] = keyboard[key]
return result
def display_number(n, colour, x, align):
# align: 0 for left, 1 for right
n = str(n) # Convert number to string
for i in range(len(n)):
screen.blit("digit" + str(colour) + n[i], (x + (i - len(n) * align) * 25, 0))
# 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, high_score
if state == State.MENU:
if key_just_pressed(keys.SPACE):
state = State.PLAY
game = Game(Bunner((240, -320)))
else:
game.update()
elif state == State.PLAY:
# Is it game over?
if game.bunner.state != PlayerState.ALIVE and game.bunner.timer < 0:
# Update high score
high_score = max(high_score, game.score())
# Write high score file
try:
with open("high.txt", "w") as file:
file.write(str(high_score))
except:
# If an error occurs writing the file, just ignore it and carry on, rather than crashing
pass
state = State.GAME_OVER
else:
game.update()
elif state == State.GAME_OVER:
# Switch to menu state, and create a new game object without a player
if key_just_pressed(keys.SPACE):
game.stop_looped_sounds()
state = State.MENU
game = Game()
def draw():
game.draw()
if state == State.MENU:
screen.blit("title", (0, 0))
screen.blit("start" + str([0, 1, 2, 1][game.scroll_pos // 6 % 4]), ((WIDTH - 270) // 2, HEIGHT - 240))
elif state == State.PLAY:
# Display score and high score
display_number(game.score(), 0, 0, 0)
display_number(high_score, 1, WIDTH - 10, 1)
elif state == State.GAME_OVER:
# Display "Game Over" image
screen.blit("gameover", (0, 0))
# Set up sound system
try:
pygame.mixer.quit()
pygame.mixer.init(44100, -16, 2, 512)
pygame.mixer.set_num_channels(16)
except:
# If an error occurs, just ignore it
pass
# Load high score from file
try:
with open("high.txt", "r") as f:
high_score = int(f.read())
except:
# If opening the file fails (likely because it hasn't yet been created), set high score to 0
high_score = 0
# Set the initial game state
state = State.MENU
# Create a new Game object, without a Player object
game = Game()
pgzrun.go()