"""Twenty Forty-Eight, by Al Sweigart al@inventwithpython.com
A sliding tile game to combine exponentially-increasing numbers.
Inspired by Gabriele Cirulli's 2048, which is a clone of Veewo Studios'
1024, which in turn is a clone of the Threes! game.
More info at https://en.wikipedia.org/wiki/2048_(video_game)
This code is available at https://nostarch.com/big-book-small-python-programming
Tags: large, game, puzzle"""

import random, sys

# Set up the constants:
BLANK = ''  # A value that represents a blank space on the board.


def main():
    print('''Twenty Forty-Eight, by Al Sweigart al@inventwithpython.com

Slide all the tiles on the board in one of four directions. Tiles with
like numbers will combine into larger-numbered tiles. A new 2 tile is
added to the board on each move. You win if you can create a 2048 tile.
You lose if the board fills up the tiles before then.''')
    input('Press Enter to begin...')

    gameBoard = getNewBoard()

    while True:  # Main game loop.
        drawBoard(gameBoard)
        print('Score:', getScore(gameBoard))
        playerMove = askForPlayerMove()
        gameBoard = makeMove(gameBoard, playerMove)
        addTwoToBoard(gameBoard)

        if isFull(gameBoard):
            drawBoard(gameBoard)
            print('Game Over - Thanks for playing!')
            sys.exit()


def getNewBoard():
    """Returns a new data structure that represents a board.

    It's a dictionary with keys of (x, y) tuples and values of the tile
    at that space. The tile is either a power-of-two integer or BLANK.
    The coordinates are laid out as:
       X0 1 2 3
      Y+-+-+-+-+
      0| | | | |
       +-+-+-+-+
      1| | | | |
       +-+-+-+-+
      2| | | | |
       +-+-+-+-+
      3| | | | |
       +-+-+-+-+"""

    newBoard = {}  # Contains the board data structure to be returned.
    # Loop over every possible space and set all the tiles to blank:
    for x in range(4):
        for y in range(4):
            newBoard[(x, y)] = BLANK

    # Pick two random spaces for the two starting 2's:
    startingTwosPlaced = 0  # The number of starting spaces picked.
    while startingTwosPlaced < 2:  # Repeat for duplicate spaces.
        randomSpace = (random.randint(0, 3), random.randint(0, 3))
        # Make sure the randomly selected space isn't already taken:
        if newBoard[randomSpace] == BLANK:
            newBoard[randomSpace] = 2
            startingTwosPlaced = startingTwosPlaced + 1

    return newBoard


def drawBoard(board):
    """Draws the board data structure on the screen."""

    # Go through each possible space left to right, top to bottom, and
    # create a list of what each space's label should be.
    labels = []  # A list of strings for the number/blank for that tile.
    for y in range(4):
        for x in range(4):
            tile = board[(x, y)]  # Get the tile at this space.
            # Make sure the label is 5 spaces long:
            labelForThisTile = str(tile).center(5)
            labels.append(labelForThisTile)

    # The {} are replaced with the label for that tile:
    print("""
+-----+-----+-----+-----+
|     |     |     |     |
|{}|{}|{}|{}|
|     |     |     |     |
+-----+-----+-----+-----+
|     |     |     |     |
|{}|{}|{}|{}|
|     |     |     |     |
+-----+-----+-----+-----+
|     |     |     |     |
|{}|{}|{}|{}|
|     |     |     |     |
+-----+-----+-----+-----+
|     |     |     |     |
|{}|{}|{}|{}|
|     |     |     |     |
+-----+-----+-----+-----+
""".format(*labels))


def getScore(board):
    """Returns the sum of all the tiles on the board data structure."""
    score = 0
    # Loop over every space and add the tile to the score:
    for x in range(4):
        for y in range(4):
            # Only add non-blank tiles to the score:
            if board[(x, y)] != BLANK:
                score = score + board[(x, y)]
    return score


def combineTilesInColumn(column):
    """The column is a list of four tile. Index 0 is the "bottom" of
    the column, and tiles are pulled "down" and combine if they are the
    same. For example, combineTilesInColumn([2, BLANK, 2, BLANK])
    returns [4, BLANK, BLANK, BLANK]."""

    # Copy only the numbers (not blanks) from column to combinedTiles
    combinedTiles = []  # A list of the non-blank tiles in column.
    for i in range(4):
        if column[i] != BLANK:
            combinedTiles.append(column[i])

    # Keep adding blanks until there are 4 tiles:
    while len(combinedTiles) < 4:
        combinedTiles.append(BLANK)

    # Combine numbers if the one "above" it is the same, and double it.
    for i in range(3):  # Skip index 3: it's the topmost space.
        if combinedTiles[i] == combinedTiles[i + 1]:
            combinedTiles[i] *= 2  # Double the number in the tile.
            # Move the tiles above it down one space:
            for aboveIndex in range(i + 1, 3):
                combinedTiles[aboveIndex] = combinedTiles[aboveIndex + 1]
            combinedTiles[3] = BLANK  # Topmost space is always BLANK.
    return combinedTiles


def makeMove(board, move):
    """Carries out the move on the board.

    The move argument is either 'W', 'A', 'S', or 'D' and the function
    returns the resulting board data structure."""

    # The board is split up into four columns, which are different
    # depending on the direction of the move:
    if move == 'W':
        allColumnsSpaces = [[(0, 0), (0, 1), (0, 2), (0, 3)],
                            [(1, 0), (1, 1), (1, 2), (1, 3)],
                            [(2, 0), (2, 1), (2, 2), (2, 3)],
                            [(3, 0), (3, 1), (3, 2), (3, 3)]]
    elif move == 'A':
        allColumnsSpaces = [[(0, 0), (1, 0), (2, 0), (3, 0)],
                            [(0, 1), (1, 1), (2, 1), (3, 1)],
                            [(0, 2), (1, 2), (2, 2), (3, 2)],
                            [(0, 3), (1, 3), (2, 3), (3, 3)]]
    elif move == 'S':
        allColumnsSpaces = [[(0, 3), (0, 2), (0, 1), (0, 0)],
                            [(1, 3), (1, 2), (1, 1), (1, 0)],
                            [(2, 3), (2, 2), (2, 1), (2, 0)],
                            [(3, 3), (3, 2), (3, 1), (3, 0)]]
    elif move == 'D':
        allColumnsSpaces = [[(3, 0), (2, 0), (1, 0), (0, 0)],
                            [(3, 1), (2, 1), (1, 1), (0, 1)],
                            [(3, 2), (2, 2), (1, 2), (0, 2)],
                            [(3, 3), (2, 3), (1, 3), (0, 3)]]

    # The board data structure after making the move:
    boardAfterMove = {}
    for columnSpaces in allColumnsSpaces:  # Loop over all 4 columns.
        # Get the tiles of this column (The first tile is the "bottom"
        # of the column):
        firstTileSpace = columnSpaces[0]
        secondTileSpace = columnSpaces[1]
        thirdTileSpace = columnSpaces[2]
        fourthTileSpace = columnSpaces[3]

        firstTile = board[firstTileSpace]
        secondTile = board[secondTileSpace]
        thirdTile = board[thirdTileSpace]
        fourthTile = board[fourthTileSpace]

        # Form the column and combine the tiles in it:
        column = [firstTile, secondTile, thirdTile, fourthTile]
        combinedTilesColumn = combineTilesInColumn(column)

        # Set up the new board data structure with the combined tiles:
        boardAfterMove[firstTileSpace] = combinedTilesColumn[0]
        boardAfterMove[secondTileSpace] = combinedTilesColumn[1]
        boardAfterMove[thirdTileSpace] = combinedTilesColumn[2]
        boardAfterMove[fourthTileSpace] = combinedTilesColumn[3]

    return boardAfterMove


def askForPlayerMove():
    """Asks the player for the direction of their next move (or quit).

    Ensures they enter a valid move: either 'W', 'A', 'S' or 'D'."""
    print('Enter move: (WASD or Q to quit)')
    while True:  # Keep looping until they enter a valid move.
        move = input('> ').upper()
        if move == 'Q':
            # End the program:
            print('Thanks for playing!')
            sys.exit()

        # Either return the valid move, or loop back and ask again:
        if move in ('W', 'A', 'S', 'D'):
            return move
        else:
            print('Enter one of "W", "A", "S", "D", or "Q".')


def addTwoToBoard(board):
    """Adds a new 2 tile randomly to the board."""
    while True:
        randomSpace = (random.randint(0, 3), random.randint(0, 3))
        if board[randomSpace] == BLANK:
            board[randomSpace] = 2
            return  # Return after finding one non-blank tile.


def isFull(board):
    """Returns True if the board data structure has no blanks."""
    # Loop over every space on the board:
    for x in range(4):
        for y in range(4):
            # If a space is blank, return False:
            if board[(x, y)] == BLANK:
                return False
    return True  # No space is blank, so return True.


# If this program was run (instead of imported), run the game:
if __name__ == '__main__':
    try:
        main()
    except KeyboardInterrupt:
        sys.exit()  # When Ctrl-C is pressed, end the program.
