Cavern
Attribution
Code the Classics – Volume 1, Chapter 2 Action Platformer, page 061.
Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.
Original Python code
from random import choice, randint, random, shuffle
from enum import Enum
import pygame, pgzero, pgzrun, sys
# 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()
# Set up constants
WIDTH = 800
HEIGHT = 480
TITLE = "Cavern"
NUM_ROWS = 18
NUM_COLUMNS = 28
LEVEL_X_OFFSET = 50
GRID_BLOCK_SIZE = 25
ANCHOR_CENTRE = ("center", "center")
ANCHOR_CENTRE_BOTTOM = ("center", "bottom")
LEVELS = [ ["XXXXX XXXXXXXX XXXXX",
"","","","",
" XXXXXXX XXXXXXX ",
"","","",
" XXXXXXXXXXXXXXXXXXXXXX ",
"","","",
"XXXXXXXXX XXXXXXXXX",
"","",""],
["XXXX XXXXXXXXXXXX XXXX",
"","","","",
" XXXXXXXXXXXXXXXXXXXX ",
"","","",
"XXXXXX XXXXXX",
" X X ",
" X X ",
" X X ",
" X X ",
"","",""],
["XXXX XXXX XXXX XXXX",
"","","","",
" XXXXXXXX XXXXXXXX ",
"","","",
"XXXX XXXXXXXX XXXX",
"","","",
" XXXXXX XXXXXX ",
"","",""]]
def block(x,y):
# Is there a level grid block at these coordinates?
grid_x = (x - LEVEL_X_OFFSET) // GRID_BLOCK_SIZE
grid_y = y // GRID_BLOCK_SIZE
if grid_y > 0 and grid_y < NUM_ROWS:
row = game.grid[grid_y]
return grid_x >= 0 and grid_x < NUM_COLUMNS and len(row) > 0 and row[grid_x] != " "
else:
return False
def sign(x):
# Returns -1 or 1 depending on whether number is positive or negative
return -1 if x < 0 else 1
class CollideActor(Actor):
def __init__(self, pos, anchor=ANCHOR_CENTRE):
super().__init__("blank", pos, anchor)
def move(self, dx, dy, speed):
new_x, new_y = int(self.x), int(self.y)
# Movement is done 1 pixel at a time, which ensures we don't get embedded into a wall we're moving towards
for i in range(speed):
new_x, new_y = new_x + dx, new_y + dy
if new_x < 70 or new_x > 730:
# Collided with edge of level
return True
# Normally you don't need brackets surrounding the condition for an if statement (unlike many other
# languages), but in the case where the condition is split into multiple lines, using brackets removes
# the need to use the \ symbol at the end of each line.
# The code below checks to see if we're position we're trying to move into overlaps with a block. We only
# need to check the direction we're actually moving in. So first, we check to see if we're moving down
# (dy > 0). If that's the case, we then check to see if the proposed new y coordinate is a multiple of
# GRID_BLOCK_SIZE. If it is, that means we're directly on top of a place where a block might be. If that's
# also true, we then check to see if there is actually a block at the given position. If there's a block
# there, we return True and don't update the object to the new position.
# For movement to the right, it's the same except we check to ensure that the new x coordinate is a multiple
# of GRID_BLOCK_SIZE. For moving left, we check to see if the new x coordinate is the last (right-most)
# pixel of a grid block.
# Note that we don't check for collisions when the player is moving up.
if ((dy > 0 and new_y % GRID_BLOCK_SIZE == 0 or
dx > 0 and new_x % GRID_BLOCK_SIZE == 0 or
dx < 0 and new_x % GRID_BLOCK_SIZE == GRID_BLOCK_SIZE-1)
and block(new_x, new_y)):
return True
# We only update the object's position if there wasn't a block there.
self.pos = new_x, new_y
# Didn't collide with block or edge of level
return False
class Orb(CollideActor):
MAX_TIMER = 250
def __init__(self, pos, dir_x):
super().__init__(pos)
# Orbs are initially blown horizontally, then start floating upwards
self.direction_x = dir_x
self.floating = False
self.trapped_enemy_type = None # Number representing which type of enemy is trapped in this bubble
self.timer = -1
self.blown_frames = 6 # Number of frames during which we will be pushed horizontally
def hit_test(self, bolt):
# Check for collision with a bolt
collided = self.collidepoint(bolt.pos)
if collided:
self.timer = Orb.MAX_TIMER - 1
return collided
def update(self):
self.timer += 1
if self.floating:
# Float upwards
self.move(0, -1, randint(1, 2))
else:
# Move horizontally
if self.move(self.direction_x, 0, 4):
# If we hit a block, start floating
self.floating = True
if self.timer == self.blown_frames:
self.floating = True
elif self.timer >= Orb.MAX_TIMER or self.y <= -40:
# Pop if our lifetime has run out or if we have gone off the top of the screen
game.pops.append(Pop(self.pos, 1))
if self.trapped_enemy_type != None:
# trapped_enemy_type is either zero or one. A value of one means there's a chance of creating a
# powerup such as an extra life or extra health
game.fruits.append(Fruit(self.pos, self.trapped_enemy_type))
game.play_sound("pop", 4)
if self.timer < 9:
# Orb grows to full size over the course of 9 frames - the animation frame updating every 3 frames
self.image = "orb" + str(self.timer // 3)
else:
if self.trapped_enemy_type != None:
self.image = "trap" + str(self.trapped_enemy_type) + str((self.timer // 4) % 8)
else:
self.image = "orb" + str(3 + (((self.timer - 9) // 8) % 4))
class Bolt(CollideActor):
SPEED = 7
def __init__(self, pos, dir_x):
super().__init__(pos)
self.direction_x = dir_x
self.active = True
def update(self):
# Move horizontally and check to see if we've collided with a block
if self.move(self.direction_x, 0, Bolt.SPEED):
# Collided
self.active = False
else:
# We didn't collide with a block - check to see if we collided with an orb or the player
for obj in game.orbs + [game.player]:
if obj and obj.hit_test(self):
self.active = False
break
direction_idx = "1" if self.direction_x > 0 else "0"
anim_frame = str((game.timer // 4) % 2)
self.image = "bolt" + direction_idx + anim_frame
class Pop(Actor):
def __init__(self, pos, type):
super().__init__("blank", pos)
self.type = type
self.timer = -1
def update(self):
self.timer += 1
self.image = "pop" + str(self.type) + str(self.timer // 2)
class GravityActor(CollideActor):
MAX_FALL_SPEED = 10
def __init__(self, pos):
super().__init__(pos, ANCHOR_CENTRE_BOTTOM)
self.vel_y = 0
self.landed = False
def update(self, detect=True):
# Apply gravity, without going over the maximum fall speed
self.vel_y = min(self.vel_y + 1, GravityActor.MAX_FALL_SPEED)
# The detect parameter indicates whether we should check for collisions with blocks as we fall. Normally we
# want this to be the case - hence why this parameter is optional, and is True by default. If the player is
# in the process of losing a life, however, we want them to just fall out of the level, so False is passed
# in this case.
if detect:
# Move vertically in the appropriate direction, at the appropriate speed
if self.move(0, sign(self.vel_y), abs(self.vel_y)):
# If move returned True, we must have landed on a block.
# Note that move doesn't apply any collision detection when the player is moving up - only down
self.vel_y = 0
self.landed = True
if self.top >= HEIGHT:
# Fallen off bottom - reappear at top
self.y = 1
else:
# Collision detection disabled - just update the Y coordinate without any further checks
self.y += self.vel_y
# Class for pickups including fruit, extra health and extra life
class Fruit(GravityActor):
APPLE = 0
RASPBERRY = 1
LEMON = 2
EXTRA_HEALTH = 3
EXTRA_LIFE = 4
def __init__(self, pos, trapped_enemy_type=0):
super().__init__(pos)
# Choose which type of fruit we're going to be.
if trapped_enemy_type == Robot.TYPE_NORMAL:
self.type = choice([Fruit.APPLE, Fruit.RASPBERRY, Fruit.LEMON])
else:
# If trapped_enemy_type is 1, it means this fruit came from bursting an orb containing the more dangerous type
# of enemy. In this case there is a chance of getting an extra help or extra life power up
# We create a list containing the possible types of fruit, in proportions based on the probability we want
# each type of fruit to be chosen
types = 10 * [Fruit.APPLE, Fruit.RASPBERRY, Fruit.LEMON] # Each of these appear in the list 10 times
types += 9 * [Fruit.EXTRA_HEALTH] # This appears 9 times
types += [Fruit.EXTRA_LIFE] # This only appears once
self.type = choice(types) # Randomly choose one from the list
self.time_to_live = 500 # Counts down to zero
def update(self):
super().update()
# Does the player exist, and are they colliding with us?
if game.player and game.player.collidepoint(self.center):
if self.type == Fruit.EXTRA_HEALTH:
game.player.health = min(3, game.player.health + 1)
game.play_sound("bonus")
elif self.type == Fruit.EXTRA_LIFE:
game.player.lives += 1
game.play_sound("bonus")
else:
game.player.score += (self.type + 1) * 100
game.play_sound("score")
self.time_to_live = 0 # Disappear
else:
self.time_to_live -= 1
if self.time_to_live <= 0:
# Create 'pop' animation
game.pops.append(Pop((self.x, self.y - 27), 0))
anim_frame = str([0, 1, 2, 1][(game.timer // 6) % 4])
self.image = "fruit" + str(self.type) + anim_frame
class Player(GravityActor):
def __init__(self):
# Call constructor of parent class. Initial pos is 0,0 but reset is always called straight afterwards which
# will set the actual starting position.
super().__init__((0, 0))
self.lives = 2
self.score = 0
def reset(self):
self.pos = (WIDTH / 2, 100)
self.vel_y = 0
self.direction_x = 1 # -1 = left, 1 = right
self.fire_timer = 0
self.hurt_timer = 100 # Invulnerable for this many frames
self.health = 3
self.blowing_orb = None
def hit_test(self, other):
# Check for collision between player and bolt - called from Bolt.update. Also check hurt_timer - after being hurt,
# there is a period during which the player cannot be hurt again
if self.collidepoint(other.pos) and self.hurt_timer < 0:
# Player loses 1 health, is knocked in the direction the bolt had been moving, and can't be hurt again
# for a while
self.hurt_timer = 200
self.health -= 1
self.vel_y = -12
self.landed = False
self.direction_x = other.direction_x
if self.health > 0:
game.play_sound("ouch", 4)
else:
game.play_sound("die")
return True
else:
return False
def update(self):
# Call GravityActor.update - parameter is whether we want to perform collision detection as we fall. If health
# is zero, we want the player to just fall out of the level
super().update(self.health > 0)
self.fire_timer -= 1
self.hurt_timer -= 1
if self.landed:
# Hurt timer starts at 200, but drops to 100 once the player has landed
self.hurt_timer = min(self.hurt_timer, 100)
if self.hurt_timer > 100:
# We've just been hurt. Either carry out the sideways motion from being knocked by a bolt, or if health is
# zero, we're dropping out of the level, so check for our sprite reaching a certain Y coordinate before
# reducing our lives count and responding the player. We check for the Y coordinate being the screen height
# plus 50%, rather than simply the screen height, because the former effectively gives us a short delay
# before the player respawns.
if self.health > 0:
self.move(self.direction_x, 0, 4)
else:
if self.top >= HEIGHT*1.5:
self.lives -= 1
self.reset()
else:
# We're not hurt
# Get keyboard input. dx represents the direction the player is facing
dx = 0
if keyboard.left:
dx = -1
elif keyboard.right:
dx = 1
if dx != 0:
self.direction_x = dx
# If we haven't just fired an orb, carry out horizontal movement
if self.fire_timer < 10:
self.move(dx, 0, 4)
# Do we need to create a new orb? Space must have been pressed and released, the minimum time between
# orbs must have passed, and there is a limit of 5 orbs.
if space_pressed() and self.fire_timer <= 0 and len(game.orbs) < 5:
# x position will be 38 pixels in front of the player position, while ensuring it is within the
# bounds of the level
x = min(730, max(70, self.x + self.direction_x * 38))
y = self.y - 35
self.blowing_orb = Orb((x,y), self.direction_x)
game.orbs.append(self.blowing_orb)
game.play_sound("blow", 4)
self.fire_timer = 20
if keyboard.up and self.vel_y == 0 and self.landed:
# Jump
self.vel_y = -16
self.landed = False
game.play_sound("jump")
# Holding down space causes the current orb (if there is one) to be blown further
if keyboard.space:
if self.blowing_orb:
# Increase blown distance up to a maximum of 120
self.blowing_orb.blown_frames += 4
if self.blowing_orb.blown_frames >= 120:
# Can't be blown any further
self.blowing_orb = None
else:
# If we let go of space, we relinquish control over the current orb - it can't be blown any further
self.blowing_orb = None
# Set sprite image. If we're currently hurt, the sprite will flash on and off on alternate frames.
self.image = "blank"
if self.hurt_timer <= 0 or self.hurt_timer % 2 == 1:
dir_index = "1" if self.direction_x > 0 else "0"
if self.hurt_timer > 100:
if self.health > 0:
self.image = "recoil" + dir_index
else:
self.image = "fall" + str((game.timer // 4) % 2)
elif self.fire_timer > 0:
self.image = "blow" + dir_index
elif dx == 0:
self.image = "still"
else:
self.image = "run" + dir_index + str((game.timer // 8) % 4)
class Robot(GravityActor):
TYPE_NORMAL = 0
TYPE_AGGRESSIVE = 1
def __init__(self, pos, type):
super().__init__(pos)
self.type = type
self.speed = randint(1, 3)
self.direction_x = 1
self.alive = True
self.change_dir_timer = 0
self.fire_timer = 100
def update(self):
super().update()
self.change_dir_timer -= 1
self.fire_timer += 1
# Move in current direction - turn around if we hit a wall
if self.move(self.direction_x, 0, self.speed):
self.change_dir_timer = 0
if self.change_dir_timer <= 0:
# Randomly choose a direction to move in
# If there's a player, there's a two thirds chance that we'll move towards them
directions = [-1, 1]
if game.player:
directions.append(sign(game.player.x - self.x))
self.direction_x = choice(directions)
self.change_dir_timer = randint(100, 250)
# The more powerful type of robot can deliberately shoot at orbs - turning to face them if necessary
if self.type == Robot.TYPE_AGGRESSIVE and self.fire_timer >= 24:
# Go through all orbs to see if any can be shot at
for orb in game.orbs:
# The orb must be at our height, and within 200 pixels on the x axis
if orb.y >= self.top and orb.y < self.bottom and abs(orb.x - self.x) < 200:
self.direction_x = sign(orb.x - self.x)
self.fire_timer = 0
break
# Check to see if we can fire at player
if self.fire_timer >= 12:
# Random chance of firing each frame. Likelihood increases 10 times if player is at the same height as us
fire_probability = game.fire_probability()
if game.player and self.top < game.player.bottom and self.bottom > game.player.top:
fire_probability *= 10
if random() < fire_probability:
self.fire_timer = 0
game.play_sound("laser", 4)
elif self.fire_timer == 8:
# Once the fire timer has been set to 0, it will count up - frame 8 of the animation is when the actual bolt is fired
game.bolts.append(Bolt((self.x + self.direction_x * 20, self.y - 38), self.direction_x))
# Am I colliding with an orb? If so, become trapped by it
for orb in game.orbs:
if orb.trapped_enemy_type == None and self.collidepoint(orb.center):
self.alive = False
orb.floating = True
orb.trapped_enemy_type = self.type
game.play_sound("trap", 4)
break
# Choose and set sprite image
direction_idx = "1" if self.direction_x > 0 else "0"
image = "robot" + str(self.type) + direction_idx
if self.fire_timer < 12:
image += str(5 + (self.fire_timer // 4))
else:
image += str(1 + ((game.timer // 4) % 4))
self.image = image
class Game:
def __init__(self, player=None):
self.player = player
self.level_colour = -1
self.level = -1
self.next_level()
def fire_probability(self):
# Likelihood per frame of each robot firing a bolt - they fire more often on higher levels
return 0.001 + (0.0001 * min(100, self.level))
def max_enemies(self):
# Maximum number of enemies on-screen at once - increases as you progress through the levels
return min((self.level + 6) // 2, 8)
def next_level(self):
self.level_colour = (self.level_colour + 1) % 4
self.level += 1
# Set up grid
self.grid = LEVELS[self.level % len(LEVELS)]
# The last row is a copy of the first row
# Note that we don't do 'self.grid.append(self.grid[0])'. That would alter the original data in the LEVELS list
# Instead, what this line does is create a brand new list, which is distinct from the list in LEVELS, and
# consists of the level data plus the first row of the level. It's also interesting to note that you can't
# do 'self.grid += [self.grid[0]]', because that's equivalent to using append.
# As an alternative, we could have copied the list on the line below '# Set up grid', by writing
# 'self.grid = list(LEVELS...', then used append or += on the line below.
self.grid = self.grid + [self.grid[0]]
self.timer = -1
if self.player:
self.player.reset()
self.fruits = []
self.bolts = []
self.enemies = []
self.pops = []
self.orbs = []
# At the start of each level we create a list of pending enemies - enemies to be created as the level plays out.
# When this list is empty, we have no more enemies left to create, and the level will end once we have destroyed
# all enemies currently on-screen. Each element of the list will be either 0 or 1, where 0 corresponds to
# a standard enemy, and 1 is a more powerful enemy.
# First we work out how many total enemies and how many of each type to create
num_enemies = 10 + self.level
num_strong_enemies = 1 + int(self.level / 1.5)
num_weak_enemies = num_enemies - num_strong_enemies
# Then we create the list of pending enemies, using Python's ability to create a list by multiplying a list
# by a number, and by adding two lists together. The resulting list will consist of a series of copies of
# the number 1 (the number depending on the value of num_strong_enemies), followed by a series of copies of
# the number zero, based on num_weak_enemies.
self.pending_enemies = num_strong_enemies * [Robot.TYPE_AGGRESSIVE] + num_weak_enemies * [Robot.TYPE_NORMAL]
# Finally we shuffle the list so that the order is randomised (using Python's random.shuffle function)
shuffle(self.pending_enemies)
self.play_sound("level", 1)
def get_robot_spawn_x(self):
# Find a spawn location for a robot, by checking the top row of the grid for empty spots
# Start by choosing a random grid column
r = randint(0, NUM_COLUMNS-1)
for i in range(NUM_COLUMNS):
# Keep looking at successive columns (wrapping round if we go off the right-hand side) until
# we find one where the top grid column is unoccupied
grid_x = (r+i) % NUM_COLUMNS
if self.grid[0][grid_x] == ' ':
return GRID_BLOCK_SIZE * grid_x + LEVEL_X_OFFSET + 12
# If we failed to find an opening in the top grid row (shouldn't ever happen), just spawn the enemy
# in the centre of the screen
return WIDTH/2
def update(self):
self.timer += 1
# Update all objects
for obj in self.fruits + self.bolts + self.enemies + self.pops + [self.player] + self.orbs:
if obj:
obj.update()
# Use list comprehensions to remove objects which are no longer wanted from the lists. For example, we recreate
# self.fruits such that it contains all existing fruits except those whose time_to_live counter has reached zero
self.fruits = [f for f in self.fruits if f.time_to_live > 0]
self.bolts = [b for b in self.bolts if b.active]
self.enemies = [e for e in self.enemies if e.alive]
self.pops = [p for p in self.pops if p.timer < 12]
self.orbs = [o for o in self.orbs if o.timer < 250 and o.y > -40]
# Every 100 frames, create a random fruit (unless there are no remaining enemies on this level)
if self.timer % 100 == 0 and len(self.pending_enemies + self.enemies) > 0:
# Create fruit at random position
self.fruits.append(Fruit((randint(70, 730), randint(75, 400))))
# Every 81 frames, if there is at least 1 pending enemy, and the number of active enemies is below the current
# level's maximum enemies, create a robot
if self.timer % 81 == 0 and len(self.pending_enemies) > 0 and len(self.enemies) < self.max_enemies():
# Retrieve and remove the last element from the pending enemies list
robot_type = self.pending_enemies.pop()
pos = (self.get_robot_spawn_x(), -30)
self.enemies.append(Robot(pos, robot_type))
# End level if there are no enemies remaining to be created, no existing enemies, no fruit, no popping orbs,
# and no orbs containing trapped enemies. (We don't want to include orbs which don't contain trapped enemies,
# as the level would never end if the player kept firing new orbs)
if len(self.pending_enemies + self.fruits + self.enemies + self.pops) == 0:
if len([orb for orb in self.orbs if orb.trapped_enemy_type != None]) == 0:
self.next_level()
def draw(self):
# Draw appropriate background for this level
screen.blit("bg%d" % self.level_colour, (0, 0))
block_sprite = "block" + str(self.level % 4)
# Display blocks
for row_y in range(NUM_ROWS):
row = self.grid[row_y]
if len(row) > 0:
# Initial offset - large blocks at edge of level are 50 pixels wide
x = LEVEL_X_OFFSET
for block in row:
if block != ' ':
screen.blit(block_sprite, (x, row_y * GRID_BLOCK_SIZE))
x += GRID_BLOCK_SIZE
# Draw all objects
all_objs = self.fruits + self.bolts + self.enemies + self.pops + self.orbs
all_objs.append(self.player)
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)
# Widths of the letters A to Z in the font images
CHAR_WIDTH = [27, 26, 25, 26, 25, 25, 26, 25, 12, 26, 26, 25, 33, 25, 26,
25, 27, 26, 26, 25, 26, 26, 38, 25, 25, 25]
def char_width(char):
# Return width of given character. For characters other than the letters A to Z (i.e. space, and the digits 0 to 9),
# the width of the letter A is returned. ord gives the ASCII/Unicode code for the given character.
index = max(0, ord(char) - 65)
return CHAR_WIDTH[index]
def draw_text(text, y, x=None):
if x == None:
# If no X pos specified, draw text in centre of the screen - must first work out total width of text
x = (WIDTH - sum([char_width(c) for c in text])) // 2
for char in text:
screen.blit("font0"+str(ord(char)), (x, y))
x += char_width(char)
IMAGE_WIDTH = {"life":44, "plus":40, "health":40}
def draw_status():
# Display score, right-justified at edge of screen
number_width = CHAR_WIDTH[0]
s = str(game.player.score)
draw_text(s, 451, WIDTH - 2 - (number_width * len(s)))
# Display level number
draw_text("LEVEL " + str(game.level + 1), 451)
# Display lives and health
# We only display a maximum of two lives - if there are more than two, a plus symbol is displayed
lives_health = ["life"] * min(2, game.player.lives)
if game.player.lives > 2:
lives_health.append("plus")
if game.player.lives >= 0:
lives_health += ["health"] * game.player.health
x = 0
for image in lives_health:
screen.blit(image, (x, 450))
x += IMAGE_WIDTH[image]
# 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():
# Switch to play state, and create a new Game object, passing it a new Player object to use
state = State.PLAY
game = Game(Player())
else:
game.update()
elif state == State.PLAY:
if game.player.lives < 0:
game.play_sound("over")
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():
game.draw()
if state == State.MENU:
# Draw title screen
screen.blit("title", (0, 0))
# Draw "Press SPACE" animation, which has 10 frames numbered 0 to 9
# The first part gives us a number between 0 and 159, based on the game timer
# Dividing by 4 means we go to a new animation frame every 4 frames
# We enclose this calculation in the min function, with the other argument being 9, which results in the
# animation staying on frame 9 for three quarters of the time. Adding 40 to the game timer is done to alter
# which stage the animation is at when the game first starts
anim_frame = min(((game.timer + 40) % 160) // 4, 9)
screen.blit("space" + str(anim_frame), (130, 280))
elif state == State.PLAY:
draw_status()
elif state == State.GAME_OVER:
draw_status()
# Display "Game Over" image
screen.blit("over", (0, 0))
# Set up sound system and start music
try:
pygame.mixer.quit()
pygame.mixer.init(44100, -16, 2, 1024)
music.play("theme")
music.set_volume(0.3)
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()