eggzy

The game screen appears here if your browser supports the Canvas API.

Attribution

Code the Classics – Volume 2.

Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported.

Original Python code


# Eggzy - Code the Classics Volume 2
# Code by Andrew Gillett and Eben Upton
# Graphics by Dan Malone
# Music and sound effects by Allister Brimble
# https://github.com/raspberrypipress/Code-the-Classics-Vol2.git
# https://store.rpipress.cc/products/code-the-classics-volume-ii

import pygame, pgzero, pgzrun, sys, os
import xml.etree.ElementTree as ET
from abc import ABC, abstractmethod
from enum import Enum
from random import randint

# On Windows, if the window is too big for the screen and you are using display scaling, you can uncomment
# the following two lines to fix the issue
# import ctypes
# ctypes.windll.user32.SetProcessDPIAware()

# 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,6):
    print("This game requires at least version 3.6 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')
# This uses a Python feature called list comprehension
pgzero_version = [int(s) if s.isnumeric() else s for s in pgzero.__version__.split('.')]
if pgzero_version < [1,2]:
    print(f"This game requires at least version 1.2 of Pygame Zero. You have version {pgzero.__version__}. Please upgrade using the command 'pip3 install --upgrade pgzero'")
    sys.exit()

# Set up constants
WIDTH = 825
HEIGHT = 550
TITLE = "Eggzy"

LEVEL_SEQUENCE = ("starter1.tmx", "starter2.tmx", "starter3.tmx", "starter4.tmx",
                  "forest1.tmx", "forest2.tmx", "forest3.tmx", "forest4.tmx", "forest9.tmx",
                  "castle1.tmx", "castle2.tmx", "castle3.tmx", "castle4.tmx",
                  "castle5.tmx", "castle6.tmx", "castle7.tmx", "castle8.tmx",
                  "forest5.tmx", "forest6.tmx", "forest7.tmx", "forest8.tmx")

GRID_BLOCK_SIZE = 25
LEVEL_Y_BOUNDARY = -100

# Change to 1, 2 or 3 to start with enemies/more enemies and less bonus time for gems
INITIAL_LEVEL_CYCLE = 0

INITIAL_TIME_REMAINING = 15
INITIAL_PICKUP_TIME_BONUS = 2
STOMP_ENEMY_TIME_BONUS = 3

# Constants affecting player movement
COYOTE_TIME = 6
JUMP_VEL_Y = -10
WALL_JUMP_X_VEL = 8
WALL_JUMP_COYOTE_TIME = 15
CACHE_JUMP_INPUT_TIME = 5
PLAYER_WIDTH = 20   # Width of player for the purpose of collisions - slightly smaller than the bounds of the sprite
PLAYER_HEIGHT = 40  # For player head collision with ceilings

ANCHOR_CENTRE = ("center", "center")
ANCHOR_CENTRE_BOTTOM = ("center", "bottom")
ANCHOR_PLAYER = ("center", 60)                # Feet of player sprite are not at the bottom
ANCHOR_FLAME = ("center", 78)
ANCHOR_FLAME_DASH = ("center", 130)

class Biome(Enum):
    FOREST = 0
    CASTLE = 1

# There are eight types of enemy - four per biome. Some properties are the same between the forest/castle equivalents,
# some are different

ENEMY_SPRITE_NAMES = {Biome.CASTLE: ["robot0", "robot1", "robot2", "robot3"],
                      Biome.FOREST: ["fly", "mghost", "triffid", "bigbloom"]}

ENEMY_TYPES_FLYING = {Biome.CASTLE: [True, True, False, False], Biome.FOREST: [True, True, False, True]}

ENEMY_TYPES_WIDTH_OVERRIDES = {Biome.CASTLE: [30, 50, 48, 50], Biome.FOREST: [30, 50, 50, 50]}
ENEMY_TYPES_HEIGHT_OVERRIDES = {Biome.CASTLE: [40, 40, 60, 120], Biome.FOREST: [30, 65, 70, 90]}

ENEMY_TYPES_ANCHOR_POINTS = {Biome.CASTLE: [("center", 40),("center", 40),("center", 95),("center", "bottom")],
                             Biome.FOREST: [("center", 60),("center", "bottom"),("center", "bottom"),("center", "bottom")]}

ENEMY_TYPES_HEALTH = [1, 3, 1, 3]
ENEMY_TYPES_SPEED = [2, 1, 2, 1]

REPLAY_FILENAME = "eggzy-replays"
MAX_REPLAYS = 10

DEBUG_SHOW_PLAYER_COLLISION_RECT = False
DEBUG_SHOW_ENEMY_COLLISION_RECTS = False
DEBUG_SHOW_BLOCK_COLLISION_RECTS = False
DEBUG_SHOW_FRAME_NUMBER = False
DEBUG_MOVEMENT = False
DEBUG_SLOWMO = 1        # Set to 2 or higher to run in slow motion, useful for testing animations

# These symbols substitute for the controller button images when displaying text.
# The symbols representing these images must be ones that aren't actually used themselves, e.g. we don't use the
# percent sign in text
SPECIAL_FONT_SYMBOLS = {'xb_a':'%', 'xb_b':'#'}

# Create a version of SPECIAL_FONT_SYMBOLS where the keys and values are swapped
SPECIAL_FONT_SYMBOLS_INVERSE = dict((v,k) for k,v in SPECIAL_FONT_SYMBOLS.items())

def move_towards(n, target, speed):
    if n < target:
        return min(n + speed, target)
    else:
        return max(n - speed, target)

def sign(x):
    # Returns 1, 0 or -1 depending on whether number is positive, zero or negative
    if x == 0:
        return 0
    else:
        return -1 if x < 0 else 1

# ABC = abstract base class - a class which is only there to serve as a base class, not to be instantiated directly
class Controls(ABC):
    NUM_BUTTONS = 2

    def __init__(self):
        self.button_previously_down = [False for i in range(Controls.NUM_BUTTONS)]
        self.is_button_pressed = [False for i in range(Controls.NUM_BUTTONS)]

    def update(self):
        # Call each frame to update button status
        for button in range(Controls.NUM_BUTTONS):
            button_down = self.button_down(button)
            self.is_button_pressed[button] = button_down and not self.button_previously_down[button]
            self.button_previously_down[button] = button_down

    @abstractmethod
    def get_x(self):
        # Overridden by subclasses
        pass

    @abstractmethod
    def get_y(self):
        # Overridden by subclasses
        pass

    @abstractmethod
    def button_down(self, button):
        # Overridden by subclasses
        pass

    def button_pressed(self, button):
        return self.is_button_pressed[button]

    @abstractmethod
    def button_name(self, button):
        return "?"

class KeyboardControls(Controls):
    def get_x(self):
        if keyboard.left:
            return -1
        elif keyboard.right:
            return 1
        else:
            return 0

    def get_y(self):
        if keyboard.up:
            return -1
        elif keyboard.down:
            return 1
        else:
            return 0

    def button_down(self, button):
        if button == 0:
            return keyboard.space
        else:
            return keyboard.z

    def button_name(self, button):
        if button == "dash":
            return "Z"
        elif button == "jump":
            return "SPACE"
        else:
            return "?"

class JoystickControls(Controls):
    def __init__(self, joystick):
        super().__init__()
        self.joystick = joystick
        joystick.init() # Not necessary in Pygame 2.0.0 onwards

    def get_axis(self, axis_num):
        if self.joystick.get_numhats() > 0 and self.joystick.get_hat(0)[axis_num] != 0:
            # For some reason, dpad up/down are inverted when getting inputs from
            # an Xbox controller, so need to negate the value if axis_num is 1
            return self.joystick.get_hat(0)[axis_num] * (-1 if axis_num == 1 else 1)

        axis_value = self.joystick.get_axis(axis_num)
        if abs(axis_value) < 0.6:
            # Dead-zone
            return 0
        else:
            # digital movement
            return 1 if axis_value > 0 else -1

    def get_x(self):
        return self.get_axis(0)

    def get_y(self):
        return self.get_axis(1)

    def button_down(self, button):
        # Before checking button, check to make sure that the controller actually has enough buttons
        # There are some weird devices out there which could cause a crash if this check were not present
        if self.joystick.get_numbuttons() <= button:
            print("Warning: controller does not have enough buttons!")
            return False
        return self.joystick.get_button(button) != 0

    def button_name(self, button):
        if button == "dash":
            return SPECIAL_FONT_SYMBOLS["xb_b"]
        elif button == "jump":
            return SPECIAL_FONT_SYMBOLS["xb_a"]
        else:
            return "?"

# Class for gem pickups
class Gem(Actor):
    # This is a class variable, equivalent to what is known in other languages as a static variable
    # The variable belongs to the class as a whole rather than any one particular instance (object) of the class
    next_type = 1

    def __init__(self, pos):
        super().__init__("blank", pos, ANCHOR_CENTRE_BOTTOM)

        # Choose which type of gem we're going to be.
        self.type = Gem.next_type

        # Set the type of the next gem
        Gem.next_type += 1
        if Gem.next_type >= 5:
            Gem.next_type = 1

        self.collected = False

    def update(self):
        # Does the player exist, and are they colliding with us?
        if game.player is not None and game.player.collidepoint(self.center):
            game.gain_time(game.time_pickup_bonus, self.centerx, self.centery)
            game.play_sound("collect")
            self.collected = True  # Disappear

        anim_frame = str((game.timer // 6) % 4)
        self.image = f"gem{self.type}_{anim_frame}"

    @staticmethod
    def new_game():
        Gem.next_type = 1

# The door prevents the player from leaving a level until all gems have been collected
class Door(Actor):
    def __init__(self, pos, biome="castle", variant=0, already_open=False):
        self.biome = biome
        self.variant = variant
        self.opening = already_open
        self.last_frame = 15 if biome == "castle" else 13
        self.frame = self.last_frame if already_open else 0
        super().__init__(f"door_{biome}_{variant}_{self.frame}", pos, anchor=(0,0))

    def update(self):
        if self.opening and self.frame < self.last_frame and game.timer % 3 == 0:
            self.frame += 1
            self.image = f"door_{self.biome}_{self.variant}_{self.frame}"

    def open(self):
        self.opening = True

    def is_fully_open(self):
        return self.frame == self.last_frame

# Used for animations such as those that appear when you pick up a gem or lose a life
class Animation(Actor):
    def __init__(self, pos, image_format_str, num_frames, frame_interval, anchor=ANCHOR_CENTRE, initial_delay=0, rise_time=-1):
        super().__init__("blank", pos, anchor)
        self.image_format_str = image_format_str
        self.num_frames = num_frames
        self.frame_interval = frame_interval
        self.timer = -initial_delay
        self.rise_time = rise_time
        self.update_image()

    def update(self):
        self.timer += 1
        self.update_image()

        # Some animations start rising up after a certain time
        if self.rise_time > -1 and self.timer > self.rise_time:
            self.y -= 1

    def update_image(self):
        if self.timer < 0:
            self.image = "blank"
        else:
            frame = min(self.timer // self.frame_interval, self.num_frames - 1)
            self.image = self.image_format_str.format(frame)

    def finished(self):
        return self.timer // self.frame_interval >= self.num_frames

class DashTrail(Animation):
    def __init__(self, pos, image):
        # Receive's the player's current sprite, uses the trail version of that sprite
        super().__init__(pos, image + "_trail_{0}", 6, 5, ANCHOR_PLAYER)

# Base class for objects which move around the level and collide with walls, such as the player and enemies
class CollideActor(Actor):
    def __init__(self, pos, anchor=ANCHOR_CENTRE):
        super().__init__("blank", pos, anchor)

    def move(self, dx, dy, speed):
        # Returns true if move was blocked
        # One of dx or dy will be 0

        new_x, new_y = self.x, 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

            # Get the player rectangle as it would be if the position were changed to new_x, new_y
            rect = self.get_rect(new_x, new_y)

            # Does this proposed new position overlap with any of the collidable tiles, or the exit door?
            if game.position_blocked(rect):
                #print(" blocked")
                return True

            # We only update the object's position if there wasn't a block there.
            self.pos = new_x, new_y
            #print(" moved")

        # Didn't collide with anything
        return False

    def get_rect(self, centre_x=None, bottom_y=None):
        # Returns a rectangle representing this actor, assuming it were positioned at the specified x and y coordinates
        # (If None, we default to the actual X/Y pos of the actor)
        # We don't use the standard Pygame Zero practice of just using the sprite bounds as the rectangle, as for the
        # player enemies we want the collidable size to be a bit smaller than the sprite
        if centre_x is None:
            centre_x = self.x
        if bottom_y is None:
            bottom_y = self.y
        w, h = self.get_collidable_width(), self.get_collidable_height()
        return Rect(centre_x - (w // 2), bottom_y - h, w, h)

    def get_collidable_width(self):
        # Overridden for Player and Enemy
        image_surface = getattr(images, self.image)
        return image_surface.get_width()

    def get_collidable_height(self):
        # Overridden for Player and Enemy
        image_surface = getattr(images, self.image)
        return image_surface.get_height()


# An actor who is subject to gravity, this includes the player and non-flying enemies
# The flying enemies do actually use this too, but disable it by setting gravity_enabled to false,
# demonstrating a drawback of inheritance in object-oriented programming! In a component-based
# system such as Unity, objects which want gravity could instead have a gravity component.
class GravityActor(CollideActor):
    MAX_FALL_SPEED = 7

    class FallState(Enum):
        LANDED = 0
        FALLING = 1
        JUMPING = 2
        WALL_JUMPING = 3

    def __init__(self, pos, gravity_enabled=True, anchor=ANCHOR_CENTRE_BOTTOM):
        super().__init__(pos, anchor)

        self.gravity_enabled = gravity_enabled
        self.vel_y = 0
        self.fall_state = GravityActor.FallState.FALLING
        self.lower_gravity_timer = 0

    def update(self, detect=True):
        if not self.gravity_enabled:
            return

        self.lower_gravity_timer -= 1

        # Apply change to Y velocity
        if game.timer % (3 if self.lower_gravity_timer > 0 else 2) == 0:
            self.vel_y = min(self.vel_y + 1, GravityActor.MAX_FALL_SPEED)

        # Apply gravity, without going over the maximum 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 and self.vel_y != 0:
            # Move vertically in the appropriate direction, at the appropriate speed
            # Set landed to false, if we're on the floor it'll be set to true again below, otherwise it will remain
            # false
            if DEBUG_MOVEMENT:
                print("{0} detect: landed false, {1}".format(game.timer, self.vel_y))
            if self.fall_state == GravityActor.FallState.LANDED:
                self.fall_state = GravityActor.FallState.FALLING
            if self.move(0, sign(self.vel_y), abs(self.vel_y)):
                if DEBUG_MOVEMENT:
                    print("move returned true")
                # If move returned True, we must have either landed or hit our head on the ceiling
                if self.vel_y > 0:
                    self.vel_y = 0
                    self.fall_state = GravityActor.FallState.LANDED
                    if DEBUG_MOVEMENT:
                        print("detect: landed true")

        else:
            # Collision detection disabled - just update the Y coordinate without any further checks
            self.y += self.vel_y

    def landed(self):
        return self.fall_state == GravityActor.FallState.LANDED


class Player(GravityActor):
    DASH_TIME = 18
    DASH_SPEED = 10
    DASH_PAUSE_TIME = 5
    DASH_TRAIL_INTERVAL = 3
    DASH_TIMER_TRAIL_CUTOFF = -10
    MAX_X_RUN_SPEED = 5

    def __init__(self, controls):
        # Call constructor of parent class. Initial pos is 0,0 but Game.next_level will set the actual starting position
        super().__init__((0, 0), anchor=ANCHOR_PLAYER)

        self.controls = controls

        # Actor for the flame on the character's head
        self.flame = Actor("flame_stand_0", self.pos, anchor=ANCHOR_FLAME)

        self.vel_x = 0
        self.facing_x = 1
        self.hurt = False
        self.dash_timer = Player.DASH_TIMER_TRAIL_CUTOFF    # Counts down
        self.dash_animation_timer = 0                       # Counts up
        self.dash_allowed = False
        self.grabbed_wall = 0
        self.coyote_time = 0
        self.fall_timer = 0                 # Number of frames since we started falling or jumping
        self.wall_jump_coyote_time = 0
        self.cached_jump_input_timer = 0
        self.enemy_stomped_timer = 0
        self.change_direction_timer = 0
        self.last_dash_sprite = "dash_horizontal_0_0"   # Used for dash trails

        self.replay_data = []

    def new_level(self, start_pos):
        self.start_pos = start_pos
        self.reset()

    def reset(self):
        self.pos = self.start_pos
        self.vel_x = 0
        self.vel_y = 0
        self.facing_x = 1            # -1 = left, 1 = right
        self.hurt = False
        self.dash_timer = Player.DASH_TIMER_TRAIL_CUTOFF
        self.gravity_enabled = True
        self.grabbed_wall = 0
        self.coyote_time = 0
        self.wall_jump_coyote_time = 0
        self.cached_jump_input_timer = 0
        self.enemy_stomped_timer = 0

        # Ensure that when we spawn or respawn, there are no enemies at or near that position - treat it
        # as if we'd stomped on their heads
        # Need to check for game being None because player is constructed during Game construction, and the
        # game global won't be set until construction is complete
        if game is not None:
            for enemy in game.enemies:
                if self.distance_to(enemy) < 150:  # 150 pixel radius, to be safe
                    enemy.destroy()
                    game.play_sound("enemy_death", 5)

    def hit_test(self, other):
        # Check for collision between player and enemy - called from Player.update
        return self.get_rect(self.x, self.y).colliderect(other.get_rect()) and not self.hurt

    def get_colliding_enemies(self):
        return [enemy for enemy in game.enemies if not enemy.dying and self.hit_test(enemy)]

    def update(self):
        # Call GravityActor.update - parameter is whether we want to perform collision detection as we fall
        was_landed = self.landed()
        super().update(not self.hurt)

        if was_landed and not self.landed():
            # We must have walked off a platform. Set coyote time timer
            self.coyote_time = COYOTE_TIME
            self.fall_timer = 0

        if self.top >= HEIGHT:
            self.reset()

        # Check for collisions with enemies, including landing on their heads
        stomped_any = False
        for enemy in self.get_colliding_enemies():
            # Die or stomp? Are we within the top 20% of the enemy collision rectangle?
            # If we're moving downward, increase the threshold to the top 50% of the collision rectangle
            # We're fairly forgiving about this - otherwise some of the deaths seem unfair
            enemy_rect = enemy.get_rect()
            threshold = enemy_rect.top + (enemy_rect.bottom - enemy_rect.top) * (0.5 if self.vel_y > 0 else 0.2)
            # If the player stomps an enemy due to downward motion they'll now be moving up, so unless they're within
            # the top 20% of the sprite, they'll get hit by it on the next frame. Prevent this using stomped_last_frame
            if self.y < threshold or self.stomped_last_frame:
                enemy.stomped()
                stomped_any = True
                self.vel_y = -6
                self.enemy_stomped_timer = 3
                self.dash_allowed = True
                if DEBUG_MOVEMENT:
                    print(game.timer, "stomp", self.y, threshold)
            else:
                # Die and respawn
                self.hurt = True
                self.vel_y = -12
                self.fall_state = GravityActor.FallState.FALLING
                self.fall_timer = 0
                self.dash_timer = Player.DASH_TIMER_TRAIL_CUTOFF
                game.play_sound("player_death")
                game.animations.append(Animation(self.pos, "loselife_{0}", 8, 4))
                if DEBUG_MOVEMENT:
                    print(game.timer, "DIE", self.y, threshold)
                break

        self.stomped_last_frame = stomped_any

        if self.landed():
            self.dash_allowed = True

        self.dash_timer -= 1
        self.dash_animation_timer += 1
        self.cached_jump_input_timer -= 1
        self.coyote_time -= 1
        self.wall_jump_coyote_time -= 1

        if self.dash_timer > Player.DASH_TIMER_TRAIL_CUTOFF:
            if self.dash_timer % Player.DASH_TRAIL_INTERVAL == 0:
                game.animations.append(DashTrail(self.pos, self.last_dash_sprite))

        dx = 0  # X direction we tried to move this frame, zero if we are standing still

        jump_pressed = self.controls.button_pressed(0)

        if jump_pressed and DEBUG_MOVEMENT:
            print(game.timer, "jump pressed")

        #print(game.timer, self.cached_jump_input_timer, self.wall_jump_coyote_time)

        if self.hurt:
            # We've just been hurt. We're dropping out of the level, so check for our sprite reaching a certain Y
            # coordinate before setting hurt to False. Code further down will make the player respawn.
            self.gravity_enabled = True
            if self.top >= HEIGHT:
                self.hurt = False

        elif self.dash_timer > 0:
            # Update dash
            # For first few frames of dash, equating to dash_timer being above DASH_TIME, player doesn't move
            if self.dash_timer < Player.DASH_TIME:
                if self.dash_timer % Player.DASH_TRAIL_INTERVAL == 0:
                    game.animations.append(DashTrail(self.pos, self.last_dash_sprite))

                # A dash may be vertical, horizontal or diagonal
                # The horizontal and vertical components of the velocity are applied separately, to improve how
                # collision detection works

                # Apply vertical component of dash
                self.move(0, sign(self.vel_y), abs(self.vel_y))

                # Apply horizontal component of dash
                if self.move(sign(self.vel_x), 0, abs(self.vel_x)) and self.vel_y >= 0:
                    # If we hit a wall, and are not travelling up, end the dash
                    self.dash_timer = 0
                    self.grabbed_wall = self.facing_x

        else:
            # We're not hurt or dashing
            # We're either on a wall, jumping/falling or walking

            # Get keyboard input. dx represents the direction the player is facing
            dx = self.controls.get_x()

            def jump():
                if DEBUG_MOVEMENT:
                    print(game.timer, "JUMP")
                self.vel_y = JUMP_VEL_Y
                self.fall_state = GravityActor.FallState.JUMPING
                self.coyote_time = 0
                self.cached_jump_input_timer = 0
                self.lower_gravity_timer = 5
                self.fall_timer = 0
                game.play_sound("jump")

            def wall_jump(wall_direction):
                if DEBUG_MOVEMENT:
                    print(game.timer, "WALL JUMP", wall_direction)
                self.vel_y = JUMP_VEL_Y
                self.fall_state = GravityActor.FallState.WALL_JUMPING
                self.vel_x = -wall_direction * WALL_JUMP_X_VEL
                self.facing_x = -wall_direction
                self.grabbed_wall = 0
                self.previous_grabbed_wall = 0
                self.wall_jump_coyote_time = 0
                self.cached_jump_input_timer = 0
                self.fall_timer = 0
                game.play_sound("jump")

            # Non-zero means we're grabbing a wall
            if self.grabbed_wall != 0:
                # Wall slide
                self.gravity_enabled = False

                # Check for wall jump
                if jump_pressed or self.cached_jump_input_timer > 0:
                    if DEBUG_MOVEMENT:
                        print(game.timer, "wall jump", self.vel_x)
                    wall_jump(self.grabbed_wall)

                # Check if player is pushing away from the wall
                elif dx == -self.grabbed_wall:
                    if DEBUG_MOVEMENT:
                        print(game.timer, "ungrab wall", self.grabbed_wall)
                    self.previous_grabbed_wall = self.grabbed_wall
                    self.wall_jump_coyote_time = WALL_JUMP_COYOTE_TIME
                    self.grabbed_wall = 0

                else:
                    # Slowly slide down wall, stop grabbing if we hit the floor or the wall is no longer there (because
                    # we slid off the bottom of it)
                    if DEBUG_MOVEMENT:
                        print(game.timer, "slide", self.grabbed_wall)

                    rect = self.get_rect(self.x + self.grabbed_wall, self.y)

                    if self.move(0, 1, 1) or not game.position_blocked(rect):
                        self.grabbed_wall = 0
                        if DEBUG_MOVEMENT:
                            print(game.timer, "slide landed or wall gone")

            else:
                # Not grabbing a wall
                # Check for coyote time wall jump, i.e. a wall jump just after we let go of the wall

                # debug
                if DEBUG_MOVEMENT and self.wall_jump_coyote_time > 0:
                    print(game.timer, "remaining wall_jump_coyote_time", self.wall_jump_coyote_time)

                if jump_pressed and self.wall_jump_coyote_time > 0:
                    if DEBUG_MOVEMENT:
                        print(game.timer, "coyote wall jump")
                    wall_jump(self.previous_grabbed_wall)

                else:
                    # Normal movement
                    self.gravity_enabled = True
                    if dx == 0:
                        # No horizontal input - come to a halt over several frames
                        self.vel_x = move_towards(self.vel_x, 0, 1)
                    else:
                        # Horizontal input - apply to x velocity
                        self.facing_x = dx
                        self.vel_x = move_towards(self.vel_x, Player.MAX_X_RUN_SPEED * dx, 1)

                    # Apply x velocity
                    # Start grabbing wall if we hit a wall and our y velocity is downwards
                    # Note: order of checks matters, self.move may cause us to move so must come before vel_y check
                    if self.vel_x != 0 and self.move(sign(self.vel_x), 0, abs(self.vel_x)) and self.vel_y > 0:
                        if DEBUG_MOVEMENT:
                            print(game.timer, "grab")
                        self.grabbed_wall = sign(self.vel_x)

                        # Cancel horizontal velocity on hitting wall
                        self.vel_x = 0

                    if (jump_pressed or self.cached_jump_input_timer > 0) and (self.landed() or self.coyote_time > 0):
                        # Jump
                        if DEBUG_MOVEMENT:
                            if not jump_pressed:
                                print(game.timer, "cached jump")
                            if not self.landed():
                                print(game.timer, f"coyote time jump {self.coyote_time}")
                        jump()

                    elif jump_pressed and not self.landed():
                        # Cache jump input for a few frames, so that if the player lands just after pressing jump,
                        # a jump will be initiated
                        self.cached_jump_input_timer = CACHE_JUMP_INPUT_TIME

                    elif not self.landed() and self.vel_y < 0 and not self.controls.button_down(0) and self.dash_timer < -10 and self.enemy_stomped_timer <= 0:
                        # In the air and moving up, haven't finished dashing in last few frames
                        # Upward velocity drops off faster if player has let go of the jump button (unless they just
                        # stomped an enemy)
                        self.vel_y = min(self.vel_y + 1, 0)

                    if self.dash_allowed and self.controls.button_pressed(1):
                        # Dash
                        dy = self.controls.get_y()
                        if dx != 0 or dy != 0:
                            if DEBUG_MOVEMENT:
                                print(game.timer, "dash")
                            v = pygame.math.Vector2(dx, dy).normalize() * Player.DASH_SPEED
                            self.vel_x = int(v.x)
                            self.vel_y = int(v.y)
                            self.gravity_enabled = False
                            self.dash_allowed = False
                            self.dash_timer = Player.DASH_TIME + Player.DASH_PAUSE_TIME
                            self.dash_animation_timer = 0
                            self.fall_state = GravityActor.FallState.FALLING
                            self.wall_jump_coyote_time = 0
                            game.play_sound("jump_long", 5)

        # When we change direction, our X velocity will be different from our facing direction (dx)
        # We set a change direction timer which then counts down, while it's above zero we play the change
        # direction animation
        if sign(dx) != sign(self.vel_x) and self.dash_timer <= 0:
            self.change_direction_timer = 5
        else:
            self.change_direction_timer -= 1

        # Update sprite
        self.determine_sprite(dx)

        # Update fall timer after choosing sprite so that we don't just skip frame 0
        # Don't increase fall timer if we're dashing
        if not self.landed() and self.dash_timer <= 0:
            self.fall_timer += 1

        # Update replay data
        self.replay_data.append( (self.pos, game.level_index, self.image) )

    def determine_sprite(self, dx):
        # Set sprite image. If we're currently hurt, the sprite will flash on and off on alternate frames.
        # dx is X direction we tried to move this frame, zero if there was no control input
        self.image = self.flame.image = "blank"
        # Flame has different anchor point depending on whether we're dashing
        self.flame.anchor = ANCHOR_FLAME
        if not self.hurt or game.timer % 2 == 1:
            # Example sprite name: "run_0_3" - first number is direction (0 right, 1 left), second is the frame number
            dir_index = "1" if self.facing_x < 0 else "0"
            if self.hurt:
                # no flame for this animatoin
                frame = min(self.fall_timer // 8, 5)
                self.image = f"die_{frame}"
                self.flame_image = "blank"

            elif self.grabbed_wall != 0 and self.vel_y >= 0:
                # We don't do wall slide animation if we're moving upward
                self.image = f"climb_{dir_index}_1"
                self.flame.image = f"flame_climb_{dir_index}_1"

            elif not self.landed():
                # In air
                if self.fall_state == GravityActor.FallState.JUMPING:
                    frame = min(self.fall_timer // 3, 5)
                    flame_frame = min(self.fall_timer // 3, 5) + 1
                    self.image = f"jump_{dir_index}_{frame}"
                    self.flame.image = f"flame_jump_{dir_index}_{flame_frame}"
                elif self.fall_state == GravityActor.FallState.WALL_JUMPING:
                    frame = min(self.fall_timer // 8, 2)
                    flame_frame = min(self.fall_timer // 4, 6)
                    self.image = f"wall_jump_{dir_index}_{frame}"
                    self.flame.image = f"flame_wall_jump_{dir_index}_{flame_frame}"
                elif self.dash_timer > 0:
                    # Choose a dash sprite and update self.last_dash_image
                    # Initially all dash directions use dash_start_0/1 (depending on facing direction), before
                    # switching to specific frames for different dash directions
                    if self.dash_animation_timer < 4:
                        flame_frame = self.dash_animation_timer // 2
                        self.image = self.last_dash_sprite = "dash_start_" + dir_index
                        self.flame.image = f"flame_dash_start_{dir_index}_{flame_frame}"
                        self.flame.anchor = ANCHOR_FLAME
                    else:
                        timer = self.dash_animation_timer - 4
                        frame = min(timer // 3, 2)
                        flame_frame = min(timer // 3, 7)
                        sprite = "dash_"
                        if self.vel_y < 0:
                            sprite += "up_"
                        elif self.vel_y > 0:
                            sprite += "down_"
                        if self.vel_x != 0:
                            sprite += "horizontal_"
                        self.image = self.last_dash_sprite = f"{sprite}{dir_index}_{frame}"
                        self.flame.image = f"flame_{sprite}{dir_index}_{flame_frame}"
                        self.flame.anchor = ANCHOR_FLAME_DASH
                else:
                    # For flame, use frames 4 and 5 of wall jump
                    frame = min(self.fall_timer // 8, 1)
                    flame_frame = min(self.fall_timer // 8, 1) + 4
                    self.image = f"fall_{dir_index}_{frame}"
                    self.flame.image = f"flame_wall_jump_{dir_index}_{flame_frame}"

            elif dx == 0:
                self.image = "stand_front"
                self.flame.image = f"flame_stand_{(game.timer // 4) % 8}"

            elif self.change_direction_timer > 0:
                # If change_direction_timer is positive, use change direction frame
                self.image = f"change_dir_{dir_index}_0"
                self.flame.image = f"flame_change_dir_{dir_index}_{(game.timer // 4) % 3}"
            else:
                # 8 frames of the run animation, switch animation frame every 4 game frames
                frame = (game.timer // 4) % 8
                self.image = f"run_{dir_index}_{frame}"
                self.flame.image = f"flame_run_{dir_index}_{(game.timer // 4) % 8}"

    def draw(self):
        super().draw()

        self.flame.pos = self.pos
        self.flame.draw()

        if DEBUG_SHOW_PLAYER_COLLISION_RECT:
            # Show collision rectangle
            screen.draw.rect(self.get_rect(), (255,255,255))

    def get_collidable_width(self):
        return PLAYER_WIDTH

    def get_collidable_height(self):
        return PLAYER_HEIGHT

class GhostPlayer(Actor):
    def __init__(self, replay_data):
        super().__init__("blank", replay_data[0][0], ANCHOR_PLAYER)
        self.replay_data = replay_data
        self.replay_frame = 0
        self.level = 0

    def update(self):
        self.replay_frame += 1
        if self.replay_frame < len(self.replay_data):
            self.pos, self.level, sprite = self.replay_data[self.replay_frame]
            if sprite == "blank":
                self.image = "blank"
            else:
                self.image = "ghost_" + sprite

    def draw(self):
        # Only draw if we're on the same level as the actual player
        if self.level == game.level_index:
            super().draw()

class Enemy(GravityActor):
    def __init__(self, pos, type, biome, direction_x=1, appearance_count=1):
        # Type must be a number from 0 to 3. 0 and 1 are both flying robots which don't have different frames for facing
        # left or right. 2 and 3 are non-flying robots which do have left/right facing frames.

        super().__init__(pos, gravity_enabled=not ENEMY_TYPES_FLYING[biome][type], anchor=ENEMY_TYPES_ANCHOR_POINTS[biome][type])

        self.direction_x = direction_x
        self.type = type
        self.biome = biome

        self.health = ENEMY_TYPES_HEALTH[type]
        self.speed = ENEMY_TYPES_SPEED[type]

        # Flying enemies which are on their third appearance will move diagonally
        self.direction_y = 1 if appearance_count >= 3 and not self.gravity_enabled else 0

        # Robot types 2 and 3, and fly/ghost have different sprites for facing left/right
        self.use_directional_sprites = (self.biome == Biome.CASTLE and self.type >= 2) or (self.biome == Biome.FOREST and self.type < 2)

        self.dying = False
        self.stomped_timer = 0

    def update(self):
        super().update(detect=not self.dying)

        if not self.dying:
            self.stomped_timer -= 1

            # Don't move on x axis if falling. Flying enemies are always counted as falling by GravityActor, they
            # should move regardless.
            if not self.gravity_enabled or self.fall_state != GravityActor.FallState.FALLING:
                # Move in current direction - turn around if we hit a wall
                if self.move(self.direction_x, 0, self.speed):
                    self.direction_x = -self.direction_x
                if self.direction_y != 0 and self.move(0, self.direction_y, self.speed):
                    self.direction_y = -self.direction_y

        # Choose and set sprite image
        image = ENEMY_SPRITE_NAMES[self.biome][self.type]
        if self.use_directional_sprites:
            direction_idx = "1" if self.direction_x > 0 else "0"
            image += "_" + str(direction_idx)
        image += "_" + str((game.timer // 4) % 8) # 8 frames of animation
        if self.stomped_timer > 0 or self.dying:
            image += "_hit"
        self.image = image

    def stomped(self):
        # Don't lose health or play sound effect if we're being stomped multiple frames in a row
        if self.stomped_timer <= 0:
            self.health -= 1
            if self.health <= 0:
                self.destroy()
                game.play_sound("enemy_death", 5)
            else:
                game.play_sound("enemy_take_damage", 5)
        self.stomped_timer = 2

    def destroy(self):
        self.dying = True
        self.gravity_enabled = True

        # Create explosion animation. Do this before gain_time so it appears underneath gain time animation
        explosion_sprite = "explosion" if self.type > 1 else "air_explosion"
        game.animations.append(Animation(self.pos, explosion_sprite + "_{0}", 12, 4, ANCHOR_CENTRE_BOTTOM))

        # Destroying an enemy always gains 3 seconds of time
        game.gain_time(STOMP_ENEMY_TIME_BONUS, self.centerx, self.centery)

    def get_collidable_width(self):
        return ENEMY_TYPES_WIDTH_OVERRIDES[self.biome][self.type]

    def get_collidable_height(self):
        return ENEMY_TYPES_HEIGHT_OVERRIDES[self.biome][self.type]

    def draw(self):
        super().draw()

        if DEBUG_SHOW_ENEMY_COLLISION_RECTS:
            # Show collision rectangle
            screen.draw.rect(self.get_rect(), (255,255,255))

class Game:
    def __init__(self, player=None, replays=None):
        self.player = player

        # Gem class is told via a static method that a new game has started, so it can reset the next gem type variable
        Gem.new_game()

        self.ghost_players = []
        if replays is not None:
            for replay in replays:
                self.ghost_players.append(GhostPlayer(replay))

        self.timer = 0
        self.time_remaining = INITIAL_TIME_REMAINING * 60
        self.time_pickup_bonus = INITIAL_PICKUP_TIME_BONUS
        self.gained_time_timer = 0

        self.level_index = (INITIAL_LEVEL_CYCLE * len(LEVEL_SEQUENCE)) - 1

        self.level_text = ""

        # These are set during load_level
        self.grid = None
        self.tileset_image = None
        self.background_image = None
        self.background_y_offset = 0

        self.next_level()

    def next_level(self):
        self.level_index += 1

        # If the new level is a repeat of the first level, reduce self.time_pickup_bonus by 1 (to a minimum of 0.5)
        if self.level_index != 0 and self.level_index % len(LEVEL_SEQUENCE) == 0:
            if self.time_pickup_bonus > 1:
                self.time_pickup_bonus -= 1
            elif self.time_pickup_bonus == 1:
                self.time_pickup_bonus = 0.5

        self.block_rects = []
        self.doors = []
        self.gems = []
        self.enemies = []
        self.animations = []
        self.level_text = ""

        # Set up level
        level_filename = LEVEL_SEQUENCE[self.level_index % len(LEVEL_SEQUENCE)]
        player_start_pos = self.load_level(level_filename)

        self.exit_open = False

        if self.player is not None:
            self.player.new_level(player_start_pos)

        # Generate collidable areas
        self.generate_block_rects()

        if self.player:
            self.player.reset()

        self.play_sound("new_wave")

    def load_level(self, filename):
        # Returns player start pos, or (0,0) if none is found
        player_start_pos = (0, 0)

        # 0 for first time through the levels, 1 for second, etc
        level_cycle = self.level_index // len(LEVEL_SEQUENCE)

        # sys.path[0] gets the folder containing the Python file we're running
        # This is necessary because we could be running in an IDE where the default working folders is not the script
        # folder but is instead the parent folder, or we could be running preinstalled on a Raspberry Pi in which case
        # the current working folder is the user's home folder
        path = os.path.join(sys.path[0], "tilemaps")

        # The map and tileset files are XML files. We're using Python's built in ElementTree module (aliased here as ET)
        # to access the tags/nodes within the XML files.
        map_tree = ET.parse(os.path.join(path, filename))
        map_root = map_tree.getroot()

        # Load background
        properties_node = map_root.find("properties")
        self.background_image = properties_node.find("./property[@name='Background']").attrib["value"]
        bg_offset_node = properties_node.find("./property[@name='Background Offset Y']")
        self.background_y_offset = int(bg_offset_node.attrib["value"]) if bg_offset_node is not None else 0

        # Load biome (used for determining which types of enemies and doors to generate)
        biome_node = properties_node.find("./property[@name='biome']")
        biome_name = biome_node.attrib["value"] if biome_node is not None else ""
        biome = Biome[biome_name.upper()]

        # Default level name text - may be replaced by tutorial text below
        self.level_text = "LEVEL " + str(self.level_index + 1)

        # Set up level tutorial text - only the first time we go round the levels.
        # Some text will have parts which we need to substitute
        # Use blank level text if there is no player object (i.e. we're on the main menu)
        tutorial_text_node = properties_node.find("./property[@name='TutorialText']")
        if self.player is not None and tutorial_text_node is not None:
            tutorial_text = tutorial_text_node.attrib["value"]
            if level_cycle == 0 and len(tutorial_text) > 0:
                dash_button_name = self.player.controls.button_name("dash")
                jump_button_name = self.player.controls.button_name("jump")
                self.level_text = tutorial_text.replace("{DASH}", dash_button_name).replace("{JUMP}", jump_button_name)

        # The map data consists of a comma-separated list of integers specifying tile IDs
        # The XML path is map/layer/data
        layer_node = map_root.find("layer")
        map_width = int(layer_node.attrib.get("width"))
        map_height = int(layer_node.attrib.get("height"))
        map_data = layer_node.find("data").text.split(",")

        # Convert map data from CSV into a 2D list of ints
        # We subtract 1 from each tile ID because we want the tile IDs to start from 0 (signifying the top left of the
        # tileset image) rather than 1. This means that empty tile will now have an ID of -1
        self.grid = []
        for row in range(map_height):
            # Extract each row from the map data
            row_start_index = row * map_width
            current_row = [int(tile) - 1 for tile in map_data[row_start_index:row_start_index+map_width]]
            self.grid.append(current_row)

        # Read object layer, which specifies things like the player start position, gems and enemies
        object_group_node = map_root.find("objectgroup")
        if object_group_node is not None:
            for object_node in object_group_node.findall("object"):
                object_name = object_node.attrib["name"]

                # Extract the object position. Why do we write 'int(float(...))'? Because the number is read from the
                # file as a string, and we'd like it as an int, but we also want to ignore anything after the
                # decimal point, which we don't care about. We can't convert directly from string to int because
                # that would fail when it encountered a number with a decimal point.
                object_pos = (int(float(object_node.attrib["x"])), int(float(object_node.attrib["y"])))
                if object_name == "PlayerStart":
                    player_start_pos = object_pos

                elif object_name == "Gem":
                    self.gems.append(Gem(object_pos))

                elif "Enemy" in object_name:
                    # Enemies have names such as "EnemyR00" where L/R indicate their initial facing direction, the
                    # first number indicates the enemy type (0 to 3), and the final number indicates the level cycle
                    # during which they first show up. Some enemies only show up on the second or third cycle through
                    # the levels
                    enemy_level_cycle = int(object_name[-1])
                    appearance_count = (level_cycle - enemy_level_cycle) + 1
                    if appearance_count >= 1:
                        facing = 1 if object_name[-3] == "R" else -1
                        enemy_type = int(object_name[-2])
                        self.enemies.append(Enemy(object_pos, enemy_type, biome, facing, appearance_count))

                elif "Door" in object_name:
                    variant_node = object_node.find("./properties/property[@name='Variant']")
                    biome_node = object_node.find("./properties/property[@name='Biome']")
                    variant = variant_node.attrib["value"] if variant_node is not None else 0
                    door_biome_name = biome_node.attrib["value"] if biome_node is not None else biome_name
                    entrance = "Entrance" in object_name
                    self.doors.append(Door(object_pos, door_biome_name, variant, entrance))

        # For the purpose of simplicity we assume that each map file only uses one tileset, which will be either the
        # forest or castle tileset. The tileset filename is specified in the 'tileset' tag within the root node
        tileset_filename = map_root.find("tileset").attrib.get("source")

        # Read tileset file, which specifies which tiles are collidable
        self.collision_tiles = set()
        tileset_xml = ET.parse(os.path.join(path, tileset_filename))
        for tile_node in tileset_xml.getroot().findall("tile"):
            # For now we'll just assume that any tile which has a node, has collision
            self.collision_tiles.add(int(tile_node.attrib["id"]))

        # Load tileset image (if we haven't loaded it already)
        tileset_image_filename = tileset_xml.getroot().find("image").attrib["source"]
        if tileset_image_filename not in tileset_images:
            tileset_images[tileset_image_filename] = pygame.image.load(os.path.join(path, tileset_image_filename))
        self.tileset_image = tileset_images[tileset_image_filename]

        return player_start_pos

    def generate_block_rects(self):
        self.block_rects = []
        current_rect = None

        def add():
            nonlocal current_rect
            self.block_rects.append(current_rect)
            current_rect = None

        # Horizontal rows
        for gy in range(len(self.grid)):
            row = self.grid[gy]
            for gx in range(len(row)):
                if row[gx] in self.collision_tiles:
                    pos_x = gx * GRID_BLOCK_SIZE
                    pos_y = gy * GRID_BLOCK_SIZE
                    # Is this the start of a new block rect?
                    if current_rect is None:
                        current_rect = Rect(pos_x, pos_y, GRID_BLOCK_SIZE, GRID_BLOCK_SIZE)
                    else:
                        # Continue existing rect
                        current_rect.w += GRID_BLOCK_SIZE

                elif current_rect is not None:
                    add()
            if current_rect is not None:
                add()

        # Now consolidate vertically
        # Keep joining rectangles with rectangles of equal width directly below, until there are no more such
        # matches

        def find_equal_width_block_below(current):
            # Returns a block with the same X coordinate and width, which is immediately below the current block,
            # or None if no such block exists
            result = [rect for rect in self.block_rects if rect.x == current.x and rect.w == current.w and rect.y == current.y + current.h]
            return result[0] if len(result) > 0 else None

        any_found = True
        while any_found:
            any_found = False
            for current in self.block_rects:
                equal_below = find_equal_width_block_below(current)
                if equal_below is not None:
                    # Extend the height of the current block and remove the one below
                    current.h += equal_below.h
                    self.block_rects.remove(equal_below)
                    any_found = True
                    break

        # Final step: any block rects aligning with the top of the level have their height increased so it extends
        # above the level, to prevent standing on top of the trees off the top of the screen
        for rect in self.block_rects:
            if rect.top == 0:
                height = rect.height
                rect.top = LEVEL_Y_BOUNDARY
                rect.height = height + -LEVEL_Y_BOUNDARY

    def update(self):
        self.timer += 1
        self.gained_time_timer -= 1

        if self.time_remaining > 0:
            self.time_remaining -= 1

        # Update all objects
        for obj in [self.player] + self.doors + self.animations + self.gems + self.enemies + self.ghost_players:
            if obj:
                obj.update()

        # Remove expired enemies, dash trails, gems and animations
        self.enemies = [enemy for enemy in self.enemies if enemy.top < HEIGHT]
        self.animations = [anim for anim in self.animations if not anim.finished()]
        self.gems = [gem for gem in self.gems if not gem.collected]

        # Check stuff to do with opening exit door and exiting level (but not if we're on the main menu)
        if self.player is not None:
            if self.exit_open:
                # Check for the player leaving the level
                if self.player.centerx >= WIDTH:
                    self.next_level()

            elif len(self.gems) == 0:
                # All gems collected, open the exit door
                self.exit_open = True
                for door in self.doors:
                    door.open()

    def draw(self):
        # Draw appropriate background for this level
        screen.blit(self.background_image, (0, self.background_y_offset))

        # Draw level tiles
        tileset_w = self.tileset_image.get_width()
        tileset_grid_w = tileset_w // GRID_BLOCK_SIZE
        for row_y in range(len(self.grid)):
            row = self.grid[row_y]
            x = 0
            for tile in row:
                if tile >= 0:
                    # Get sprite from tileset based on ID
                    tileset_grid_y = tile // tileset_grid_w
                    tileset_grid_x = tile % tileset_grid_w
                    # Have to use screen.surface.blit instead of screen.blit as the latter is a Pygame Zero method which
                    # passes through to the Pygame version but doesn't support the optional area parameter
                    tile_rect = Rect(tileset_grid_x * GRID_BLOCK_SIZE, tileset_grid_y * GRID_BLOCK_SIZE, GRID_BLOCK_SIZE, GRID_BLOCK_SIZE)
                    screen.surface.blit(self.tileset_image, (x, row_y * GRID_BLOCK_SIZE), area=tile_rect)
                x += GRID_BLOCK_SIZE

        # Draw all objects, in this order
        for obj in self.ghost_players + self.doors + self.animations + [self.player] + self.gems + self.enemies:
            if obj is not None:
                obj.draw()

        # DEBUG - draw block rects
        if DEBUG_SHOW_BLOCK_COLLISION_RECTS:
            for rect in self.block_rects:
                screen.draw.rect(rect, (255,255,255))

        self.draw_ui()

    def draw_ui(self):
        # Display level text and background
        pygame.draw.rect(screen.surface, (0,54,255), Rect(0,500,WIDTH, 50))
        screen.blit("text_area_frame", (0, 500))
        draw_text(self.level_text, WIDTH // 2, 508, align=TextAlign.CENTRE)

        # Show background sprite for time remaining
        screen.blit("status_back", (WIDTH // 2 - 297 // 2, 0))

        # Show time remaining
        # Use bright font if player has just gained time
        font = "font" if self.gained_time_timer < 0 else "fontbr"
        draw_text(f"{self.time_remaining / 60:.1f}", WIDTH // 2, 10, align=TextAlign.CENTRE, font=font)

        if DEBUG_SHOW_FRAME_NUMBER:
            draw_text(str(game.timer), WIDTH // 2, 0, align=TextAlign.CENTRE)

    def gain_time(self, time, x, y):
        game.time_remaining += time * 60
        time_added_id = "half" if time == 0.5 else str(time)
        format_str = "timer_plus_" + time_added_id + "_{0}"
        game.animations.append(Animation((x,y), format_str, 14, 4, initial_delay=5, rise_time=34))
        game.animations.append(Animation((x,y), "pickup_{0}", 8, 4))
        self.gained_time_timer = 20

    def position_blocked(self, rect):
        # Check collision with block tiles
        for block_rect in self.block_rects:
            if rect.colliderect(block_rect):
                # print(" blocked")
                return True

        # Check collision with door
        for door in self.doors:
            if not door.is_fully_open() and door.colliderect(rect):
                return True

        # Don't allow going off left side of screen, or above vertical boundary
        # We do need to allow player to go off right side of screen so they can go
        # through the exit door
        if rect.left <= 0 or rect.top < LEVEL_Y_BOUNDARY:
            return True

        return False

    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 sound file of that name was found, print the error that Pygame Zero provides, which
                # includes the filename.
                # Also occurs if sound fails to play for another reason (e.g. if this machine has no sound hardware)
                print(e)

def get_char_image_and_width(char, font):
    # Return width of given character. ord() gives the Unicode code for the given character.
    if char == " ":
        return None, 22
    else:
        if char in SPECIAL_FONT_SYMBOLS_INVERSE:
            image = getattr(images, SPECIAL_FONT_SYMBOLS_INVERSE[char])
        else:
            # Format character code to always be 3 digits, with zeroes on the left - e.g. 65 becomes 065
            image = getattr(images, f"{font}{ord(char):03d}")
        return image, image.get_width()

def text_width(text, font):
    return sum([get_char_image_and_width(c, font)[1] for c in text])

class TextAlign(Enum):
    LEFT = 0
    CENTRE = 1
    RIGHT = 2

def draw_text(text, x, y, align=TextAlign.LEFT, font="font"):
    if align == TextAlign.CENTRE:
        x -= text_width(text, font) // 2
    elif align == TextAlign.RIGHT:
        x -= text_width(text, font)

    for char in text:
        image, width = get_char_image_and_width(char, font)
        if image is not None:
            screen.blit(image, (x, y))
        x += width

class State(Enum):
    TITLE = 1
    CONTROLS = 2
    PLAY = 3
    GAME_OVER = 4

def get_joystick_if_exists():
    return pygame.joystick.Joystick(0) if pygame.joystick.get_count() > 0 else None

def setup_joystick_controls():
    # We call this on startup, and keep calling it if no controller is present,
    # so a controller can be connected while the game is open
    global joystick_controls
    joystick = get_joystick_if_exists()
    joystick_controls = JoystickControls(joystick) if joystick is not None else None

def update_controls():
    keyboard_controls.update()
    # Allow a controller to be connected while the game is open
    if joystick_controls is None:
        setup_joystick_controls()
    if joystick_controls is not None:
        joystick_controls.update()

def get_save_folder():
    # By default, we save to the same folder as the Python file
    # But if the current working folder is the same as the user's home folder, write save data to a subfolder of that,
    # because the folder containing the Python file may not be writeable. This is relevant when the games are run from
    # the pre-installed versions which come with Raspberry Pi OS
    # On Windows, the home folder is C:\Users\<username>\
    current_working_folder = os.getcwd()
    home_folder = os.path.expanduser('~')
    if current_working_folder != home_folder:
        return sys.path[0]
    else:
        # Get a location within the user's home folder, then ensure the folder exists
        path = os.path.expanduser('~/.code-the-classics-vol-2')
        if not os.path.exists(path):
            os.makedirs(path)
        return path

def save_replays(replays):
    # We'll save one replay per line, with entries separated by commas
    try:
        with open(os.path.join(get_save_folder(), REPLAY_FILENAME), "w") as file:
            for replay in replays:
                line = ""
                for entry in replay:
                    # Each entry consists of a position (X and Y in a tuple), level number and sprite
                    # We'll separate the items using commas and the entries using semicolons. It doesn't matter what
                    # the symbols are as long as they don't occur within the data
                    # Open the replays file to see what it looks like!
                    line += f"{int(entry[0][0])},{int(entry[0][1])},{entry[1]},{entry[2]};"

                # Write the string for the current replay to the file, removing the trailing symbol from the end, and
                # adding a new line on the end
                file.write(line[0:-1] + "\n")
    except Exception as e:
        print(f"Error while saving replays: {e}")

def load_replays():
    # Returns list of replays and high score
    replays = []
    try:
        path = os.path.join(get_save_folder(), REPLAY_FILENAME)
        if os.path.exists(path):
            with open(path) as file:
                for line in file:
                    current_replay = []

                    # Remove the newline symbol from the end of the line
                    line = line.rstrip()

                    # Split the string on semicolon to get a list of all entries for this replay
                    entries = line.split(";")

                    for entry in entries:
                        # Within each entry, split on comma and convert each element to the correct type
                        elements = entry.split(",")

                        pos = (float(elements[0]), float(elements[1]))

                        current_replay.append( (pos, int(elements[2]), elements[3]) )

                    replays.append(current_replay)

    except Exception as e:
        # In case of error (eg missing file or formatting error), just return an empty list, and high score of zero
        print("Error while loading replays: '" + str(e) + "'. Replay data will be reset")
        return [], 0

    # The high score is stored as the total number of frames of data in the replay with the longest length
    high_score = 0 if len(replays) == 0 else len(max(replays, key=lambda replay: len(replay)))

    return replays, high_score

# Pygame Zero calls the update and draw functions each frame

def update():
    global state, game, high_score, game_over_state_timer, all_replays, total_frames

    # Run in slow motion if DEBUG_SLOWMO is higher than 1
    total_frames += 1
    if total_frames % DEBUG_SLOWMO != 0:
        return

    update_controls()

    def button_pressed_controls(button_num):
        # Local function for detecting button 0 being pressed on either keyboard or controller, returns the controls
        # object which was used to press it, or None if button was not pressed
        for controls in (keyboard_controls, joystick_controls):
            # Check for fire button being pressed on each controls object
            # joystick_controls will be None if there no controller was connected on game startup,
            # so must check for that
            if controls is not None and controls.button_pressed(button_num):
                return controls
        return None

    if state == State.TITLE:
        # Check for player starting game with either keyboard or controller
        if button_pressed_controls(0) is not None:
            state = State.CONTROLS

    elif state == State.CONTROLS:
        # Check for start game
        controls = button_pressed_controls(0)
        if controls is not None:
            # Switch to play state, and create a new Game object, passing it a new Player object to use
            state = State.PLAY
            game = Game(Player(controls), all_replays)
            play_music("ingame_theme", 0.2)

    elif state == State.PLAY:
        if game.time_remaining <= 0:
            game.play_sound("gameover")
            state = State.GAME_OVER
            game_over_state_timer = 0

            # Add the replay data for this game to all_replays
            all_replays.append(game.player.replay_data)

            # Ensure that all_replays never has more than 10 replays, otherwise there could be performance issues
            if len(all_replays) > MAX_REPLAYS:
                # Sort replays by length, longest first
                all_replays.sort(key=lambda replay: len(replay), reverse=True)

                # Recreate the list, consisting of only the first 10
                all_replays = all_replays[:MAX_REPLAYS]

            save_replays(all_replays)
        else:
            game.update()

    elif state == State.GAME_OVER:
        # Don't allow the player to press a button to go back to the main menu until one second has passed
        # This prevents the issue of accidentally skipping the game over screen because the player was just starting
        # to press the jump button as the time ran out
        game_over_state_timer += 1
        if game_over_state_timer > 60 and button_pressed_controls(0) is not None:
            # Update high score variable at this point
            if game.timer > high_score:
                high_score = game.timer

            # Switch to title screen state
            state = State.TITLE
            play_music("title_theme")

def draw():
    if state == State.TITLE:
        # Draw title screen
        screen.blit("title", (0, 0))
        screen.blit("press_to_start", (0, 0))

        # Draw "start" animation, which has 11 frames numbered 0 to 10
        anim_frame = (total_frames // 6) % 11
        screen.blit("start" + str(anim_frame), (WIDTH//2 - 150, 360))

    elif state == State.CONTROLS:
        screen.fill((0, 0, 0))
        screen.blit("controls", (0, 0))

    elif state == State.PLAY:
        game.draw()

    elif state == State.GAME_OVER:
        screen.fill((0,54,255))

        # Display "Game Over" images
        # 625 is the width of the game over images
        anim_frame = (total_frames // 5) % 14
        screen.blit(f"gameover{anim_frame}", (WIDTH//2 - 625//2, 100))

        seconds = int(game.timer / 60)
        if seconds >= 60:
            screen.blit("survived_for_mins_seconds", (0, 0))
            draw_text(f"{seconds // 60}", 180, 270, align=TextAlign.RIGHT, font="fontlrg")
            draw_text(f"{seconds % 60}", 470, 270, align=TextAlign.CENTRE, font="fontlrg")
        else:
            screen.blit("survived_for_seconds", (0, 0))
            draw_text(f"{seconds}", 300, 310, align=TextAlign.RIGHT, font="fontlrg")

        if game.timer > high_score:
            # Show "NEW RECORD!"
            # 575 is the width of the new record images
            anim_frame = (total_frames // 5) % 8
            screen.blit(f"newrecord{anim_frame}", (WIDTH // 2 - 575 // 2, 380))

def play_music(name, volume=0.3):
    try:
        music.play(name)
        music.set_volume(volume)
    except Exception:
        # If an error occurs (e.g. no sound hardware), ignore it
        pass

##############################################################################

# Set up sound system and start music
try:
    # Restart the Pygame audio mixer which Pygame Zero sets up by default. We find that the default settings
    # cause issues with delayed or non-playing sounds on some devices
    pygame.mixer.quit()
    pygame.mixer.init(48000, -16, 2, 1024)

    play_music("title_theme")
except Exception:
    # If an error occurs (e.g. no sound hardware), ignore it
    pass

# Dictionary mapping tileset image filename to the loaded images, will be filled in as we load levels
tileset_images = {}

# Set up controls
keyboard_controls = KeyboardControls()
setup_joystick_controls()

all_replays, high_score = load_replays()

# Set the initial game state
state = State.TITLE
game = None

# How long have we been in the game over state?
game_over_state_timer = 0

total_frames = 0

# Tell Pygame Zero to take over
pgzrun.go()