[major] refactor(game): add abstract classes

I don't know anymore where and what changed
This commit is contained in:
Kristofers Solo 2024-01-02 22:59:30 +02:00
parent c03be8f3cf
commit 624401d27b
22 changed files with 404 additions and 256 deletions

View File

@ -2,7 +2,7 @@
from loguru import logger from loguru import logger
from py2048.game import Game from py2048 import Game
@logger.catch @logger.catch

View File

@ -1,5 +1,4 @@
from .color import ColorScheme
from .config import Config from .config import Config
from .utils import BASE_PATH, Direction from .game import Game
__all__ = ["Direction", "ColorScheme", "Config", "BASE_PATH"] __all__ = ["Config", "Game"]

View File

@ -1,27 +1,12 @@
from .color import ColorScheme from .utils import Board, ColorScheme, Font, Header, Position, Screen, Size, Tile
class Config: class Config:
FONT_FAMILY = "Roboto" FONT = Font()
FONT_SIZE = 32
COLORSCHEME = ColorScheme.ORIGINAL.value COLORSCHEME = ColorScheme.ORIGINAL.value
TILE_SIZE = 75 TILE = Tile()
TILE_BORDER_WIDTH = TILE_SIZE // 20 BOARD = Board()
TILE_BORDER_RADIUS = TILE_SIZE // 10 HEADER = Header()
INITIAL_TILE_COUNT = 2 SCREEN = Screen()
TILE_VALUE_PROBABILITY = 0.9
BOARD_SIZE = 4
BOARD_WIDTH = BOARD_SIZE * TILE_SIZE
BOARD_HEIGHT = BOARD_SIZE * TILE_SIZE
HEADER_WIDTH = BOARD_WIDTH + TILE_SIZE
HEADER_HEIGHT = TILE_SIZE
BOARD_X = TILE_SIZE // 2
BOARD_Y = HEADER_HEIGHT + TILE_SIZE // 2
SCREEN_WIDTH = HEADER_WIDTH
SCREEN_HEIGHT = BOARD_HEIGHT + TILE_SIZE + HEADER_HEIGHT
SCREEN_SIZE = SCREEN_WIDTH, SCREEN_HEIGHT

View File

@ -6,21 +6,20 @@ from loguru import logger
from .config import Config from .config import Config
from .objects import Board from .objects import Board
from .screens import Header, Menu from .screens import Header, Menu
from .utils import Direction, _setup_logger from .utils import Direction, setup_logger
class Game: class Game:
def __init__(self) -> None: def __init__(self) -> None:
_setup_logger() setup_logger()
logger.info("Initializing game") logger.info("Initializing game")
pygame.init() pygame.init()
self.screen: pygame.Surface = pygame.display.set_mode(Config.SCREEN_SIZE) self.screen: pygame.Surface = pygame.display.set_mode(Config.SCREEN.size)
pygame.display.set_caption("2048") pygame.display.set_caption("2048")
self.board = Board() self.board = Board()
self.header = Header() self.header = Header()
self.menu = Menu() # self.menu = Menu()
self.score = 0
def run(self) -> None: def run(self) -> None:
"""Run the game loop.""" """Run the game loop."""
@ -36,9 +35,9 @@ class Game:
def _render(self) -> None: def _render(self) -> None:
"""Render the game.""" """Render the game."""
self.screen.fill(Config.COLORSCHEME.BG) self.screen.fill(Config.COLORSCHEME.BG)
# self.board.draw(self.screen) self.board.draw(self.screen)
# self.header.draw(self.screen, self.score) self.header.draw(self.screen, 0)
self.menu.draw(self.screen) # self.menu.draw(self.screen)
pygame.display.flip() pygame.display.flip()
def _hande_events(self) -> None: def _hande_events(self) -> None:
@ -57,19 +56,19 @@ class Game:
self.move_down() self.move_down()
elif event.key == pygame.K_q: elif event.key == pygame.K_q:
self.exit() self.exit()
self.menu._handle_events(event) # self.menu._handle_events(event)
def move_up(self) -> None: def move_up(self) -> None:
self.score += self.board.move(Direction.UP) self.board.move(Direction.UP)
def move_down(self) -> None: def move_down(self) -> None:
self.score += self.board.move(Direction.DOWN) self.board.move(Direction.DOWN)
def move_left(self) -> None: def move_left(self) -> None:
self.score += self.board.move(Direction.LEFT) self.board.move(Direction.LEFT)
def move_right(self) -> None: def move_right(self) -> None:
self.score += self.board.move(Direction.RIGHT) self.board.move(Direction.RIGHT)
def exit(self) -> None: def exit(self) -> None:
"""Exit the game.""" """Exit the game."""

View File

@ -1,7 +1,6 @@
from .board import Board from .board import Board
from .button import Button from .button import Button
from .label import Label from .label import Label
from .sprite import Sprite
from .tile import Tile from .tile import Tile
__all__ = ["Board", "Button", "Sprite", "Tile", "Label"] __all__ = ["Board", "Button", "Label", "Tile"]

View File

@ -0,0 +1,5 @@
from .clickable_ui_element import ClickableUIElement
from .movable_ui_element import MovableUIElement
from .ui_element import UIElement
__all__ = ["ClickableUIElement", "MovableUIElement", "UIElement"]

View File

@ -0,0 +1,28 @@
from abc import ABC, ABCMeta, abstractmethod
from typing import Callable, Optional
import pygame
class ClickableUIElement(ABC, metaclass=ABCMeta):
def __init__(
self,
/,
hover_color: str,
action: Optional[Callable[[], None]] = None,
) -> None:
self.action = action
self.hover_color = hover_color
self.is_hovered = False
@abstractmethod
def on_click(self) -> None:
"""Handle the click event."""
@abstractmethod
def on_hover(self) -> None:
"""Handle the hover event."""
@abstractmethod
def _draw_hover_background(self, surface: pygame.Surface) -> None:
"""Draw the hover rectangle."""

View File

@ -0,0 +1,24 @@
from abc import ABC, ABCMeta, abstractmethod
import pygame
from py2048.utils import Direction, Position
class MovableUIElement(ABC, metaclass=ABCMeta):
@abstractmethod
def move(self, direction: Direction) -> None:
"""Move the element in the given direction."""
@abstractmethod
def update(self) -> None:
"""Update the element."""
@abstractmethod
def __hash__(self) -> int:
"""Return a hash of the sprite."""
@property
@abstractmethod
def pos(self) -> Position:
"""Return the position of the element."""

View File

@ -0,0 +1,49 @@
from abc import ABC, ABCMeta, abstractmethod
from typing import Optional
import pygame
from loguru import logger
from py2048 import Config
from py2048.utils import Position, Size
class UIElement(ABC, metaclass=ABCMeta):
def __init__(
self,
/,
*,
position: Position,
bg_color: str,
font_color: str,
size: Size = Size(50, 50),
text: str = "",
border_radius: int = 0,
border_width: int = 0,
):
super().__init__()
self.text = text
self.size = size
self.bg_color = bg_color
self.font_color = font_color
self.border_radius = border_radius
self.border_width = border_width
self.position = position
self.x, self.y = self.position
self.font = pygame.font.SysFont(Config.FONT.family, Config.FONT.size)
@abstractmethod
def draw(self, surface: pygame.Surface) -> None:
"""Draw the element on the given surface."""
@abstractmethod
def _draw_background(self, surface: pygame.Surface) -> None:
"""Draw a background for the given surface."""
@abstractmethod
def _draw_text(self) -> None:
"""Draw the text of the element."""
@abstractmethod
def _create_surface(self) -> pygame.Surface:
"""Create a surface for the element."""

View File

@ -3,7 +3,8 @@ import random
import pygame import pygame
from loguru import logger from loguru import logger
from py2048 import Config, Direction from py2048 import Config
from py2048.utils import Direction, Position
from .tile import Tile from .tile import Tile
@ -11,9 +12,9 @@ from .tile import Tile
class Board(pygame.sprite.Group): class Board(pygame.sprite.Group):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.rect = pygame.Rect(0, 0, Config.BOARD_WIDTH, Config.BOARD_HEIGHT) self.rect = pygame.Rect(0, 0, *Config.BOARD.size)
self.rect.x = Config.BOARD_X self.score: int = 0
self.rect.y = Config.BOARD_Y self.rect.x, self.rect.y = Config.BOARD.pos
self.initiate_game() self.initiate_game()
def initiate_game(self) -> None: def initiate_game(self) -> None:
@ -22,7 +23,6 @@ class Board(pygame.sprite.Group):
def draw(self, surface: pygame.Surface) -> None: def draw(self, surface: pygame.Surface) -> None:
"""Draw the board.""" """Draw the board."""
tile: Tile
self._draw_background(surface) self._draw_background(surface)
super().draw(surface) super().draw(surface)
@ -33,17 +33,17 @@ class Board(pygame.sprite.Group):
surface, surface,
Config.COLORSCHEME.BOARD_BG, Config.COLORSCHEME.BOARD_BG,
self.rect, self.rect,
border_radius=Config.TILE_BORDER_RADIUS, border_radius=Config.TILE.border.radius,
) # background ) # background
pygame.draw.rect( pygame.draw.rect(
surface, surface,
Config.COLORSCHEME.BOARD_BG, Config.COLORSCHEME.BOARD_BG,
self.rect, self.rect,
width=Config.TILE_BORDER_WIDTH, width=Config.TILE.border.width,
border_radius=Config.TILE_BORDER_RADIUS, border_radius=Config.TILE.border.radius,
) # border ) # border
def move(self, direction: Direction) -> int: def move(self, direction: Direction) -> None:
"""Move the tiles in the specified direction.""" """Move the tiles in the specified direction."""
score = 0 score = 0
tiles = self.sprites() tiles = self.sprites()
@ -60,23 +60,22 @@ class Board(pygame.sprite.Group):
tiles.sort(key=lambda tile: tile.rect.x, reverse=True) tiles.sort(key=lambda tile: tile.rect.x, reverse=True)
for tile in tiles: for tile in tiles:
score += tile.move(direction) tile.move(direction)
self.score += tile.value
if not self._is_full(): if not self._is_full():
self.generate_random_tile() self.generate_random_tile()
return score
def generate_initial_tiles(self) -> None: def generate_initial_tiles(self) -> None:
"""Generate the initial tiles.""" """Generate the initial tiles."""
self.generate_tile(Config.INITIAL_TILE_COUNT) self.generate_tile(Config.TILE.initial_count)
def generate_tile(self, amount: int = 1, *pos: tuple[int, int]) -> None: def generate_tile(self, amount: int = 1, *pos: Position) -> None:
"""Generate `amount` number of tiles or at the specified positions.""" """Generate `amount` number of tiles or at the specified positions."""
if pos: if pos:
for coords in pos: for coords in pos:
x, y = coords[0] * Config.TILE_SIZE, coords[1] * Config.TILE_SIZE x, y = coords.x * Config.TILE.size, coords.y * Config.TILE.size
self.add(Tile(x, y, self)) self.add(Tile(Position(x, y), self))
return return
for _ in range(amount): for _ in range(amount):
@ -86,9 +85,9 @@ class Board(pygame.sprite.Group):
"""Generate a tile with random coordinates aligned with the grid.""" """Generate a tile with random coordinates aligned with the grid."""
while True: while True:
# Generate random coordinates aligned with the grid # Generate random coordinates aligned with the grid
x = random.randint(0, 3) * Config.TILE_SIZE + Config.BOARD_X x = random.randint(0, 3) * Config.TILE.size + Config.BOARD.pos.x
y = random.randint(0, 3) * Config.TILE_SIZE + Config.BOARD_Y y = random.randint(0, 3) * Config.TILE.size + Config.BOARD.pos.y
tile = Tile(x, y, self) tile = Tile(Position(x, y), self)
colliding_tiles = pygame.sprite.spritecollide( colliding_tiles = pygame.sprite.spritecollide(
tile, self, False tile, self, False
@ -100,7 +99,7 @@ class Board(pygame.sprite.Group):
def _is_full(self) -> bool: def _is_full(self) -> bool:
"""Check if the board is full.""" """Check if the board is full."""
return len(self.sprites()) == Config.BOARD_SIZE**2 return len(self.sprites()) == Config.BOARD.len**2
def _can_move(self) -> bool: def _can_move(self) -> bool:
"""Check if any movement is possible on the board.""" """Check if any movement is possible on the board."""

View File

@ -1,47 +1,54 @@
import sys import sys
from typing import Callable, Optional
import pygame import pygame
from attrs import define, field from attrs import define, field
from py2048.color import ColorScheme from py2048 import Config
from py2048.config import Config from py2048.utils import Direction, Position
from .abc import ClickableUIElement, UIElement
@define class Button(UIElement, ClickableUIElement):
class Button(pygame.sprite.Sprite): def __init__(
def __init__(self, text: str, x: int, y: int, group: pygame.sprite.Group): self,
super().__init__() /,
self.text = text *,
self.image = self._create_button_surface() hover_color: str,
action: Optional[Callable[[], None]] = None,
**kwargs,
):
super().__init__(hover_color, action)
Static.__init__(self, **kwargs)
# text: str = field(kw_only=True) def on_click(self) -> None:
# font_family: str = field(kw_only=True) pass
# font_size: int = field(kw_only=True)
# font_color: ColorScheme = field(kw_only=True)
# position: tuple[int, int] = field(kw_only=True)
# width: int = field(kw_only=True)
# height: int = field(kw_only=True)
# action = field(kw_only=True)
# bg_color: ColorScheme = field(kw_only=True)
# hover_color: ColorScheme = field(kw_only=True)
# font: pygame.Font = field(init=False)
# rendered_text: pygame.Surface = field(init=False)
# rect: pygame.Rect = field(init=False)
# is_hovered: bool = field(init=False, default=False)
def __attrs_post_init__(self) -> None: def on_hover(self) -> None:
"""Initialize the button.""" pass
self.font = pygame.font.SysFont(self.font_family, self.font_size)
self._draw_text() def _draw_background(self, surface: pygame.Surface) -> None:
"""Draw a rectangle with borders on the given surface."""
pygame.draw.rect(
surface,
self.bg_color,
(*self.position, *self.size),
border_radius=self.border_radius,
)
def _draw_text(self) -> None: def _draw_text(self) -> None:
"""Draw the text on the button.""" """Draw the text of the element."""
self.rendered_text = self.font.render( self.rendered_text = self.font.render(
self.text, True, self.font_color, self.bg_color self.text, True, self.font_color, self.bg_color
) )
self.rect = pygame.Rect( self.rect = self.rendered_text.get_rect(topleft=self.position)
self.position[0], self.position[1], self.width, self.height
) def _create_surface(self) -> pygame.Surface:
"""Create a surface for the element."""
sprite_surface = pygame.Surface(self.size, pygame.SRCALPHA)
self._draw_background(sprite_surface)
return sprite_surface
def check_hover(self, mouse_pos: tuple[int, int]) -> None: def check_hover(self, mouse_pos: tuple[int, int]) -> None:
"""Check if the mouse is hovering over the button.""" """Check if the mouse is hovering over the button."""
@ -54,18 +61,18 @@ class Button(pygame.sprite.Sprite):
def draw(self, surface: pygame.Surface) -> None: def draw(self, surface: pygame.Surface) -> None:
"""Draw the button on the given surface.""" """Draw the button on the given surface."""
if self.is_hovered:
self._draw_rect(surface, self.hover_color)
else:
self._draw_rect(surface, self.bg_color)
surface.blit(self.rendered_text, self.position) self._draw_hover_background(
surface
) if self.is_hovered else self._draw_background(surface)
def _draw_rect(self, surface: pygame.Surface, color: ColorScheme) -> None: surface.blit(self.rendered_text, self.rect.topleft)
"""Draw the button rectangle."""
def _draw_hover_background(self, surface: pygame.Surface) -> None:
"""Draw the hover rectangle."""
pygame.draw.rect( pygame.draw.rect(
surface, surface,
self.bg_color, self.hover_color,
self.rect, self.rect,
border_radius=Config.TILE_BORDER_RADIUS, border_radius=self.border_radius,
) )

View File

@ -1,16 +1,16 @@
import pygame import pygame
from attrs import define, field from attrs import define, field
from py2048.color import ColorScheme
from py2048.config import Config from py2048 import Config
@define @define
class Label: class Label:
text: str text: str
position: tuple[int, int] position: tuple[int, int]
bg_color: ColorScheme bg_color: str
font_family: str font_family: str
font_color: ColorScheme font_color: str
font_size: int font_size: int
font: pygame.Font = field(init=False) font: pygame.Font = field(init=False)
rendered_text: pygame.Surface = field(init=False) rendered_text: pygame.Surface = field(init=False)
@ -20,15 +20,15 @@ class Label:
self.font = pygame.font.SysFont(self.font_family, self.font_size) self.font = pygame.font.SysFont(self.font_family, self.font_size)
self._draw_text() self._draw_text()
def _draw_text(self) -> None: def draw(self, surface: pygame.Surface) -> None:
self.rendered_text = self.font.render( surface.blit(self.rendered_text, self.position)
self.text, True, self.font_color, self.bg_color
)
self.rect = self.rendered_text.get_rect(topleft=self.position)
def update(self, new_text: str) -> None: def update(self, new_text: str) -> None:
self.text = new_text self.text = new_text
self._draw_text() self._draw_text()
def draw(self, surface: pygame.Surface) -> None: def _draw_text(self) -> None:
surface.blit(self.rendered_text, self.position) self.rendered_text = self.font.render(
self.text, True, self.font_color, self.bg_color
)
self.rect = self.rendered_text.get_rect(topleft=self.position)

View File

@ -1,39 +0,0 @@
from abc import ABC, ABCMeta, abstractmethod
import pygame
from py2048 import Config, Direction
class Sprite(ABC, pygame.sprite.Sprite, metaclass=ABCMeta):
def __init__(self, x: int, y: int, group: pygame.sprite.Group):
super().__init__()
self.image = self._create_surface()
self.rect = self.image.get_rect()
self.rect.topleft = x, y
self.font = pygame.font.SysFont(Config.FONT_FAMILY, Config.FONT_SIZE)
self.group = group
self.update()
@abstractmethod
def draw(self, surface: pygame.Surface) -> None:
"""Draw the sprite on the given surface."""
@abstractmethod
def update(self) -> None:
"""Update the sprite."""
@abstractmethod
def move(self, direction: Direction) -> None:
"""Move the tile by `dx` and `dy`."""
@abstractmethod
def _draw_background(self, surface: pygame.Surface) -> None:
"""Draw a rounded rectangle with borders on the given surface."""
@abstractmethod
def _create_surface(self) -> pygame.Surface:
"""Create a surface for the sprite."""
sprite_surface = pygame.Surface((100, 100), pygame.SRCALPHA)
self._draw_background(sprite_surface)
return sprite_surface

View File

@ -2,102 +2,123 @@ import random
from typing import Union from typing import Union
import pygame import pygame
from loguru import logger
from py2048 import ColorScheme, Config, Direction from py2048 import Config
from py2048.utils import ColorScheme, Direction, Position, Size
from .sprite import Sprite from .abc import MovableUIElement, UIElement
def _grid_pos(pos: int) -> int: def _grid_pos(pos: int) -> int:
"""Return the position in the grid.""" """Return the position in the grid."""
return pos // Config.TILE_SIZE + 1 return pos // Config.TILE.size + 1
class Tile(Sprite): class Tile(UIElement, MovableUIElement, pygame.sprite.Sprite):
def __init__( def __init__(
self, x: int, y: int, group: pygame.sprite.Group, value: int | None = 2 self,
position: Position,
group: pygame.sprite.Group,
): ):
super().__init__(x, y, group) pygame.sprite.Sprite.__init__(self)
self.value = 2 if random.random() <= Config.TILE.value_probability else 4
self.value: int = ( super().__init__(
value position=position,
if value is not None text=f"{self.value}",
else 2 bg_color=Config.COLORSCHEME.TILE_0,
if random.random() <= Config.TILE_VALUE_PROBABILITY font_color=Config.COLORSCHEME.DARK_TEXT,
else 4 size=Size(Config.TILE.size, Config.TILE.size),
border_radius=Config.TILE.border.radius,
border_width=Config.TILE.border.width,
) )
self.score: int = 0
self.group = group
self.image = self._create_surface() self.image = self._create_surface()
self.rect = self.image.get_rect() self.rect = self.image.get_rect()
self.rect.topleft = x, y self.rect.topleft = self.position
self.font = pygame.font.SysFont(Config.FONT_FAMILY, Config.FONT_SIZE)
self.group = group
self.update() self.update()
def draw(self, surface: pygame.Surface) -> None:
"""Draw the value of the tile."""
self._draw_background(surface)
self._draw_text()
def update(self) -> None:
"""Update the sprite."""
self._draw_background(self.image)
self.text = f"{self.value}"
self._draw_text()
self.image.blit(self.image, (0, 0))
def _draw_background(self, surface: pygame.Surface) -> None: def _draw_background(self, surface: pygame.Surface) -> None:
"""Draw a rounded rectangle with borders on the given surface.""" """Draw a rounded rectangle with borders on the given surface."""
rect = (0, 0, Config.TILE_SIZE, Config.TILE_SIZE) rect = (0, 0, *self.size)
pygame.draw.rect( pygame.draw.rect(
surface, self._get_color(), rect, border_radius=Config.TILE_BORDER_RADIUS surface, self._get_color(), rect, border_radius=Config.TILE.border.radius
) # background ) # background
pygame.draw.rect( pygame.draw.rect(
surface, surface,
(0, 0, 0, 0), (0, 0, 0, 0),
rect, rect,
border_radius=Config.TILE_BORDER_RADIUS, border_radius=Config.TILE.border.radius,
width=Config.TILE_BORDER_WIDTH, width=Config.TILE.border.width,
) # border ) # border
def _create_surface(self) -> pygame.Surface: def _draw_text(self) -> None:
"""Create a surface for the tile.""" """Draw the text of the sprite."""
sprite_surface = pygame.Surface( self.rendered_text = self.font.render(self.text, True, self.font_color)
(Config.TILE_SIZE, Config.TILE_SIZE), pygame.SRCALPHA self.image.blit(
self.rendered_text,
self.rendered_text.get_rect(center=self.image.get_rect().center),
) )
def _create_surface(self) -> pygame.Surface:
"""Create a surface for the sprite."""
sprite_surface = pygame.Surface(self.size, pygame.SRCALPHA)
self._draw_background(sprite_surface) self._draw_background(sprite_surface)
return sprite_surface return sprite_surface
def draw(self, surface: pygame.Surface) -> None: def move(self, direction: Direction) -> None:
"""Draw the value of the tile.""" """
text = self.font.render(str(self.value), True, Config.COLORSCHEME.DARK_TEXT) Move the tile by `dx` and `dy`.
sprite_surface = self._create_surface() If the tile collides with another tile, it will merge with it if possible.
Before moving, reset the score of the tile.
sprite_center: tuple[int, int] = (Config.TILE_SIZE // 2, Config.TILE_SIZE // 2) """
text_rect: pygame.Rect = text.get_rect(center=self.image.get_rect().center)
sprite_surface.blit(text, text_rect)
self.image.blit(sprite_surface, (0, 0))
def move(self, direction: Direction) -> int:
"""Move the tile by `dx` and `dy`."""
score = 0
while True: while True:
new_x, new_y = self._calc_new_pos(direction) new_x, new_y = self._calc_new_pos(direction)
if self._is_out_if_bounds(new_x, new_y): if self._is_out_if_bounds(new_x, new_y):
return score return
if self._has_collision(new_x, new_y): if self._has_collision(new_x, new_y):
collided_tile = self._get_collided_tile(new_x, new_y) collided_tile = self._get_collided_tile(new_x, new_y)
if collided_tile and self._can_merge(collided_tile): if collided_tile and self._can_merge(collided_tile):
score += self._merge(collided_tile) self._merge(collided_tile)
else: else:
return score return
self.group.remove(self) self.group.remove(self)
self.rect.topleft = new_x, new_y self.rect.topleft = new_x, new_y
self.group.add(self) self.group.add(self)
def get_score(self) -> int:
"""Return the score of the tile."""
return self.score
def _calc_new_pos(self, direction: Direction) -> tuple[int, int]: def _calc_new_pos(self, direction: Direction) -> tuple[int, int]:
"""Calculate the new position of the tile.""" """Calculate the new position of the tile."""
dx, dy = direction * Config.TILE_SIZE dx, dy = direction * Config.TILE.size
return self.rect.x + dx, self.rect.y + dy return self.rect.x + dx, self.rect.y + dy
def _is_out_if_bounds(self, x: int, y: int) -> bool: def _is_out_if_bounds(self, x: int, y: int) -> bool:
"""Return whether the tile is out of bounds.""" """Return whether the tile is out of bounds."""
board_left = Config.BOARD_X board_left = Config.BOARD.pos.x
board_right = Config.BOARD_X + Config.BOARD_WIDTH - Config.TILE_SIZE board_right = Config.BOARD.pos.x + Config.BOARD.size.width - Config.TILE.size
board_top = Config.BOARD_Y board_top = Config.BOARD.pos.y
board_bottom = Config.BOARD_Y + Config.BOARD_HEIGHT - Config.TILE_SIZE board_bottom = Config.BOARD.pos.y + Config.BOARD.size.height - Config.TILE.size
return not (board_left <= x <= board_right and board_top <= y <= board_bottom) return not (board_left <= x <= board_right and board_top <= y <= board_bottom)
def _has_collision(self, x: int, y: int) -> bool: def _has_collision(self, x: int, y: int) -> bool:
@ -120,18 +141,13 @@ class Tile(Sprite):
"""Check if the tile can merge with another tile.""" """Check if the tile can merge with another tile."""
return self.value == other.value return self.value == other.value
def _merge(self, other: "Tile") -> int: def _merge(self, other: "Tile") -> None:
"""Merge the tile with another tile.""" """Merge the tile with another tile."""
self.group.remove(other) self.group.remove(other)
self.group.remove(self) self.group.remove(self)
self.value += other.value self.value += other.value
self.update() self.update()
self.group.add(self) self.group.add(self)
return self.value
def update(self) -> None:
"""Update the sprite."""
self.draw()
def can_move(self) -> bool: def can_move(self) -> bool:
"""Check if the tile can move""" """Check if the tile can move"""
@ -145,8 +161,8 @@ class Tile(Sprite):
return True return True
return False return False
def _get_color(self) -> ColorScheme: def _get_color(self) -> str:
"""Change the color of the tile based on its value""" """Change the color of the tile based on its value."""
color_map = { color_map = {
2: Config.COLORSCHEME.TILE_2, 2: Config.COLORSCHEME.TILE_2,
4: Config.COLORSCHEME.TILE_4, 4: Config.COLORSCHEME.TILE_4,
@ -175,6 +191,6 @@ class Tile(Sprite):
return hash((self.rect.x, self.rect.y, self.value)) return hash((self.rect.x, self.rect.y, self.value))
@property @property
def pos(self) -> tuple[int, int]: def pos(self) -> Position:
"""Return the position of the tile.""" """Return the position of the tile."""
return _grid_pos(self.rect.x), _grid_pos(self.rect.y) return Position(_grid_pos(self.rect.x), _grid_pos(self.rect.y))

View File

@ -1,4 +1,4 @@
from .header import Header from .header import Header
from .menu import Menu from .menu import Menu
__all__ = ["Menu", "Header"] __all__ = ["Header", "Menu"]

View File

@ -1,19 +1,25 @@
import pygame import pygame
from py2048 import Config from py2048 import Config
from py2048.objects import Label from py2048.objects import Label
from py2048.utils import Position
class Header: class Header:
def __init__(self) -> None: def __init__(self) -> None:
self.rect = pygame.Rect(0, 0, Config.HEADER_WIDTH, Config.HEADER_HEIGHT) self.rect = pygame.Rect(0, 0, *Config.HEADER.size)
def draw(self, screen: pygame.Surface, score: int) -> None: def draw(self, screen: pygame.Surface, score: int) -> None:
"""Draw the header.""" """Draw the header."""
score = Label( self.score = Label(
text=f"{score}", text=f"{score}",
position=(10, 10), position=Position(10, 10),
bg_color=Config.COLORSCHEME.BOARD_BG, bg_color=Config.COLORSCHEME.BOARD_BG,
font_family=Config.FONT_FAMILY, font_family=Config.FONT.family,
font_color=Config.COLORSCHEME.DARK_TEXT, font_color=Config.COLORSCHEME.DARK_TEXT,
font_size=Config.FONT_SIZE, font_size=Config.FONT.size,
).draw(screen) ).draw(screen)
def update(self, score: int) -> None:
"""Update the header."""
self.Label.text = f"{score}"

View File

@ -3,6 +3,7 @@ from loguru import logger
from py2048 import Config from py2048 import Config
from py2048.objects import Button from py2048.objects import Button
from py2048.utils import Position
class Menu: class Menu:
@ -14,22 +15,21 @@ class Menu:
"Exit": self.exit, "Exit": self.exit,
} }
buttons_width, button_height = 120, 50 buttons_width, button_height = 120, 50
self.buttons = [ self.buttons = [
Button( Button(
text=text, position=Position(
font_family=Config.FONT_FAMILY, Config.SCREEN.size.width / 2 - button_height // 2,
font_size=Config.FONT_SIZE, Config.SCREEN.size.height / len(buttons_data) * index
font_color=Config.COLORSCHEME.LIGHT_TEXT, - button_height // 2,
position=(
Config.SCREEN_WIDTH / 2 - 50,
Config.SCREEN_HEIGHT / (len(buttons_data) + 1) * index
- button_height,
), ),
width=buttons_width,
height=button_height,
action=action,
bg_color=Config.COLORSCHEME.BOARD_BG, bg_color=Config.COLORSCHEME.BOARD_BG,
font_color=Config.COLORSCHEME.LIGHT_TEXT,
hover_color=Config.COLORSCHEME.TILE_0, hover_color=Config.COLORSCHEME.TILE_0,
size=(buttons_width, button_height),
text=text,
border_radius=Config.TILE.border.radius,
action=action,
) )
for index, (text, action) in enumerate(buttons_data.items(), start=1) for index, (text, action) in enumerate(buttons_data.items(), start=1)
] ]

View File

@ -1,33 +0,0 @@
from enum import Enum
from pathlib import Path
from loguru import logger
from .config import Config
BASE_PATH = Path(__file__).resolve().parent.parent.parent
def _setup_logger() -> None:
logger.add(
BASE_PATH.joinpath(".logs", "game.log"),
format="{time} | {level} | {message}",
level="DEBUG" if BASE_PATH.joinpath("debug").exists() else "INFO",
rotation="1 MB",
compression="zip",
)
class Direction(Enum):
UP = (0, -1)
DOWN = (0, 1)
LEFT = (-1, 0)
RIGHT = (1, 0)
def __mul__(self, num: int) -> tuple[int, int]:
"""Multiply the direction by a constant."""
return self.value[0] * num, self.value[1] * num
def __imul__(self, num: int) -> tuple[int, int]:
"""Multiply the direction by a constant."""
return self.value[0] * num, self.value[1] * num

View File

@ -0,0 +1,34 @@
from pathlib import Path
from loguru import logger
from .collections import Board, Font, Header, Position, Screen, Size, Tile
from .color import ColorScheme
from .enums import Direction
BASE_PATH = Path(__file__).resolve().parent.parent.parent.parent
def setup_logger() -> None:
logger.add(
BASE_PATH.joinpath(".logs", "game.log"),
format="{time} | {level} | {message}",
level="DEBUG" if BASE_PATH.joinpath("debug").exists() else "INFO",
rotation="1 MB",
compression="zip",
)
__all__ = [
"BASE_PATH",
"Board",
"ColorScheme",
"Direction",
"Font",
"Position",
"Size",
"Tile",
"setup_logger",
"Header",
"Screen",
]

View File

@ -0,0 +1,52 @@
from typing import NamedTuple
from attr import Factory, define, field
class Position(NamedTuple):
x: int
y: int
class Size(NamedTuple):
width: int
height: int
@define
class Font:
family: str = "Roboto"
size: int = 32
@define
class Border:
width: int
radius: int
@define
class Tile:
size: int = 75
border: Border = Border(size // 20, size // 10)
initial_count: int = 2
value_probability: float = 0.9
@define
class Board:
len: int = 4
size: Size = Size(len * Tile().size, len * Tile().size)
pos: Position = Position(Tile().size // 2, Tile().size + Tile().size // 2)
@define
class Header:
size: Size = Size(Board().size.width + Tile().size, Tile().size)
@define
class Screen:
size: Size = Size(
Header().size.width, Board().size.height + Tile().size + Header().size.height
)

18
src/py2048/utils/enums.py Normal file
View File

@ -0,0 +1,18 @@
from enum import Enum
from .collections import Position
class Direction(Enum):
UP = Position(0, -1)
DOWN = Position(0, 1)
LEFT = Position(-1, 0)
RIGHT = Position(1, 0)
def __mul__(self, num: int) -> Position:
"""Multiply the direction by a constant."""
return Position(self.value.x * num, self.value.y * num)
def __imul__(self, num: int) -> tuple[int, int]:
"""Multiply the direction by a constant."""
return Position(self.value.x * num, self.value.y * num)