kinetix

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


# Kinetix - Code the Classics Volume 2
# Code by Eben Upton and Andrew Gillett
# 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

# If the game window doesn't fit on the screen, you may need to turn off or reduce display scaling in the Windows/macOS settings
# On Windows, you can uncomment the following two lines to fix the issue. It sets the program as "DPI aware"
# meaning that display scaling won't be applied to it.
#import ctypes
#ctypes.windll.user32.SetProcessDPIAware()

import pygame, pgzero, pgzrun, math, sys
from abc import ABC, abstractmethod
from enum import Enum, IntEnum
from random import random, randint, uniform, choice
from pygame import surface
from pygame.math import Vector2

# 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 = 640
HEIGHT = 640
TITLE = "Kinetix"

BAT_SPEED = 8

BAT_MIN_X = 35
BAT_MAX_X = 605

TOP_EDGE = 50
RIGHT_EDGE = 617
LEFT_EDGE = 23

BAT_TOP_EDGE = 590

BALL_INITIAL_OFFSET = 10

BALL_START_SPEED = 5
BALL_MIN_SPEED = 4
BALL_MAX_SPEED = 11

BALL_SPEED_UP_INTERVAL = 10 * 60        # Normal ball speed up interval (10 seconds at 60 frames per second)
BALL_SPEED_UP_INTERVAL_FAST = 15 * 60   # Speed up interval for when the ball is above a speed threshold
BALL_FAST_SPEED_THRESHOLD = 7

BALL_RADIUS = 7

BULLET_SPEED = 8

BRICKS_X_START = 20
BRICKS_Y_START = 100

BRICK_WIDTH = 40
BRICK_HEIGHT = 20
SHADOW_OFFSET = 10

POWERUP_CHANCE = 0.2

FIRE_INTERVAL = 30

PORTAL_ANIMATION_SPEED = 5

LEVELS = [
        ["        ",
         "        ",
         "        ",
         "     a  ",
         "    a7a ",
         "     a  ",
         "     a55",
         "    444 ",
         "   333a ",
         "  222a  ",
         " 111a   ",
         "   11aa ",
         "    111 ",
         "    6   ",
         "     6  "],

        ["        ",
         "        ",
         "    3   ",
         "    3   ",
         "    3   ",
         "    3000",
         "    3000",
         "   53000",
         "   53000",
         "  35a555",
         " 3 5aa55",
         "3  5aaa5",
         "  355555",
         "  333333",
         "   333  ",
         "    33  ",
         "     3  "],

        ["   7    ",
         "  77    ",
         " 7777   ",
         " 7777   ",
         " 77777  ",
         " 77777  ",
         " 77 777 ",
         " 7  7777",
         " 7   717",
         "     777",
         "      77",
         "      7 ",
         "     c7 ",
         "      c ",
         "      c "],

        ["   03   ",
         "   30   ",
         "    03  ",
         "    30  ",
         "     0  ",
         " 8   0  ",
         " 88 8033",
         "  883333",
         "   8333d",
         "   33733",
         "  33373d",
         " 3333333",
         " 3c 333d",
         " cc 3333",
         " c   3 3",
         "     3 3",
         "    3 3 ",
         "    c 3 ",
         "    cc3c",
         "    cccc",
         "      d "],

        ["5   9  0",
         "0   4  3",
         "08  4  4",
         "53  47 2",
         " 39 92 1",
         " 84  2  ",
         "  47 26 ",
         "5 92 71 ",
         "08 26 1 ",
         "53971 1 ",
         " 8471c6 ",
         "  926acc",
         "   71aad",
         "039 6aac",
         "dc421ac ",
         "  dccc  ",
         "    d   "],

        ["  dccccd",
         "  c89765",
         "  c34210",
         "  c34210",
         "  c34210",
         "  c34210",
         "  c3421d",
         "  c34210",
         "  c34210",
         "  c34210",
         "  c34210",
         "  c89765",
         "  dccccd"]
]

def get_mirrored_level(level):
    # For each row, return a new row which includes the existing row plus
    # a mirrored version. todo explain row[-2::-1]
    return [row + row[-2::-1] for row in level]

class Controls(ABC):
    def __init__(self):
        self.fire_previously_down = False
        self.is_fire_pressed = False

    def update(self):
        # Call each frame to update fire status
        fire_down = self.fire_down()
        self.is_fire_pressed = fire_down and not self.fire_previously_down
        self.fire_previously_down = fire_down

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

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

    def fire_pressed(self):
        return self.is_fire_pressed

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

    def fire_down(self):
        return keyboard.space

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

    def get_x(self):
        # First check if there is an input on the dpad for the X axis. The dpad is classified here as a joystick 'hat'
        if self.joystick.get_numhats() > 0 and self.joystick.get_hat(0)[0] != 0:
            return self.joystick.get_hat(0)[0] * BAT_SPEED

        # If no input on the dpad, check for analogue left/right input
        axis_value = self.joystick.get_axis(0)
        if abs(axis_value) < 0.2:
            # Dead-zone, necessary because some devices may register a small amount of input even when the player isn't
            # moving the analogue stick
            return 0
        else:
            return axis_value * BAT_SPEED

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

class AIControls(Controls):
    def __init__(self):
        super().__init__()
        self.offset = 0

    def get_x(self):
        if game.portal_active:
            # If the portal to the next level is open, just move right so that we go through it
            return BAT_SPEED
        else:
            # Randomly shift the bat AI offset over time, so the AI player doesn't constantly hit the ball perfectly
            # in the centre of the bat. Limit offset between -40 and 40
            self.offset += randint(-1, 1)
            self.offset = min(max(-40, self.offset), 40)

            # Follow position of the first ball (in case of multiball)
            return min(BAT_SPEED, max(-BAT_SPEED, game.balls[0].x - (game.bat.x + self.offset)))

    def fire_down(self):
        # Just have the AI mash the fire button
        return randint(0,5) == 0

class Powerup(IntEnum):
    # These numbers correspond to the sprite filenames
    # e.g. barrel06 is extend bat, frame 6
    EXTEND_BAT = 0
    GUN = 1
    SMALL_BAT = 2
    MAGNET = 3
    MULTI_BALL = 4
    FAST_BALLS = 5
    SLOW_BALLS = 6
    PORTAL = 7
    EXTRA_LIFE = 8

class BatType(IntEnum):
    NORMAL = 0
    MAGNET = 1
    GUN = 2
    EXTENDED = 3
    SMALL = 4

POWERUP_BAT_TYPES = {
    Powerup.EXTEND_BAT: BatType.EXTENDED,
    Powerup.GUN: BatType.GUN,
    Powerup.SMALL_BAT: BatType.SMALL,
    Powerup.MAGNET: BatType.MAGNET
}

POWERUP_SOUNDS = {
    Powerup.EXTEND_BAT: "bat_extend",
    Powerup.GUN: "bat_gun",
    Powerup.MAGNET: "magnet",
    Powerup.SMALL_BAT: "bat_small",
    Powerup.EXTRA_LIFE: "extra_life",
    Powerup.FAST_BALLS: "speed_up",
    Powerup.SLOW_BALLS: "powerup",
    Powerup.MULTI_BALL: "multiball"
}

class CollisionType(Enum):
    WALL = 0
    BAT = 1
    BAT_EDGE = 2
    BRICK = 3
    INDESTRUCTIBLE_BRICK = 4

class Bullet(Actor):
    def __init__(self, pos, side):
        super().__init__(f"bullet{side}", pos)

        self.alive = True

    def update(self):
        self.y -= BULLET_SPEED

        # returns tuple of (tuple 2: impact pos, bool: show impact, CollisionType), or None if no collision
        c = game.collide(self.x, self.y, Vector2(0, -1), 2)
        if c is not None:
            self.alive = False
            game.impacts.append(Impact(self.pos, 15))
            if c[2] == CollisionType.BRICK or c[2] == CollisionType.INDESTRUCTIBLE_BRICK:
                game.play_sound("bullet_hit", 4)

# The barrel class represents the collectable powerups that sometimes fall from destroyed bricks
class Barrel(Actor):
    def __init__(self, pos):
        super().__init__("blank", pos)

        # Decide powerup type, with each type able to have its own probability
        # First we create a dictionary of types to weights, where a higher weight means that powerup is more likely
        # to be chosen. For the PORTAL powerup, which opens a portal to the next level, it can't be generated unless
        # there are only a few bricks remaining, at which point it becomes very likely
        weights = {Powerup.EXTEND_BAT:6,
                   Powerup.GUN:6,
                   Powerup.SMALL_BAT:6,
                   Powerup.MAGNET:6,
                   Powerup.MULTI_BALL:6,
                   Powerup.FAST_BALLS:6,
                   Powerup.SLOW_BALLS:6,
                   Powerup.EXTRA_LIFE:2,
                   Powerup.PORTAL:0 if game.bricks_remaining > 20 or game.portal_active else 20}

        # Create a list of powerup types, with each type repeated a certain
        # number of times based on its weight
        types = [type for type, weight in weights.items() for i in range(weight)]

        # Randomly choose one of the types from the list. Types which
        # are repeated many times are more likely to be chosen
        self.type = choice(types)

        self.time = 0

        # Create separate actor for shadow sprite
        self.shadow = Actor("barrels", (self.x + SHADOW_OFFSET, self.y + SHADOW_OFFSET))

    def update(self):
        self.time += 1
        self.y += 1

        w = (game.bat.width // 2) + BALL_RADIUS

        # Check for barrel being collected by bat
        if self.y >= BAT_TOP_EDGE - 10 and self.y <= BAT_TOP_EDGE + 30 and abs(self.x - game.bat.x) < w:
            # Create barrel collection animation - sprites 'impacte0' to 'impacte4'
            # 14 is E in hexadecimal
            game.impacts.append(Impact((self.x, self.y - 11), 14))

            # Play sound effect (if this powerup has a sound effect)
            if self.type in POWERUP_SOUNDS:
                game.play_sound(POWERUP_SOUNDS[self.type])

            # Move barrel off the bottom of the screen, it will then be deleted
            self.y = HEIGHT + 100

            if self.type in POWERUP_BAT_TYPES:
                game.bat.change_type(POWERUP_BAT_TYPES[self.type])
            elif self.type == Powerup.MULTI_BALL:
                game.balls = [j for b in game.balls for j in b.generate_multiballs()]
            elif self.type == Powerup.FAST_BALLS:
                game.change_all_ball_speeds(3)
            elif self.type == Powerup.SLOW_BALLS:
                game.change_all_ball_speeds(-3)
            elif self.type == Powerup.PORTAL:
                game.activate_portal()
            elif self.type == Powerup.EXTRA_LIFE:
                game.lives += 1

        # The name of each powerup sprite has the format "barrel[powerup type][frame]",
        # where powerup type is a number from 0 to 8 and frame is a number from 0 to 9
        # We switch to a new animation frame every 10 game frames
        self.image = f"barrel{int(self.type)}{self.time // 10 % 10}"

        self.shadow.pos = (self.x + SHADOW_OFFSET, self.y + SHADOW_OFFSET)

# The Impact class is used for the animations played when the ball hits a wall or destroys a brick
class Impact(Actor):
    def __init__(self, pos, type):
        super().__init__("blank", pos)

        self.type = type
        self.time = 0

    def update(self):
        # The impact animation sprites have names like 'impact00' where the first digit is the type of impact and
        # the second is the animation frame. The type is converted into a hexadecimal number. The type can be between
        # 0 and 15, where values from 10 to 15 are represented by the hexadecimal digits a to f. The Python hex
        # function is used to convert the type to hexadecimal, the resulting string will always start with '0x' meaning
        # hexadecimal, so we strip off the first two characters from the start of string.
        self.image = "impact" + hex(self.type)[2:] + str(self.time // 4)

        self.time += 1

class Ball(Actor):
    def __init__(self, x=0, y=0, dir=Vector2(0, 0), stuck_to_bat=True, speed=BALL_START_SPEED):
        super().__init__("ball0", (0,0))

        self.x = x
        self.y = y

        # Direction should always be a unit vector (a vector with a length of 1)
        # It's important that we make a full copy of the direction, rather than just copying the reference.
        # Since a Vector2 is an object, it's a reference type. If you copy a reference type it means you now have two
        # variables referring to the same object. If we said below 'self.dir = dir' it would mean that when a ball
        # copied its direction from another ball, the directions of the two balls would remain linked to each other
        self.dir = Vector2(dir)

        self.stuck_to_bat = stuck_to_bat
        self.bat_offset = BALL_INITIAL_OFFSET

        self.speed = speed

        self.speed_up_timer = 0
        self.time_since_touched_bat = 0
        self.time_since_damaged_brick = 0

        self.shadow = Actor("balls", (self.x + 16, self.y + 16))

    def update(self):
        self.time_since_damaged_brick += 1

        if self.stuck_to_bat:
            self.x = game.bat.x + self.bat_offset
            self.y = game.bat.y - BALL_RADIUS

            # Launch ball from bat if fire is pressed
            if game.controls.fire_pressed():
                self.stuck_to_bat = False
                _, self.dir = self.get_bat_bounce_vector()
        else:
            # Normal ball movement
            self.time_since_touched_bat += 1

            # Speed up every so often
            # If ball hasn't touched bat in a while, speed up more frequently
            self.speed_up_timer += 1
            if self.time_since_touched_bat > 5 * 60:
                self.speed_up_timer += 1
            interval = BALL_SPEED_UP_INTERVAL if self.speed < BALL_FAST_SPEED_THRESHOLD else BALL_SPEED_UP_INTERVAL_FAST
            interval2 = interval * 0.75
            if self.speed_up_timer > interval or (self.speed_up_timer > interval2 and self.time_since_touched_bat > interval2):
                self.increment_speed()
                self.speed_up_timer = 0

            # Move one pixel at a time, speed times (rounded down to a whole number)
            for i in range(self.speed):
                # Move and collide on X axis
                self.x += self.dir.x

                # returns tuple of (tuple 2: impact pos, bool: show impact, CollisionType), or None if no collision
                c = game.collide(self.x, self.y, self.dir)

                if c is not None:
                    # Invert X direction and move back to previous position, before the collision
                    self.dir.x = -self.dir.x
                    self.x += self.dir.x

                    if c[1]:
                        # Create impact animation type 12 (C in hexadecimal)
                        game.impacts.append(Impact(c[0], 0xc))

                    if c[2] == CollisionType.BRICK:
                        self.time_since_damaged_brick = 0

                    Ball.collision_sound(c[2])

                # Original y position before movement
                oy = self.y

                # Move and collide on Y axis
                self.y += self.dir.y

                # returns tuple of (tuple 2: impact pos, bool: show impact, CollisionType), or None
                c = game.collide(self.x, self.y, self.dir)

                if c is not None:
                    # Invert Y direction and move back to previous position, before the collision
                    self.dir.y = -self.dir.y
                    self.y += self.dir.y

                    if c[1]:
                        # Create impact animation type 12 (C in hexadecimal)
                        game.impacts.append(Impact(c[0], 0xc))

                    if c[2] == CollisionType.BRICK:
                        self.time_since_damaged_brick = 0

                    Ball.collision_sound(c[2])

                elif self.dir.y > 0:
                    # Check for collision with bat - only if we're moving down

                    # If bottom of ball was previously above/at top edge of bat, but is now below it
                    if oy + BALL_RADIUS <= BAT_TOP_EDGE and self.y + BALL_RADIUS > BAT_TOP_EDGE:
                        # See if we're colliding on X axis
                        collided_x, new_dir = self.get_bat_bounce_vector()
                        if collided_x:
                            # Ball collided with bat
                            if game.bat.current_type == BatType.MAGNET:
                                self.stuck_to_bat = True
                                self.bat_offset = self.x - game.bat.x
                                self.dir = Vector2(0, 0)
                            else:
                                # No magnet powerup, bounce ball in the direction we got from get_bat_bounce_vector
                                self.dir = new_dir

                            self.time_since_touched_bat = 0

                            game.impacts.append(Impact((self.x, self.y), 0xc))

                            Ball.collision_sound(CollisionType.BAT)

                            # If we became stuck to the bat, break out of the movement/speed loop
                            if self.stuck_to_bat:
                                break

                    # If bottom of ball is below top edge of bat, and top of ball is above halfway point of bat
                    elif self.y + BALL_RADIUS > BAT_TOP_EDGE and self.y < BAT_TOP_EDGE + 15:
                        # If the ball hits the top of the bat, the section above will deal with it, if we get here
                        # and the bat/ball positions on the X axis overlap, that means the ball must have hit the
                        # side of the bat.

                        # See if we're colliding on X axis
                        collided_x, _ = self.get_bat_bounce_vector()
                        if collided_x:
                            # Detected ball hitting the side of the bat
                            # Send the ball off at an extreme angle, and increase speed

                            # Determine whether the ball will go left or right
                            dx = 1 if self.x > game.bat.x else -1

                            # Determine new direction vector, with a slightly random Y velocity
                            # The new direction vector is normalised to ensure that it is a unit vector
                            self.dir = Vector2(dx, uniform(-0.3, -0.1)).normalize()

                            self.time_since_touched_bat = 0

                            game.impacts.append(Impact((self.x, BAT_TOP_EDGE), 0xc))

                            self.speed = min(self.speed + 4, BALL_MAX_SPEED)

                            Ball.collision_sound(CollisionType.BAT_EDGE)

        # Set shadow actor's position
        self.shadow.pos = (self.x + 16, self.y + 16)

    def increment_speed(self):
        self.speed = min(self.speed + 1, BALL_MAX_SPEED)

    def get_bat_bounce_vector(self):
        # Determine the direction vector to use for the ball bouncing off the bat
        # For bat side collisions this is handled in update, in that case this
        # method is just used to determine whether the ball overlapped with the
        # bat on the X axis

        # dx = difference in X position between centre of bat and centre of ball
        dx = self.x - game.bat.x

        # dx must be within w pixels for the ball to be able to hit the bat
        w = (game.bat.width // 2) + BALL_RADIUS

        # Is ball is within the correct range of the bat on the X axis?
        if abs(dx) < w:
            # Return that the ball was within the correct range on the X
            # axis for there to be a collision, and the bounce vector this
            # position corresponds to
            vec = Vector2(dx / w, -0.5).normalize()
            return True, vec
        else:
            # Return that the ball was not close enough on the X axis for a
            # collision to be possible. Return a vector pointing straight up
            # in case any code tries to use the bounce vector in this scenario.
            # This shouldn't happen, but better safe than sorry - returning
            # None for these values could result in a crash in such a scenario
            return False, Vector2(0, -1)

    def generate_multiballs(self):
        # Get multi ball initial positions
        # This method is called for each existing ball, returning a list of 3 new balls for each one
        # The original ball is then discarded
        balls = []
        for i in range(3):
            # Create direction vector for new ball, the first ball will have the same direction as
            # its original parent ball, the others will have direction vectors rotated 120 and 240
            # degrees from that
            vec = self.dir.rotate(i * 120)
            if abs(vec.y) < 0.15:
                # dy could be zero if the ball is currently stuck to the bat, or could be very close
                # to zero by chance, which could lead to the ball bouncing left and right for ages
                # So if either of these happen, just generate a random upward vector
                vec = Vector2(uniform(-1,1), -1).normalize()

            balls.append(Ball(self.x, self.y, vec, False, self.speed))

        return balls

    @staticmethod
    def collision_sound(collision_type):
        # A static method relates to the class as a whole rather than a specific instance
        # of the class, so doesn't have self as the first parameter
        if collision_type == CollisionType.BRICK or collision_type == CollisionType.INDESTRUCTIBLE_BRICK:
            game.play_sound("hit_brick")
        elif collision_type == CollisionType.WALL:
            game.play_sound("hit_wall")
        elif collision_type == CollisionType.BAT:
            if game.bat.current_type == BatType.MAGNET:
                game.play_sound("ball_stick")
            else:
                game.play_sound("hit_fast")
        elif collision_type == CollisionType.BAT_EDGE:
            if game.bat.current_type == BatType.MAGNET:
                game.play_sound("ball_stick")
            else:
                game.play_sound("hit_veryfast")

class Bat(Actor):
    def __init__(self, controls):
        super().__init__("blank", (320, 590), anchor=("center", 15))

        self.controls = controls
        self.fire_timer = 0

        # The values of target_type and current_type are instances the BatType enum
        # Normally these will be the same. If the player has just picked up a powerup/powerdown
        # then type is the type of bat we're transitioning to, once the transition animation has finished
        # the current type is set to the type
        self.current_type = BatType.NORMAL
        self.target_type = BatType.NORMAL
        self.frame = 0

        # Create shadow actor
        self.shadow = Actor("blank", (self.x + 16, self.y + 16), anchor=("center", 15))

    def update(self):
        # Handle animating to a new bat type
        # If we're a normal bat, we animate to a new type over 12 game frames,
        # changing animation frame every 4 game frames
        # e.g. changing from normal bat (sprite: bat00) to small bat, we go to
        # bat40 (which is the same as the normal bat), then through bat41, bat42
        # and ending at bat43, the fully shrunk bat.
        if self.target_type != BatType.NORMAL and self.target_type == self.current_type and self.frame < 12:
            self.frame += 1

        # If we're switching to a new type from something other than normal bat,
        # we first animate backwards to the first frame of the current type
        if self.target_type != self.current_type and self.frame > 0:
            self.frame -= 1

        # When we're at frame 0, we can update the current type to equal the
        # new type
        if self.frame == 0:
            self.current_type = self.target_type

        # Choose sprite based on current_type and frame
        self.image = f"bat{int(self.current_type)}{self.frame // 4}"

        self.fire_timer -= 1

        # Fire gun?
        if self.controls.fire_down() and self.current_type == BatType.GUN and self.frame == 12 and self.fire_timer <= 0:
            self.fire_timer = FIRE_INTERVAL

            self.image += "f"  # not really visible for the 1 frame it's shown

            game.bullets.append(Bullet((self.x - 20, self.y), 0))
            game.bullets.append(Bullet((self.x + 20, self.y), 1))

            game.play_sound("laser")

        # Move bat based on controls, don't let it go off the edge of the screen
        new_x = self.x + self.controls.get_x()

        # Enforce left boundary
        min_x = BAT_MIN_X + (self.width // 2)
        new_x = max(min_x, new_x)

        if not game.portal_active:
            # Enforce right boundary
            max_x = BAT_MAX_X - (self.width // 2)
            new_x = min(max_x, new_x)

        self.x = new_x

        # Check for leaving level via portal
        if game.portal_active and new_x == BAT_MAX_X - (self.width // 2):
            self.portal_animation_active = True

        # Update shadow actor
        self.shadow.x = self.x + 16
        self.shadow.y = self.y + 16
        self.shadow.image = f"bats{str(int(self.current_type))}{self.frame // 4}"

    def change_type(self, type):
        self.target_type = type

    def is_portal_transition_complete(self):
        return self.x - (self.width // 2) >= WIDTH

# Does the ball (x, y, radius) collide with the brick at the given
# grid position? Returns the point at which the collision occurred
def brick_collide(x, y, grid_x, grid_y, r):
    # Get ball extent as a square
    x0 = x - r
    y0 = y - r
    x1 = x + r
    y1 = y + r

    # Get brick's left, top, right and bottom coordinates
    xb0 = grid_x * BRICK_WIDTH + BRICKS_X_START
    yb0 = grid_y * BRICK_HEIGHT + BRICKS_Y_START
    xb1 = xb0 + BRICK_WIDTH
    yb1 = yb0 + BRICK_HEIGHT

    # Calculate brick centre position
    xbc = (xb0+xb1) // 2
    ybc = (yb0+yb1) // 2

    # Detecting bounce off side of brick
    # if ball right edge > brick left edge,
    #  and ball left edge < brick right edge
    #  and ball y centre > brick top edge
    #  and ball y centre < brick bottom edge
    if x1 > xb0 and x0 < xb1 and y > yb0 and y < yb1:
        if x < xbc:
            return xb0, y
        else:
            return xb1, y

    # Detect bounce off top or bottom of brick
    # if ball x centre > brick left edge
    #  and ball x centre < brick right edge
    #  and ball y bottom > brick y top
    #  and ball y top < brick y bottom
    if x > xb0 and x < xb1 and y1 > yb0 and y0 < yb1:
        if y < ybc:
            return x, yb0
        else:
            return x, yb1

    # Put x/y position into a Vector2 object, which allows us to use the Vector2 methods length/length_squared
    # to calculate distances
    pos_vector = Vector2(x, y)

    # Get closest brick corner
    # We call the Python min function with a list of positions (one for each corner of the brick)
    # The key argument is a lambda function which calculates the squared distance between pos_vector (the pos we're
    # checking) and the corner position (p). We use length_squared rather than length because it's faster and we just
    # care about which corner is closest, not what the actual distance is
    closest = min([(xb0, yb0), (xb1, yb0), (xb0, yb1), (xb1, yb1)],
                  key = lambda p: (pos_vector - Vector2(p)).length_squared())

    # Check if we are actually overlapping with the nearest corner
    if (pos_vector - Vector2(closest)).length() < r:
        # Position does overlap with nearest corner, return corner position
        return closest
    else:
        # No collision with this brick
        return None

class Game:
    def __init__(self, controls=None, lives=3):
        self.controls = controls if controls else AIControls()
        self.lives = lives
        self.score = 0

        self.new_level(0)

    def new_level(self, level_num):
        self.play_sound("start_game")

        # Go back to first level if we've finished last level
        if level_num >= len(LEVELS):
            level_num = 0

        # Create bitmaps for brick and shadow backgrounds
        self.brick_surface = surface.Surface((WIDTH, HEIGHT), flags=pygame.SRCALPHA)
        self.brick_surface.fill((0, 0, 0, 0))

        self.shadow_surface = surface.Surface((WIDTH, HEIGHT), flags=pygame.SRCALPHA)
        self.shadow_surface.fill((0, 0, 0, 0))

        level = get_mirrored_level(LEVELS[level_num])

        self.num_rows = len(level)
        self.num_cols = len(level[0])

        # Convert level data, a list of strings, to as 2D list of integers (or None where no brick is present)
        # The numbers in the level data are in hexadecimal (base 16), where A to F represent 10 to 15
        self.bricks = [[None if level[y][x] == " " else int(level[y][x], 16) for x in range(self.num_cols)] for y in range(self.num_rows)]

        # Draw bricks, and count how many there are, not counting brick ID 13 which is indestructible
        self.bricks_remaining = 0
        for y in range(self.num_rows):
            for x in range(self.num_cols):
                self.redraw_brick(x, y)
                if self.bricks[y][x] != None and self.bricks[y][x] != 13:
                    self.bricks_remaining += 1

        self.balls = [Ball()]
        self.bat = Bat(self.controls)

        self.bullets = []
        self.barrels = []
        self.impacts = []

        self.level_num = level_num
        self.portal_active = False
        self.portal_frame = 0
        self.portal_timer = 0

    def redraw_brick(self, x, y):
        screen_x = x * BRICK_WIDTH + BRICKS_X_START
        screen_y = y * BRICK_HEIGHT + BRICKS_Y_START
        if self.bricks[y][x] != None:
            # Display a brick at this position

            # Get brick image via filename, the files have names brick0 to brickd, see Impact class for a comment
            # explaining how we use hexadecimal numbers here
            brick_image = getattr(images, "brick" + hex(self.bricks[y][x])[2:])

            # Display the brick image to the brick surface, which is an image just containing the bricks
            self.brick_surface.blit(brick_image, (screen_x, screen_y))

            # Update shadow surface
            self.shadow_surface.blit(images.bricks, (screen_x + SHADOW_OFFSET, screen_y + SHADOW_OFFSET))
        else:
            # Remove a brick (and its shadow) from this position)
            self.brick_surface.fill((0, 0, 0, 0), (screen_x, screen_y, BRICK_WIDTH, BRICK_HEIGHT))
            self.shadow_surface.fill((0, 0, 0, 0), (screen_x + SHADOW_OFFSET, screen_y + SHADOW_OFFSET, BRICK_WIDTH, BRICK_HEIGHT))

    def collide(self, x, y, dir, r=BALL_RADIUS):
        # Called to check whether a ball or a bullet would collide with something if it moved in the specified direction
        # Only checks for walls and bricks, collisions with bat are handled elsewhere
        # If there's a collision with a destructible brick, the brick will take damage
        # returns tuple of (tuple 2: impact pos, bool: show impact, CollisionType), or None if no collision

        # Extract x and y of direction into separate variables
        dx,dy = dir

        if dx < 0 and x < LEFT_EDGE + r:
            return (LEFT_EDGE, y), True, CollisionType.WALL
        if dx > 0 and x > RIGHT_EDGE - r:
            return (RIGHT_EDGE, y), True, CollisionType.WALL
        if dy < 0 and y < TOP_EDGE + r:
            return (x, TOP_EDGE), True, CollisionType.WALL

        # Work out the range of brick rows and columns that the ball overlaps
        # This means we don't need to check the ball against every brick,
        # only against the bricks it could potentially be colliding with
        x0 = max(0, math.floor((x-BRICKS_X_START-r)/BRICK_WIDTH))
        y0 = max(0, math.floor((y-BRICKS_Y_START-r)/BRICK_HEIGHT))
        x1 = min(self.num_cols - 1, math.floor((x - BRICKS_X_START + r) / BRICK_WIDTH))
        y1 = min(self.num_rows - 1, math.floor((y - BRICKS_Y_START + r) / BRICK_HEIGHT))

        # Collide with bricks
        for yb in range(y0, y1+1):
            for xb in range(x0, x1+1):
                # Is there a brick in this position?
                if self.bricks[yb][xb] != None:
                    # Check for collision with current brick
                    c = brick_collide(x, y, xb, yb, r)

                    if c is not None:
                        # There was a collision
                        centre_pos = (xb * BRICK_WIDTH + BRICKS_X_START + BRICK_WIDTH // 2,
                                      yb * BRICK_HEIGHT + BRICKS_Y_START + BRICK_HEIGHT // 2)

                        collision_type = CollisionType.BRICK

                        # Check brick type
                        # Brick 12 (brickc.png) requires a hit to turn into brick 11
                        # Brick 13 (brickd.png) is indestructible
                        if self.bricks[yb][xb] >= 12:
                            # Indestructible brick
                            if self.bricks[yb][xb] == 13:
                                collision_type = CollisionType.INDESTRUCTIBLE_BRICK
                            self.impacts.append(Impact(centre_pos, 13))
                            if self.bricks[yb][xb] == 12:
                                self.bricks[yb][xb] = 11
                        else:
                            self.impacts.append(Impact(centre_pos, self.bricks[yb][xb]))

                            if random() < POWERUP_CHANCE:
                                self.barrels.append(Barrel(centre_pos))

                            self.bricks[yb][xb] = None
                            self.redraw_brick(xb, yb)

                            self.bricks_remaining -= 1
                            if self.bricks_remaining == 0:
                                self.activate_portal()

                            self.score += 10

                        return c, False, collision_type

        return None

    def activate_portal(self):
        self.portal_active = True
        self.play_sound("portal_exit")

    def update(self):
        # Update bat and balls
        for obj in [self.bat] + self.balls:
            obj.update()

        # Remove any balls which are off the bottom of the screen
        # We achieve this by regenerating the balls list using a list comprehension, only keeping balls which are
        # still on the screen
        self.balls = [obj for obj in self.balls if obj.y < HEIGHT]

        # Lose a life if there are no balls
        if len(self.balls) == 0:
            # We don't care about how many lives the player has in demo mode
            if self.lives > 0 or self.in_demo_mode():
                self.lives -= 1
                self.balls = [Ball()]
                self.bat.change_type(BatType.NORMAL)

            self.play_sound("lose_life")

        # Update impacts, barrels and bullets
        for obj in self.impacts + self.barrels + self.bullets:
            obj.update()

        # Remove timed-out impacts, barrels which have gone off the bottom of
        # the screen, and bullets which are no longer alive
        self.impacts = [obj for obj in self.impacts if obj.time < 16]
        self.barrels = [obj for obj in self.barrels if obj.y < HEIGHT]
        self.bullets = [obj for obj in self.bullets if obj.alive]

        # Update the portal that allows you to leave the level
        if self.portal_active:
            if self.portal_frame < 3:
                # Update portal animation
                self.portal_timer -= 1
                if self.portal_timer <= 0:
                    self.portal_timer = PORTAL_ANIMATION_SPEED
                    self.portal_frame += 1
            elif self.bat.is_portal_transition_complete():
                self.new_level(self.level_num + 1)

        # If no balls have damaged/destroyed bricks or touched the bat in the last 30 seconds, change all
        # indestructible bricks to two-hit bricks, to avoid a situation where the ball can get stuck bouncing
        # between indestructible bricks
        if self.detect_stuck_balls():
            # Go through all bricks, change indestructible bricks to two-hit bricks
            changed_any = False
            for row in range(self.num_rows):
                for col in range (self.num_cols):
                    # 13 is indestructible brick, 12 is two-hit brick
                    if self.bricks[row][col] == 13:
                        self.bricks[row][col] = 12
                        self.redraw_brick(col, row)
                        changed_any = True

            # Play a sound effect, but only if there were indestructible blocks that were changed
            if changed_any:
                self.play_sound("bat_small", 1)

            # To prevent this triggering again next frame, which should have no gameplay impact but could have
            # a performance impact, we'll pretend that one of the balls has touched the bat in the last 30 seconds
            if len(self.balls) > 0:
                self.balls[0].time_since_touched_bat = 0

    def detect_stuck_balls(self):
        # Detect whether all balls are stuck bouncing between indestructible bricks,
        if len(self.balls) == 0:
            # Having no balls in play doesn't count as all balls being stuck
            return False

        for ball in self.balls:
            if ball.time_since_damaged_brick < 30 * 60 or ball.time_since_touched_bat < 30 * 60:
                # This ball has damaged a brick or touched a bat in the last 30 seconds, so all balls aren't stuck
                return False

        # All balls are stuck
        return True

    def draw(self):
        screen.blit(f"arena{self.level_num % len(LEVELS)}", (0,0))

        # Draw exit portal
        screen.blit(f"portal_exit{self.portal_frame}", (WIDTH - 70 - 20, HEIGHT - 70))

        # Draw enemy doors - currently unused, but animations are present for the doors opening and closing,
        # and for enemies - try adding enemies to the game and making use of these animations!
        screen.blit("portal_meanie00", (110, 40))
        screen.blit("portal_meanie10", (440, 40))

        # This prevents drawing onto the edges of the screen, meaning that the
        # shadows don't overlap with the darker part of the right hand wall
        screen.surface.set_clip((20, 42, 600, 598))

        # Draw brick shadows
        screen.blit(self.shadow_surface, (0, 0))

        # Draw shadows for powerup barrels, balls and bat
        for obj in self.barrels + self.balls + [self.bat]:
            obj.shadow.draw()

        # Draw bricks
        screen.blit(self.brick_surface, (0, 0))

        # Draw balls, bat, barrels and bullets
        for obj in self.balls + [self.bat] + self.barrels + self.bullets:
            obj.draw()

        # Cancel screen clipping mode set earlier
        screen.surface.set_clip(None)

        # Draw impact animations
        for obj in self.impacts:
            obj.draw()

        # Only draw score and lives in normal mode, not in AI/demo mode
        if not self.in_demo_mode():
            self.draw_score()
            self.draw_lives()

    def draw_score(self):
        # Convert score into a string of digits (e.g. "150") so we can
        # draw each individual digit, from left to right
        x = 15
        for digit in str(self.score):
            image = "digit" + digit
            screen.blit(image, (x, 50))
            x += 55

    def draw_lives(self):
        x = 0
        for i in range(self.lives):
            screen.blit("life", (x, HEIGHT-20))
            x += 50

    def play_sound(self, name, count=1):
        # We don't play any in-game sound effects if player is an AI player - as this means we're on the menu
        if not self.in_demo_mode():
            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
                getattr(sounds, name + str(randint(0, count - 1))).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 change_all_ball_speeds(self, change):
        for b in self.balls:
            b.speed = min(max(b.speed + change, BALL_MIN_SPEED), BALL_MAX_SPEED)

    def in_demo_mode(self):
        return isinstance(self.controls, AIControls)

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()

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

# Pygame Zero calls the update and draw functions each frame

def update():
    global state, game, total_frames

    total_frames += 1

    update_controls()

    if state == State.TITLE:
        ai_controls.update()
        game.update()

        # Check for start game
        for controls in (keyboard_controls, joystick_controls):
            # Check for fire button being pressed on each controls object
            # joystick_controls will be None if there is no controller, so must check for that
            if controls is not None and controls.fire_pressed():
                game = Game(controls)
                state = State.PLAY
                stop_music()
                break

    elif state == State.PLAY:
        if game.lives > 0:
            game.update()
        else:
            game.play_sound("game_over")
            state = State.GAME_OVER

    elif state == State.GAME_OVER:
        for controls in (keyboard_controls, joystick_controls):
            if controls is not None and controls.fire_pressed():
                # Return to title screen, which includes a game being played by AI in the background
                game = Game(ai_controls)
                state = state.TITLE
                play_music("title_theme")

def draw():
    game.draw()

    if state == State.TITLE:
        screen.blit("title", (0,0))
        screen.blit("startgame", (20,80))
        screen.blit(f"start{(total_frames // 4) % 13}", (WIDTH//2 - 250//2, 530))

    elif state == State.GAME_OVER:
        screen.blit(f"gameover{(total_frames // 4) % 15}", (WIDTH//2 - 450//2, 450))

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

def stop_music():
    try:
        music.stop()
    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(44100, -16, 2, 1024)
    play_music("title_theme")
    music.set_volume(0.3)
except Exception:
    # If an error occurs (e.g. no sound hardware), ignore it
    pass

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

# Set up state and Game object
state = State.TITLE
game = Game(ai_controls)

total_frames = 0

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