6
\$\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!

Ben A
10.7k5 gold badges37 silver badges101 bronze badges
asked Sep 19, 2021 at 19:03
\$\endgroup\$

2 Answers 2

4
\$\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)
# ...
answered Sep 20, 2021 at 0:42
\$\endgroup\$
2
  • \$\begingroup\$ > if there are no summable blocks and no movable blocks. Isn't that a lose? Could you elaborate please? \$\endgroup\$ Commented 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\$ Commented Sep 21, 2021 at 2:09
5
\$\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.

answered Sep 20, 2021 at 23:21
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.