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!
2 Answers 2
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)
# ...
-
\$\begingroup\$ > if there are no summable blocks and no movable blocks. Isn't that a lose? Could you elaborate please? \$\endgroup\$Random_Pythoneer59– Random_Pythoneer592021年09月20日 05:26:40 +00:00Commented 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– Reinderien2021年09月21日 02:09:07 +00:00Commented Sep 21, 2021 at 2:09
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.