5
\$\begingroup\$

I built 2048 in Python with Pygame and it works as expected.

I wanted to know where I could -

  • Optimize the performance.
  • Improve the UX.
  • Or just improve the code readability.

My file directory -

2048 - 
      | - constants.py
      | - app.py
      | - main.py

Run using python main.py or python3 main.py.

Here is the code:

constants.py

'''Constants for 2048'''

import pygame
from pygame.locals import *

pygame.init()
pygame.font.init()

WIDTH, HEIGHT = 750, 500
CAPTION = '2048'

TOP, LEFT = 125, 50
BLOCK_WIDTH, BLOCK_HEIGHT = 75, 75
GAP = 7

MAIN_MENU_FONT = pygame.font.SysFont('Tahoma', 45)
TITLE_FONT = pygame.font.SysFont('Tahoma', 75)
BLOCK_FONT = pygame.font.SysFont('Tahoma', 25)
STATS_FONT = pygame.font.SysFont('Tahoma', 40)
GAME_OVER_FONT = pygame.font.SysFont('Tahoma', 45)

# Credit for Colors - https://github.com/yangshun/2048-python/blob/master/constants.py
BLACK = '#000000'
WHITE = '#ffffff'
BLUE =  '#0000ff'
RED =   '#ff0000'

BG_COLOR = '#bbada0'

COLOR_MAP = {
    0:     ('#CCC0B4', None),
    2:     ('#eee4da', '#776e65'),
    4:     ('#ede0c8', '#776e65'),
    8:     ('#f2b179', '#f9f6f2'),
    16:    ('#f59563', '#f9f6f2'),
    32:    ('#f67c5f', '#f9f6f2'),
    64:    ('#f65e3b', '#f9f6f2'),
    128:   ('#edcf72', '#f9f6f2'),
    256:   ('#edcc61', '#f9f6f2'),
    512:   ('#edc850', '#f9f6f2'),
    1024:  ('#edc53f', '#f9f6f2'),
    2048:  ('#edc22e', '#f9f6f2'),
    4096:  ('#eee4da', '#776e65'),
    8192:  ('#edc22e', '#f9f6f2'),
    16384: ('#f2b179', '#776e65'),
    32768: ('#f59563', '#776e65'),
    65536: ('#f67c5f', '#f9f6f2')
}

app.py

'''App Class for 2048'''

from constants import *

import pygame
from pygame.locals import *
from random import choice

pygame.init()
pygame.font.init()

class App_2048:
    '''2048 App Class'''
    def __init__(self, end: int = None) -> None:
        '''Initializing the 2048 class. 
        Accepts an optional int and ends the game as a win once a end number tile is obtained, else keeps continuing'''
        self.end = end
        self.win = pygame.display.set_mode((WIDTH, HEIGHT))
        pygame.display.set_caption(CAPTION)

    def init_variables(self):
        self.running = True
        self.score = 0
        self.grid = [[0 for _ in range(4)] for _ in range(4)]
        self.add_block()

    def exit(self) -> None:
        '''Safely exit the program'''
        pygame.quit()
        quit()

    def rot90(self, matrix: list[list]) -> list[list]:
        '''Rotates the given matrix 90 degree and returns it'''
        return [list(reversed(row)) for row in zip(*matrix)]

    def rot180(self, matrix: list[list]) -> list[list]:
        '''Rotates the given matrix 180 degree and returns it'''
        return self.rot90(self.rot90(matrix))

    def rot270(self, matrix: list[list]) -> list[list]:
        '''Rotates the given matrix 270 degree and returns it'''
        return self.rot180(self.rot90(matrix))

    def push_right(self) -> None:
        '''Pushes the tiles in self.matrix to the right'''
        for col in range(len(self.grid[0]) - 2, -1, -1):
            for row in self.grid:
                if row[col + 1] == 0:
                    row[col], row[col + 1] = 0, row[col]
                elif row[col + 1] == row[col]:
                    self.score += row[col] * 2
                    row[col], row[col + 1] = 0, row[col] * 2

    def right(self) -> None:
        '''Performs a right action on self.matrix'''
        self.push_right()
        self.update()

    def left(self) -> None:
        '''Performs a left action on self.matrix'''
        self.grid = self.rot180(self.grid)
        self.push_right()
        self.grid = self.rot180(self.grid)
        self.update()

    def up(self) -> None:
        '''Performs a up action on self.matrix'''
        self.grid = self.rot90(self.grid)
        self.right()
        self.grid = self.rot270(self.grid)
        self.update()

    def down(self) -> None:
        '''Performs a down action on self.matrix'''
        self.grid = self.rot270(self.grid)
        self.push_right()
        self.grid = self.rot90(self.grid)
        self.update()

    def game_state(self) -> str:
        '''Returns the state of the game:
        1) WIN if the game is won.
        2) BLOCK AVAILABLE if a block is still not filled.
        3) CAN MERGE if two tiles can still be merged.
        4) LOSE if the game is lost.'''
        if self.end:
            for row in range(len(self.grid)):
                for col in range(len(self.grid[row])):
                    if self.grid[row][col] == self.end:
                        return 'WIN'

        for row in range(len(self.grid)):
            for col in range(len(self.grid[row])):
                if self.grid[row][col] == 0:
                    return 'BLOCK AVAILABLE'

        for row in range(len(self.grid)):
            for col in range(len(self.grid[row]) - 1):
                if self.grid[row][col] == self.grid[row][col + 1]:
                    return 'CAN MERGE'

        for row in range(len(self.grid) - 1):
            for col in range(len(self.grid[row])):
                if self.grid[row][col] == self.grid[row + 1][col]:
                    return 'CAN MERGE'

        return 'LOSE'

    def add_block(self) -> None:
        '''Adds a random block to self.matrix'''
        free_blocks = [(y, x) for y, row in enumerate(self.grid) for x, num in enumerate(row) if num == 0]
        y, x = choice(free_blocks)
        self.grid[y][x] = 2

    def update(self) -> None:
        '''Updates the game state'''
        state = self.game_state()
        if state == 'WIN':
            self.game_over(True)
        elif state == 'LOSE':
            self.game_over(False)
        elif state == 'BLOCK AVAILABLE':
            self.add_block()

    def game_over(self, win: bool) -> None:
        '''Ends the game'''
        self.draw_win()
        if win:
            label = GAME_OVER_FONT.render(f'You won! You scored {self.score} points.', 1, BLUE)
        else:
            label = GAME_OVER_FONT.render(f'You lost! You scored {self.score} points.', 1, RED)
        self.win.blit(label, (WIDTH//2 - label.get_width()//2, 
            HEIGHT//2 - label.get_height()//2))
        pygame.display.update()
        pygame.time.delay(3000)
        self.running = False

    def draw_win(self) -> None:
        '''Draws onto the self.win'''
        self.win.fill(BLACK)
        self.draw_grid()
        self.draw_stats()

    def draw_grid(self) -> None:
        '''Draws self.matrix onto self.win'''
        pygame.draw.rect(self.win, BG_COLOR,
            (LEFT - GAP, TOP - GAP, len(self.grid[0]) * (BLOCK_WIDTH + GAP) + GAP, 
            len(self.grid) * (BLOCK_HEIGHT + GAP) + GAP))
        for row in range(len(self.grid)):
            for col in range(len(self.grid[row])):
                x = LEFT + (col * (BLOCK_WIDTH + GAP))
                y = TOP + (row * (BLOCK_HEIGHT + GAP))
                value = self.grid[row][col]
                bg_color, font_color = COLOR_MAP[value]
                color_rect = pygame.Rect(x, y, BLOCK_WIDTH, BLOCK_HEIGHT)
                pygame.draw.rect(self.win, bg_color, color_rect)

                if value != 0:
                    label = BLOCK_FONT.render(str(value), 1, font_color)
                    font_rect = label.get_rect()
                    font_rect.center = color_rect.center
                    self.win.blit(label, font_rect)

    def draw_stats(self) -> None:
        '''Draws the stats onto self.win'''
        label = TITLE_FONT.render(f'2048', 1, WHITE)
        self.win.blit(label, (150, 5))

        label = STATS_FONT.render(f'Score: {self.score}', 1, WHITE)
        self.win.blit(label, (400, 125))

    def main(self) -> None:
        '''Main function which runs the game'''
        self.init_variables()
        while self.running:
            self.draw_win()
            pygame.display.update()
            for event in pygame.event.get():
                if event.type == QUIT:
                    pygame.quit()
                    quit()
                if event.type == KEYDOWN:
                    if event.key in [K_a, K_LEFT]:
                        self.left()
                    if event.key in [K_d, K_RIGHT]:
                        self.right()
                    if event.key in [K_w, K_UP]:
                        self.up()
                    if event.key in [K_s, K_DOWN]:
                        self.down()
        self.main_menu()

    def main_menu(self) -> None:
        '''Runs a main menu'''
        self.win.fill(BLACK)
        label = MAIN_MENU_FONT.render('Press any key to start...', 1, WHITE)
        self.win.blit(label, (WIDTH//2 - label.get_width()//2, 
            HEIGHT//2 - label.get_height()//2))
        pygame.display.update()
        running = True
        while running:
            for event in pygame.event.get():
                if event.type == QUIT:
                    self.exit()
                if event.type in [KEYDOWN, MOUSEBUTTONDOWN]:
                    running = False
        self.main()

main.py

'''2048 implemented in Python Pygame'''

__author__ = 'Random Coder 59'
__version__ = '1.0.1'
__email__ = '[email protected]'

from constants import *
from app import App_2048

import pygame
from pygame.locals import *
from random import choice

pygame.init()
pygame.font.init()

if __name__ == '__main__':
    app = App_2048()
    app.main_menu()

Thank you!

\$\endgroup\$

2 Answers 2

3
\$\begingroup\$

play screen

I've been playing this for ?? hours without loss, so I'm going to call it here; that's proof of two things:

  • it's fun and addictive, and
  • this specific variant isn't all that challenging.

In your user interface, consider offering a variant on your block instantiation logic: if there are no summable blocks and no movable blocks, then instead of instantiating a new block on a random tile of the source side, refuse to do the move; for example this board:

2...
4...
8...
2...

would not be permitted a left-swipe.

This is a more challenging mode that forces more difficult decisions. An even more challenging mode, close to the mobile game Threes, is to start increasing the source block value above two at random based on the current score.

In your code, the only thing that stands out so far is the fonts:

MAIN_MENU_FONT = pygame.font.SysFont('Tahoma', 45)
# ...

where you can somewhat abbreviate this by using a partial:

FONT = partial(pygame.font.SysFont, 'Tahoma')
MAIN_MENU_FONT = FONT(45)
# ...
\$\endgroup\$
2
  • \$\begingroup\$ > if there are no summable blocks and no movable blocks. Isn't that a lose? Could you elaborate please? \$\endgroup\$ Sep 20, 2021 at 5:26
  • \$\begingroup\$ @Random_Pythoneer59 I showed an example. It wouldn't be a loss; the set of possible moves would be constrained \$\endgroup\$
    – Reinderien
    Sep 21, 2021 at 2:09
4
\$\begingroup\$

For code readability, I'd recommend renaming the methods (e.g. rot90) to something more verbose, e.g. rotate_90_degrees. It doesn't cost you much and reads a lot more professional.

Secondly, I'd change the names of the methods game_over and game_state to something that starts with a verb, like you have all your other methods.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.