Infinite Bunner
Attribution
Code the Classics – Volume 1, Chapter 3 Top-down Platformer, page 095.
Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.
Original Python code
# 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):
try:
# 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()
except:
# If a sound fails to play, ignore the error
pass
def loop_sound(self, name, count, volume):
try:
# 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]
except:
# If a sound fails to play, ignore the error
pass
def stop_looped_sounds(self):
try:
for sound in self.looped_sounds.values():
sound.stop()
self.looped_sounds.clear()
except:
# If sound system is not working/present, ignore the error
pass
# 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()