import pgzeropgzrunpygame
import mathsysrandom
from enum import Enum

# Check Python version number. sys.version_info gives version as a tuple, e.g. if (3,7,2,'final',0) for version 3.7.2.
# Unlike many languages, Python can compare two tuples in the same way that you can compare numbers.
if sys.version_info < (3,5):
    print("This game requires at least version 3.5 of Python. Please download it from www.python.org")
    sys.exit()

# Check Pygame Zero version. This is a bit trickier because Pygame Zero only lets us get its version number as a string.
# So we have to split the string into a list, using '.' as the character to split on. We convert each element of the
# version number into an integer - but only if the string contains numbers and nothing else, because it's possible for
# a component of the version to contain letters as well as numbers (e.g. '2.0.dev0')
# We're using a Python feature called list comprehension - this is explained in the Bubble Bobble/Cavern chapter.
pgzero_version = [int(sif s.isnumeric() else for in pgzero.__version__.split('.')]
if pgzero_version < [1,2]:
    print("This game requires at least version 1.2 of Pygame Zero. You have version {0}. Please upgrade using the command 'pip3 install --upgrade pgzero'".format(pgzero.__version__))
    sys.exit()

# Set up constants
WIDTH 800
HEIGHT 480
TITLE "Boing!"

HALF_WIDTH WIDTH // 2
HALF_HEIGHT HEIGHT // 2

PLAYER_SPEED 6
MAX_AI_SPEED 6

def normalised(xy):
    # Return a unit vector
    # Get length of vector (x,y) - math.hypot uses Pythagoras' theorem to get length of hypotenuse
    # of right-angle triangle with sides of length x and y
    # todo note on safety
    length math.hypot(xy)
    return (lengthlength)

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


# Class for an animation which is displayed briefly whenever the ball bounces
class Impact(Actor):
    def __init__(selfpos):
        super().__init__("blank"pos)
        self.time 0

    def update(self):
        # There are 5 impact sprites numbered 0 to 4. We update to a new sprite every 2 frames.
        self.image "impact" str(self.time // 2)

        # The Game class maintains a list of Impact instances. In Game.update, if the timer for an object
        # has gone beyond 10, the object is removed from the list.
        self.time += 1


class Ball(Actor):
    def __init__(selfdx):
        super().__init__("ball", (0,0))

        self.xself.HALF_WIDTHHALF_HEIGHT

        # dx and dy together describe the direction in which the ball is moving. For example, if dx and dy are 1 and 0,
        # the ball is moving to the right, with no movement up or down. If both values are negative, the ball is moving
        # left and up, with the angle depending on the relative values of the two variables. If you're familiar with
        # vectors, dx and dy represent a unit vector. If you're not familiar with vectors, see the explanation in the
        # book.
        self.dxself.dy dx0

        self.speed 5

    def update(self):
        # Each frame, we move the ball in a series of small steps - the number of steps being based on its speed attribute
        for in range(self.speed):
            # Store the previous x position
            original_x self.x

            # Move the ball based on dx and dy
            self.+= self.dx
            self.+= self.dy

            # Check to see if ball needs to bounce off a bat

            # To determine whether the ball might collide with a bat, we first measure the horizontal distance from the
            # ball to the centre of the screen, and check to see if its edge has gone beyond the edge of the bat.
            # The centre of each bat is 40 pixels from the edge of the screen, or to put it another way, 360 pixels
            # from the centre of the screen. The bat is 18 pixels wide and the ball is 14 pixels wide. Given that these
            # sprites are anchored from their centres, when determining if they overlap or touch, we need to look at
            # their half-widths - 9 and 7. Therefore, if the centre of the ball is 344 pixels from the centre of the
            # screen, it can bounce off a bat (assuming the bat is in the right position on the Y axis - checked
            # shortly afterwards).
            # We also check the previous X position to ensure that this is the first frame in which the ball crossed the threshold.
            if abs(self.HALF_WIDTH) >= 344 and abs(original_x HALF_WIDTH) < 344:

                # Now that we know the edge of the ball has crossed the threshold on the x-axis, we need to check to
                # see if the bat on the relevant side of the arena is at a suitable position on the y-axis for the
                # ball collide with it.

                if self.HALF_WIDTH:
                    new_dir_x 1
                    bat game.bats[0]
                else:
                    new_dir_x = -1
                    bat game.bats[1]

                difference_y self.bat.y

                if difference_y > -64 and difference_y 64:
                    # Ball has collided with bat - calculate new direction vector

                    # To understand the maths used below, we first need to consider what would happen with this kind of
                    # collision in the real world. The ball is bouncing off a perfectly vertical surface. This makes for a
                    # pretty simple calculation. Let's take a ball which is travelling at 1 metre per second to the right,
                    # and 2 metres per second down. Imagine this is taking place in space, so gravity isn't a factor.
                    # After the ball hits the bat, it's still going to be moving at 2 m/s down, but it's now going to be
                    # moving 1 m/s to the left instead of right. So its speed on the y-axis hasn't changed, but its
                    # direction on the x-axis has been reversed. This is extremely easy to code - "self.dx = -self.dx".
                    # However, games don't have to perfectly reflect reality.
                    # In Pong, hitting the ball with the upper or lower parts of the bat would make it bounce diagonally
                    # upwards or downwards respectively. This gives the player a degree of control over where the ball
                    # goes. To make for a more interesting game, we want to use realistic physics as the starting point,
                    # but combine with this the ability to influence the direction of the ball. When the ball hits the
                    # bat, we're going to deflect the ball slightly upwards or downwards depending on where it hit the
                    # bat. This gives the player a bit of control over where the ball goes.

                    # Bounce the opposite way on the X axis
                    self.dx = -self.dx

                    # Deflect slightly up or down depending on where ball hit bat
                    self.dy += difference_y 128

                    # Limit the Y component of the vector so we don't get into a situation where the ball is bouncing
                    # up and down too rapidly
                    self.dy min(max(self.dy, -1), 1)

                    # Ensure our direction vector is a unit vector, i.e. represents a distance of the equivalent of
                    # 1 pixel regardless of its angle
                    self.dxself.dy normalised(self.dxself.dy)

                    # Create an impact effect
                    game.impacts.append(Impact((self.new_dir_x 10self.y)))

                    # Increase speed with each hit
                    self.speed += 1

                    # Add an offset to the AI player's target Y position, so it won't aim to hit the ball exactly
                    # in the centre of the bat
                    game.ai_offset random.randint(-1010)

                    # Bat glows for 10 frames
                    bat.timer 10

                    # Play hit sounds, with more intense sound effects as the ball gets faster
                    game.play_sound("hit"5)  # play every time in addition to:
                    if self.speed <= 10:
                        game.play_sound("hit_slow"1)
                    elif self.speed <= 12:
                        game.play_sound("hit_medium"1)
                    elif self.speed <= 16:
                        game.play_sound("hit_fast"1)
                    else:
                        game.play_sound("hit_veryfast"1)

            # The top and bottom of the arena are 220 pixels from the centre
            if abs(self.HALF_HEIGHT) > 220:
                # Invert vertical direction and apply new dy to y so that the ball is no longer overlapping with the
                # edge of the arena
                self.dy = -self.dy
                self.+= self.dy

                # Create impact effect
                game.impacts.append(Impact(self.pos))

                # Sound effect
                game.play_sound("bounce"5)
                game.play_sound("bounce_synth"1)

    def out(self):
        # Has ball gone off the left or right edge of the screen?
        return self.or self.WIDTH


class Bat(Actor):
    def __init__(selfplayermove_func=None):
        40 if player == else 760
        HALF_HEIGHT
        super().__init__("blank", (xy))

        self.player player
        self.score 0

        # move_func is a function we may or may not have been passed by the code which created this object. If this bat
        # is meant to be player controlled, move_func will be a function that when called, returns a number indicating
        # the direction and speed in which the bat should move, based on the keys the player is currently pressing.
        # If move_func is None, this indicates that this bat should instead be controlled by the AI method.
        if move_func != None:
            self.move_func move_func
        else:
            self.move_func self.ai

        # Each bat has a timer which starts at zero and counts down by one every frame. When a player concedes a point,
        # their timer is set to 20, which causes the bat to display a different animation frame. It is also used to
        # decide when to create a new ball in the centre of the screen - see comments in Game.update for more on this.
        # Finally, it is used in Game.draw to determine when to display a visual effect over the top of the background
        self.timer 0

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

        # Our movement function tells us how much to move on the Y axis
        y_movement self.move_func()

        # Apply y_movement to y position, ensuring bat does not go through the side walls
        self.min(400max(80self.y_movement))

        # Choose the appropriate sprite. There are 3 sprites per player - e.g. bat00 is the left-hand player's
        # standard bat sprite, bat01 is the sprite to use when the ball has just bounced off the bat, and bat02
        # is the sprite to use when the bat has just missed the ball and the ball has gone out of bounds.
        # bat10, 11 and 12 are the equivalents for the right-hand player

        frame 0
        if self.timer 0:
            if game.ball.out():
                frame 2
            else:
                frame 1

        self.image "bat" str(self.player) + str(frame)

    def ai(self):
        # Returns a number indicating how the computer player will move - e.g. 4 means it will move 4 pixels down
        # the screen.

        # To decide where we want to go, we first check to see how far we are from the ball.
        x_distance abs(game.ball.self.x)

        # If the ball is far away, we move towards the centre of the screen (HALF_HEIGHT), on the basis that we don't
        # yet know whether the ball will be in the top or bottom half of the screen when it reaches our position on
        # the X axis. By waiting at a central position, we're as ready as it's possible to be for all eventualities.
        target_y_1 HALF_HEIGHT

        # If the ball is close, we want to move towards its position on the Y axis. We also apply a small offset which
        # is randomly generated each time the ball bounces. This is to make the computer player slightly less robotic
        # - a human player wouldn't be able to hit the ball right in the centre of the bat each time.
        target_y_2 game.ball.game.ai_offset

        # The final step is to work out the actual Y position we want to move towards. We use what's called a weighted
        # average - taking the average of the two target Y positions we've previously calculated, but shifting the
        # balance towards one or the other depending on how far away the ball is. If the ball is more than 400 pixels
        # (half the screen width) away on the X axis, our target will be half the screen height (target_y_1). If the
        # ball is at the same position as us on the X axis, our target will be target_y_2. If it's 200 pixels away,
        # we'll aim for halfway between target_y_1 and target_y_2. This reflects the idea that as the ball gets closer,
        # we have a better idea of where it's going to end up.
        weight1 min(1x_distance HALF_WIDTH)
        weight2 weight1

        target_y = (weight1 target_y_1) + (weight2 target_y_2)

        # Subtract target_y from our current Y position, then make sure we can't move any further than MAX_AI_SPEED
        # each frame
        return min(MAX_AI_SPEEDmax(-MAX_AI_SPEEDtarget_y self.y))


class Game:
    def __init__(selfcontrols=(NoneNone)):
        # Create a list of two bats, giving each a player number and a function to use to receive
        # control inputs (or the value None if this is intended to be an AI player)
        self.bats = [Bat(0controls[0]), Bat(1controls[1])]

        # Create a ball object
        self.ball Ball(-1)

        # Create an empty list which will later store the details of currently playing impact
        # animations - these are displayed for a short time every time the ball bounces
        self.impacts = []

        # Add an offset to the AI player's target Y position, so it won't aim to hit the ball exactly
        # in the centre of the bat
        self.ai_offset 0

    def update(self):
        # Update all active objects
        for obj in self.bats + [self.ball] + self.impacts:
            obj.update()

        # Remove any expired impact effects from the list. We go through the list backwards, starting from the last
        # element, and delete any elements those time attribute has reached 10. We go backwards through the list
        # instead of forwards to avoid a number of issues which occur in that scenario. In the next chapter we will
        # look at an alternative technique for removing items from a list, using list comprehensions.
        for in range(len(self.impacts) - 1, -1, -1):
            if self.impacts[i].time >= 10:
                del self.impacts[i]

        # Has ball gone off the left or right edge of the screen?
        if self.ball.out():
            # Work out which player gained a point, based on whether the ball
            # was on the left or right-hand side of the screen
            scoring_player if self.ball.WIDTH // else 0
            losing_player scoring_player

            # We use the timer of the player who has just conceded a point to decide when to create a new ball in the
            # centre of the level. This timer starts at zero at the beginning of the game and counts down by one every
            # frame. Therefore, on the frame where the ball first goes off the screen, the timer will be less than zero.
            # We set it to 20, which means that this player's bat will display a different animation frame for 20
            # frames, and a new ball will be created after 20 frames
            if self.bats[losing_player].timer 0:
                self.bats[scoring_player].score += 1

                game.play_sound("score_goal"1)

                self.bats[losing_player].timer 20

            elif self.bats[losing_player].timer == 0:
                # After 20 frames, create a new ball, heading in the direction of the player who just missed the ball
                direction = -if losing_player == else 1
                self.ball Ball(direction)

    def draw(self):
        # Draw background
        screen.blit("table", (0,0))

        # Draw 'just scored' effects, if required
        for in (0,1):
            if self.bats[p].timer and game.ball.out():
                screen.blit("effect" str(p), (0,0))

        # Draw bats, ball and impact effects - in that order. Square brackets are needed around the ball because
        # it's just an object, whereas the other two are lists - and you can't directly join an object onto a
        # list without first putting it in a list
        for obj in self.bats + [self.ball] + self.impacts:
            obj.draw()

        # Display scores - outer loop goes through each player
        for in (0,1):
            # Convert score into a string of 2 digits (e.g. "05") so we can later get the individual digits
            score "{0:02d}".format(self.bats[p].score)
            # Inner loop goes through each digit
            for in (0,1):
                # Digit sprites are numbered 00 to 29, where the first digit is the colour (0 = grey,
                # 1 = blue, 2 = green) and the second digit is the digit itself
                # Colour is usually grey but turns red or green (depending on player number) when a
                # point has just been scored
                colour "0"
                other_p p
                if self.bats[other_p].timer and game.ball.out():
                    colour "2" if == 0  else "1"
                image "digit" colour str(score[i])
                screen.blit(image, (255 + (160 p) + (55), 46))

    def play_sound(selfnamecount=1):
        # Some sounds have multiple varieties. If count > 1, we'll randomly choose one from those
        # We don't play any in-game sound effects if player 0 is an AI player - as this means we're on the menu
        if self.bats[0].move_func != self.bats[0].ai:
            # 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
            try:
                getattr(soundsname str(random.randint(0count 1))).play()
            except:
                pass

def p1_controls():
    move 0
    if keyboard.or keyboard.down:
        move PLAYER_SPEED
    elif keyboard.or keyboard.up:
        move = -PLAYER_SPEED
    return move

def p2_controls():
    move 0
    if keyboard.m:
        move PLAYER_SPEED
    elif keyboard.k:
        move = -PLAYER_SPEED
    return move

class State(Enum):
    MENU 1
    PLAY 2
    GAME_OVER 3

num_players 1

# Is space currently being held down?
space_down False


# Pygame Zero calls the update and draw functions each frame

def update():
    global stategamenum_playersspace_down

    # Work out whether the space key has just been pressed - i.e. in the previous frame it wasn't down,
    # and in this frame it is.
    space_pressed False
    if keyboard.space and not space_down:
        space_pressed True
    space_down keyboard.space

    if state == State.MENU:
        if space_pressed:
            # Switch to play state, and create a new Game object, passing it the controls function for
            # player 1, and if we're in 2 player mode, the controls function for player 2 (otherwise the
            # 'None' value indicating this player should be computer-controlled)
            state State.PLAY
            controls = [p1_controls]
            controls.append(p2_controls if num_players == else None)
            game Game(controls)
        else:
            # Detect up/down keys
            if num_players == and keyboard.up:
                sounds.up.play()
                num_players 1
            elif num_players == and keyboard.down:
                sounds.down.play()
                num_players 2

            # Update the 'attract mode' game in the background (two AIs playing each other)
            game.update()

    elif state == State.PLAY:
        # Has anyone won?
        if max(game.bats[0].scoregame.bats[1].score) > 9:
            state State.GAME_OVER
        else:
            game.update()

    elif state == State.GAME_OVER:
        if space_pressed:
            # Reset to menu state
            state State.MENU
            num_players 1

            # Create a new Game object, without any players
            game Game()

def draw():
    game.draw()

    if state == State.MENU:
        menu_image "menu" str(num_players 1)
        screen.blit(menu_image, (0,0))

    elif state == State.GAME_OVER:
        screen.blit("over", (0,0))


# The mixer allows us to play sounds and music
try:
    pygame.mixer.quit()
    pygame.mixer.init(44100, -1621024)

    music.play("theme")
    music.set_volume(0.3)
except:
    # If an error occurs (e.g. no sound device), just ignore it
    pass

# Set the initial game state
state State.MENU

# Create a new Game object, without any players
game Game()

# Tell Pygame Zero to start - this line is only required when running the game from an IDE such as IDLE or PyCharm
pgzrun.go()