"""
2048 Game

@author: Paul Vincent Craven
@author: Robert Duvall (added classes, simplified code)

This module represents an Arcade version of the grid-based game 2048.
    https://play2048.co/

Use the arrow keys to move tiles. Tiles with the same number merge into one when they touch.
"""
import math
import arcade
import random


BACKGROUND_COLOR = (119, 110, 101)
# Colors for squares based on their numeric value (0 is empty square, then powers of two: 2, 4, 8, 16, ..., 2048)
SQUARE_COLORS = [
    (205, 193, 180),
    (238, 228, 218),
    (237, 224, 200),
    (242, 177, 121),
    (245, 149, 99),
    (246, 124, 95),
    (246, 94, 59),
    (237, 207, 114),
    (237, 204, 97),
    (237, 200, 80),
    (237, 197, 63),
    (237, 194, 46),
    (62, 57, 51)
]
# Text color switches from light to dark as the square colors get darker
TEXT_COLOR_LIGHT = (249, 246, 242)
TEXT_COLOR_DARK = (119, 110, 101)
TEXT_SIZE = 36
# Choose values that determine game play
BOARD_SIZE = 6
SQUARE_SIZE = 100
MARGIN = 10
# Size of the game window is determined by the number of squares on the board
WINDOW_WIDTH = BOARD_SIZE * (SQUARE_SIZE + MARGIN) + MARGIN
WINDOW_HEIGHT = BOARD_SIZE * (SQUARE_SIZE + MARGIN) + MARGIN
GAME_NAME = '2048'


###
# Game mechanics related classes to help simplify the main Game class.
###
class Grid:
    """
    This class represents the 2D data structure that represents the state of the game board:
    - the values of each square (empty or a power of 2)
    """
    def __init__(self, size):
        self.cells = []
        self.num_rows = size
        self.num_columns = size

    def reset(self):
        """
        Reset all the grid cell values back to 0.
        """
        # nested loop that creates a list of lists of 0s squeezed into a single line
        self.cells = [ [0] * self.num_columns for x in range(self.num_rows) ]
        # OR
        # uses Python multiply operator to "repeat" the list elements instead of nested loop
        # self.cells = []
        # for r in range(self.num_rows):
        #     self.cells.append([0] * self.num_columns)

        # OR
        # conventional nested loop that creates each list in the outer loop and appends each value in the inner loop
        # self.cells = []
        # for y in range(self.num_rows):
        #     self.cells.append([])
        #     for x in range(self.num_columns):
        #         self.cells[y].append(0)

    def score(self):
        """
        Add up all the grid cell values.
        """
        total = 0
        for listOfValues in self.cells:
            # sum function loops over entire list of numbers, so no inner loop needed
            total += sum(listOfValues)
        return total

    def is_full(self):
        """
        Returns True only if every cell has a non-zero value.
        """
        for listOfValues in self.cells:
            for cell in listOfValues:
                if cell == 0:
                    return False
        return True

    def print(self):
        """
        Print the 2D grid one row per line with values evenly spaced across the row.
        """
        for listOfValues in self.cells:
            for cell in listOfValues:
                print(f"{cell:5}", end='')
            print()
        print()

    def spawn(self):
        """
        Find a random empty location to spawn a new number.
        """
        # return False if there are no empty cells
        if self.is_full():
            return False

        possible_locations = []
        # loop through each cell:
        for row in range(self.num_rows):
            for column in range(self.num_columns):
                # if this cell is empty, add it to the list
                if self.cells[row][column] == 0:
                    possible_locations.append([column, row])

        # spawn a 2 90% of the time, 10% of the time a 4.
        number = 2 if random.random() <= 0.9 else 4
        # select random one out of the possible locations, assign list contents to individual variables
        column, row = random.choice(possible_locations)
        # set grid value to the random number
        self.cells[row][column] = number
        return True

    def compress(self, row_change, col_change):
        """
        Shift non-zero values over, compressing out any zeros, along the given direction by setting:
        - the start index
        - one past the last index
        - the step direction (forward or reverse through the list)
        """
        if row_change > 0:
            return self.compress_up_down(self.num_rows - 1, -1, -1)
        elif row_change < 0:
            return self.compress_up_down(0, self.num_rows, 1)
        elif col_change > 0:
            return self.compress_left_right(self.num_columns - 1, -1, -1)
        elif col_change < 0:
            return self.compress_left_right(0, self.num_columns, 1)

    def compress_up_down(self, row_start, row_end, row_step):
        """
        Shift non-zero values in rows, compressing out any zeros, along the given direction
        (start to end of row OR reversed, end to start of row).
        2 0 2 0 -> 0 0 2 2
        """
        changed = False
        # loop of every column
        for col in range(self.num_columns):
            # keep track of the index of where to swap the current non-zero to
            row_index = row_start
            # loop over every row
            for row in range(row_start, row_end, row_step):
                # move the current non-zero back to where the first zero value is
                if self.cells[row][col] != 0 and self.cells[row_index][col] == 0:
                    # swap two values by assigning to each in reverse order!
                    self.cells[row][col], self.cells[row_index][col] = self.cells[row_index][col], self.cells[row][col]
                    row_index += row_step
                    changed = True
                # move past existing non-zero value
                if self.cells[row_index][col] != 0:
                    row_index += row_step
        return changed

    def compress_left_right(self, col_start, col_end, col_step):
        """
        Shift non-zero values in columns, compressing out any zeros, along the given direction
        (start to end of column OR reversed, end to start of column).
        2 0 2 0 -> 0 0 2 2
        """
        changed = False
        # loop of every row
        for row in range(self.num_rows):
            # keep track of the index of where to swap the current non-zero to
            col_index = col_start
            # loop over every column
            for col in range(col_start, col_end, col_step):
                # move the current non-zero back to where the first zero value is
                if self.cells[row][col] != 0 and self.cells[row][col_index] == 0:
                    # swap two values by assigning to each in reverse order!
                    self.cells[row][col], self.cells[row][col_index] = self.cells[row][col_index], self.cells[row][col]
                    col_index += col_step
                    changed = True
                # move past existing non-zero value
                if self.cells[row][col_index] != 0:
                    col_index += col_step
        return changed

    def merge(self, row_change, col_change):
        """
        Merge like cells along the given direction by setting:
        - the start index
        - one past the last index
        - the step direction (forward or reverse through the list)
        """
        if row_change > 0:
            return self.merge_up_down(0, self.num_rows - 1, 1)
        elif row_change < 0:
            return self.merge_up_down(self.num_rows - 1, 0, -1)
        elif col_change > 0:
            return self.merge_left_right(0, self.num_columns - 1, 1)
        elif col_change < 0:
            return self.merge_left_right(self.num_columns - 1, 0, -1)

    def merge_up_down(self, row_start, row_end, row_step):
        """
        Merge like cells in rows along the given direction (start to end of row OR reversed, end to start of row)
        4 4 4 4 -> 0 8 0 8
        """
        changed = False
        # loop of every column
        for col in range(self.num_columns):
            # loop over row up to, but not including, the last value
            for row in range(row_start, row_end, row_step):
                # check for two consecutive values by looking ahead to the next value
                if self.cells[row][col] != 0 and self.cells[row][col] == self.cells[row + row_step][col]:
                    self.cells[row][col] *= 2
                    self.cells[row + row_step][col] = 0
                    changed = True
        return changed

    def merge_left_right(self, col_start, col_end, col_step):
        """
        Merge like cells in columns along the given direction (start to end of column OR reversed, end to start of column)
        4 4 4 4 -> 0 8 0 8
        """
        changed = False
        # loop of every row
        for row in range(self.num_rows):
            # loop over column up to, but not including, the last value
            for col in range(col_start, col_end, col_step):
                # check for two consecutive values by looking ahead to the next value
                if self.cells[row][col] != 0 and self.cells[row][col] == self.cells[row][col + col_step]:
                    self.cells[row][col] *= 2
                    self.cells[row][col + col_step] = 0
                    changed = True
        return changed

    def slide(self, row_change, col_change):
        """
        Process the "slide" action by compressing, merging, and compressing again.
        """
        c1 = self.compress(row_change, col_change)
        c2 = self.merge(row_change, col_change)
        c3 = self.compress(row_change, col_change)
        # a change in any of the actions means a change happened
        return c1 or c2 or c3


class Square(arcade.SpriteSolidColor):
    """
    This class represents a square in the grid as a Sprite that knows its power of 2 value.
    """
    def __init__(self, value, size, x, y):
        # oddly, to be able to change the color properly later it must first be set to WHITE
        super().__init__(size, size, arcade.color.WHITE)
        self.center_x = x
        self.center_y = y
        self.value = value

    def draw(self):
        """
        Draw as a Square plus the numeric value, set the color and text size based on its value.
        """
        # get index for list of colors by getting which power of 2 the value is
        index = int(math.log2(self.value)) if self.value != 0 else 0
        self.color = SQUARE_COLORS[index]
        # draw the square
        super().draw()
        # if the square is not empty (i.e., has a power of 2 value)
        if self.value != 0:
            # IF-ELSE conditional squeezed into a single line
            color = TEXT_COLOR_DARK if self.value <= 8 else TEXT_COLOR_LIGHT
            # OR
            # if self.value <= 8:
            #     color = TEXT_COLOR_DARK
            # else:
            #     color = TEXT_COLOR_LIGHT
            size = TEXT_SIZE - 4 * len(str(self.value))
            arcade.draw_text(self.value, self.center_x, self.center_y, color, size, anchor_x="center", anchor_y="center")


###
# Main game window that interacts with the player, implements the game rules, and draws the Grid's values
###
class Game(arcade.Window):
    """
    This class represents the entire game:
    - setting up any objects to appear in game
    - updating their values (in method on_update)
    - drawing them (in method on_draw)
    - responding to mouse input (in method on_mouse_press)
    - responding to key input (in method on_key_press)
    """
    def __init__(self):
        super().__init__(WINDOW_WIDTH, WINDOW_HEIGHT, GAME_NAME)
        # create the 2D data structure that represents the game board
        self.grid = Grid(BOARD_SIZE)
        # create the 2D grid of sprites the shows the grid
        self.sprites = []

    def setup(self):
        """
        Set up the initial state at the beginning of the game or to allow playing again after a win or loss
        """
        self.grid.reset()
        self.grid.spawn()
        self.create_grid_sprites()
        self.grid.print()
        arcade.set_background_color(BACKGROUND_COLOR)

    def create_grid_sprites(self):
        """
        Create the Sprites to represent the state of the game on the screen.
        """
        self.sprites.clear()
        # nested loop to calculate the (x, y) coordinate of each Sprite
        for row in range(self.grid.num_rows):
            # add new list to the list that will hold the row of Sprites
            self.sprites.append([])
            for col in range(self.grid.num_columns):
                # create each Sprite with information about the grid first, then size and (x, y) position
                sprite = Square(self.grid.cells[row][col], SQUARE_SIZE,
                                SQUARE_SIZE / 2 + MARGIN + col * (SQUARE_SIZE + MARGIN),
                                SQUARE_SIZE / 2 + MARGIN + (BOARD_SIZE - row - 1) * (SQUARE_SIZE + MARGIN))
                self.sprites[row].append(sprite)

    def update_grid_sprites(self):
        """
        Update each Sprite's value to match the game board's value in case it changed
        """
        for row in range(self.grid.num_rows):
            for col in range(self.grid.num_columns):
                self.sprites[row][col].value = self.grid.cells[row][col]

    def on_key_press(self, symbol: int, modifiers: int):
        """
        Called whenever a key is pressed.
        """
        changed = False
        # slide values based on direction
        if symbol == arcade.key.LEFT:
            changed = self.grid.slide(0, -1)
        elif symbol == arcade.key.RIGHT:
            changed = self.grid.slide(0, 1)
        elif symbol == arcade.key.UP:
            changed = self.grid.slide(-1, 0)
        elif symbol == arcade.key.DOWN:
            changed = self.grid.slide(1, 0)

        # spawn a new value if sliding values made any kind of change to the grid's state
        if changed:
            print('SLIDE')
            self.grid.print()
            print('SPAWN')
            if self.grid.spawn():
                self.grid.print()
                print(self.grid.score())
            else:
                print('NO SPACE TO SPAWN')
            # update the Sprites to reflect any changes made
            self.update_grid_sprites()

    def on_draw(self):
        """
        Draw the grid of sprites on the screen.
        """
        self.clear()
        for rows in self.sprites:
            for sprite in rows:
                sprite.draw()


# Create the game and set initial scene
game = Game()
game.setup()
# Play the game forever
arcade.run()
