feat(game): add main menu

This commit is contained in:
Kristofers Solo 2024-01-06 05:07:46 +02:00
parent 0afe1ed3cb
commit 7d5bf8e658
16 changed files with 285 additions and 91 deletions

11
main.py
View File

@ -61,16 +61,17 @@ def main(args: argparse.ArgumentParser) -> None:
elif args.verbose: elif args.verbose:
CONFIG.log_level = "info" CONFIG.log_level = "info"
import ai # import ai
import game import game
if args.train is not None: if args.train is not None:
ai.log.debug("Training the AI") # ai.log.debug("Training the AI")
# ai.train(*args.train) # # ai.train(*args.train)
game.Game(GameMode.AI_TRAINING).run() # game.Menu(GameMode.AI_TRAINING).run()
pass
else: else:
game.log.debug("Running the game") game.log.debug("Running the game")
game.Game(GameMode.PLAYER).run() game.Main(GameMode.PLAYER).run()
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,5 +1,5 @@
from .log import log from .log import log
from .screens import Game, Menu, Tetris from .screens import Game, Main, Tetris
__all__ = [ __all__ = [
"log", "log",

View File

@ -1,7 +1,7 @@
from .game import Game from .game import Game
from .menu import Menu from .main import Main
from .preview import Preview from .preview import Preview
from .score import Score from .score import Score
from .tetris import Tetris from .tetris import Tetris
__all__ = ["Tetris", "Game", "Preview", "Score", "Menu"] __all__ = ["Tetris", "Game", "Preview", "Score", "Main"]

View File

@ -1,7 +1,5 @@
from abc import ABC, ABCMeta, abstractmethod from abc import ABC, ABCMeta, abstractmethod
import pygame
class BaseScreen(ABC, metaclass=ABCMeta): class BaseScreen(ABC, metaclass=ABCMeta):
"""Base screen class.""" """Base screen class."""
@ -37,7 +35,7 @@ class SceenElement(ABC, metaclass=ABCMeta):
"""Initialize the rectangle.""" """Initialize the rectangle."""
@abstractmethod @abstractmethod
def _update_diplaysurface(self) -> None: def _update_display_surface(self) -> None:
"""Update the display surface.""" """Update the display surface."""

View File

@ -0,0 +1,20 @@
from abc import ABC, ABCMeta, abstractmethod
from typing import Callable, Optional
import pygame
class Button(ABC, metaclass=ABCMeta):
"""Base button class."""
def __init__(self, text: str, action: Optional[Callable[[], None]]) -> None:
self.action = action
self.text = text
@abstractmethod
def on_click(self) -> None:
"""Handle click event."""
@abstractmethod
def on_hover(self) -> None:
"""Handle hover event."""

View File

@ -4,12 +4,12 @@ import pygame
from utils import CONFIG, Figure, GameMode from utils import CONFIG, Figure, GameMode
from game.log import log from game.log import log
from game.sprites.tetromino import Tetromino
from .base import BaseScreen from .base import BaseScreen
from .preview import Preview from .preview import Preview
from .score import Score from .score import Score
from .tetris import Tetris from .tetris import Tetris
from .tetromino import Tetromino
class Game(BaseScreen): class Game(BaseScreen):
@ -27,26 +27,20 @@ class Game(BaseScreen):
music: Pygame music that plays in the background. music: Pygame music that plays in the background.
""" """
def __init__(self, mode: GameMode) -> None: def __init__(self) -> None:
log.info("Initializing the game")
self.game_mode = mode
self._initialize_pygeme()
self._initialize_game_components() self._initialize_game_components()
self._start_background_music() self._start_background_music()
def draw(self) -> None: def draw(self) -> None:
"""Update the display.""" """Update the display."""
pygame.display.update()
def update(self) -> None:
pass
def run(self) -> None: def run(self) -> None:
"""Run the main game loop."""
while True:
self.run_game_loop()
def run_game_loop(self) -> None:
"""Run a single iteration of the game loop.""" """Run a single iteration of the game loop."""
self.draw() self.draw()
self.handle_events() self.handle_events() # FIX:
self.tetris.run() self.tetris.run()
self.score.run() self.score.run()
@ -56,33 +50,20 @@ class Game(BaseScreen):
self.draw() self.draw()
self.clock.tick(CONFIG.fps) self.clock.tick(CONFIG.fps)
def handle_events(self) -> None: def mute(self) -> None:
"""Handle Pygame events.""" """Mute the game."""
for event in pygame.event.get(): self.music.set_volume(0)
if event.type == pygame.QUIT: self.tetris.mute()
self.exit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_q:
self.exit()
def exit(self) -> None:
"""Exit the game."""
pygame.quit()
sys.exit()
def _initialize_game_components(self) -> None: def _initialize_game_components(self) -> None:
"""Initialize game-related components.""" """Initialize game-related components."""
self.clock = pygame.time.Clock()
self.next_figure: Figure = self._generate_next_figure() self.next_figure: Figure = self._generate_next_figure()
self.tetris = Tetris(self._get_next_figure, self._update_score) self.tetris = Tetris(self._get_next_figure, self._update_score)
self.score = Score() self.score = Score()
self.preview = Preview() self.preview = Preview()
def mute(self) -> None:
"""Mute the game."""
self.music.set_volume(0)
self.tetris.mute()
def _update_score(self, lines: int, score: int, level: int) -> None: def _update_score(self, lines: int, score: int, level: int) -> None:
""" """
Update the game score. Update the game score.
@ -114,14 +95,6 @@ class Game(BaseScreen):
self.next_figure = self._generate_next_figure() self.next_figure = self._generate_next_figure()
return next_figure return next_figure
def _initialize_pygeme(self) -> None:
"""Initialize Pygame and set up the display."""
pygame.init()
pygame.display.set_caption(CONFIG.window.title)
self.display_surface = pygame.display.set_mode(CONFIG.window.size)
self.display_surface.fill(CONFIG.colors.bg)
self.clock = pygame.time.Clock()
def _start_background_music(self) -> None: def _start_background_music(self) -> None:
"""Start playing background music.""" """Start playing background music."""
self.music = pygame.mixer.Sound(CONFIG.music.background) self.music = pygame.mixer.Sound(CONFIG.music.background)

116
src/game/screens/main.py Normal file
View File

@ -0,0 +1,116 @@
import sys
import pygame
from utils import CONFIG, GameMode
from game.log import log
from .base import BaseScreen, SceenElement, TextScreen
from .game import Game
from .menu_button import MenuButton
class Main(BaseScreen, SceenElement, TextScreen):
def __init__(self, mode: GameMode) -> None:
log.info("Initializing the game")
self._initialize_pygame()
self._initialize_surface()
self._initialize_rect()
self._initialize_font()
self._set_buttons()
self._initialize_increment_height()
self.game_mode = mode # TODO: use this
def draw(self) -> None:
"""Update the display."""
self._draw_background()
self._draw_text()
self._draw_border()
pygame.display.update()
def update(self) -> None:
pass
def handle_events(self) -> None:
for event in pygame.event.get():
if event.type == pygame.QUIT:
self.exit()
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_q:
self.exit()
def run(self) -> None:
while True:
self.draw()
self.handle_events()
def exit(self) -> None:
"""Exit the game."""
pygame.quit()
sys.exit()
def _set_buttons(self) -> None:
self.buttons: list[MenuButton] = [
MenuButton("Play", None),
MenuButton("AI", None),
MenuButton("Settings", None),
MenuButton("Quit", self.exit),
]
def _initialize_pygame(self) -> None:
"""Initialize Pygame and set up the display."""
pygame.init()
pygame.display.set_caption(CONFIG.window.title)
def _draw_background(self) -> None:
self.display_surface.fill(CONFIG.colors.bg)
def _draw_border(self) -> None:
pass
def _initialize_surface(self) -> None:
self.display_surface = pygame.display.set_mode(CONFIG.window.size)
def _initialize_rect(self) -> None:
"""Initialize the rectangle."""
self.rect = self.display_surface.get_rect(topright=(0, 0))
def _update_display_surface(self) -> None:
"""Do nothing. Not needed in this class."""
def _initialize_increment_height(self) -> None:
"""Initialize the increment height for positioning text elements/buttons."""
self.increment_height = (
self.display_surface.get_height() - CONFIG.window.size.height / 2
) / len(self.buttons)
def _display_text(self, text: str, pos: tuple[float, float]) -> None:
"""
Display a single text element on the score surface.
Args:
text: A tuple containing the label and value of the text element.
pos: The position (x, y) where the text should be displayed.
"""
text_surface = self.font.render(text, True, CONFIG.colors.fg)
text_rect = text_surface.get_rect(center=pos)
self.display_surface.blit(text_surface, text_rect)
def _initialize_font(self) -> None:
"""Initialize the font used to display the score."""
self.font = pygame.font.Font(CONFIG.font.family, CONFIG.font.size)
def _draw_text(self) -> None:
"""Draw the text and buttons on the surface."""
x, y = self.display_surface.get_width() / 2, 100
self._display_text("Tetris", (x, y))
for idx, button in enumerate(self.buttons):
x = self.display_surface.get_width() / 2
y = (
self.increment_height / 4
+ idx * self.increment_height
+ CONFIG.window.size.height / 4
) # TODO: tweak a bit more
button.draw(self.display_surface, (x, y))

View File

@ -1,7 +0,0 @@
from game.log import log
from .base import BaseScreen
class Menu(BaseScreen):
pass

View File

@ -0,0 +1,88 @@
from typing import Callable, Optional
import pygame
from utils import CONFIG
from .base import BaseScreen, SceenElement, TextScreen
from .button import Button
class MenuButton(Button, BaseScreen, SceenElement, TextScreen):
def __init__(self, text: str, action: Optional[Callable[[], None]]) -> None:
super().__init__(text, action)
self._initialize_surface()
self._initialize_font()
def on_click(self) -> None:
"""Handle click event."""
if self.action:
self.action()
def on_hover(self) -> None:
"""Handle hover event."""
self._draw_border()
def run(self) -> None:
"""Display the button on the game surface."""
self.draw()
def update(self) -> None:
"""Update the button."""
pass
def draw(self, surface: pygame.Surface, pos: tuple[float, float]) -> None:
"""Draw the button on the button surface."""
self._initialize_rect(pos)
self._update_display_surface()
self._draw_background()
self._draw_text()
self._draw_border()
def _initialize_surface(self) -> None:
"""Initialize the button surface."""
self.surface = pygame.Surface(CONFIG.window.button.size)
self.display_surface = pygame.display.get_surface()
def _initialize_rect(self, pos: tuple[float, float]) -> None:
"""Initialize the button rectangle."""
self.rect = self.surface.get_rect(center=pos)
def _draw_text(self) -> None:
"""Draw the text on the text surface."""
x = self.surface.get_width() / 2
y = self.surface.get_height() / 2
self._display_text(self.text, (x, y))
def _display_text(self, text: str, pos: tuple[float, float]) -> None:
"""
Display a single text element on the button surface.
Args:
text: The text to be displayed.
pos: The position (x, y) where the text should be displayed.
"""
text_surface = self.font.render(text, True, CONFIG.colors.fg_sidebar)
text_rect = text_surface.get_rect(center=pos)
self.surface.blit(text_surface, text_rect)
def _initialize_font(self) -> None:
"""Initialize the font used to display the score."""
self.font = pygame.font.Font(CONFIG.font.family, CONFIG.font.size)
def _draw_background(self) -> None:
"""Fill the background of the button."""
self.surface.fill(CONFIG.colors.bg_sidebar)
def _draw_border(self) -> None:
"""Draw the border of the button."""
pygame.draw.rect(
self.display_surface,
CONFIG.colors.border_highlight,
self.rect,
CONFIG.game.line_width * 2,
CONFIG.game.border_radius,
)
def _update_display_surface(self) -> None:
"""Update the display surface."""
self.display_surface.blit(self.surface, self.rect)

View File

@ -11,7 +11,7 @@ class Preview(BaseScreen, SceenElement):
Attributes: Attributes:
surface: Pygame surface representing the preview. surface: Pygame surface representing the preview.
rect: Pygame rectangle representing the preview. rect: Pygame rectangle representing the preview.
dispaly_surface: Pygame display surface. display_surface: Pygame display surface.
increment_height: Height of each figure in the preview. increment_height: Height of each figure in the preview.
""" """
@ -34,7 +34,7 @@ class Preview(BaseScreen, SceenElement):
def draw(self) -> None: def draw(self) -> None:
"""Draw the preview on the preview surface.""" """Draw the preview on the preview surface."""
self._update_diplaysurface() self._update_display_surface()
self._draw_background() self._draw_background()
self._draw_border() self._draw_border()
self._draw_figure() self._draw_figure()
@ -42,7 +42,7 @@ class Preview(BaseScreen, SceenElement):
def _draw_border(self) -> None: def _draw_border(self) -> None:
"""Draw the border around the preview surface.""" """Draw the border around the preview surface."""
pygame.draw.rect( pygame.draw.rect(
self.dispaly_surface, self.display_surface,
CONFIG.colors.border_highlight, CONFIG.colors.border_highlight,
self.rect, self.rect,
CONFIG.game.line_width * 2, CONFIG.game.line_width * 2,
@ -70,7 +70,7 @@ class Preview(BaseScreen, SceenElement):
def _initialize_surface(self) -> None: def _initialize_surface(self) -> None:
"""Initialize the preview surface.""" """Initialize the preview surface."""
self.surface = pygame.Surface(CONFIG.sidebar.preview) self.surface = pygame.Surface(CONFIG.sidebar.preview)
self.dispaly_surface = pygame.display.get_surface() self.display_surface = pygame.display.get_surface()
def _initialize_rect(self) -> None: def _initialize_rect(self) -> None:
"""Initialize the preview rectangle.""" """Initialize the preview rectangle."""
@ -81,6 +81,6 @@ class Preview(BaseScreen, SceenElement):
) )
) )
def _update_diplaysurface(self) -> None: def _update_display_surface(self) -> None:
"""Update the display surface.""" """Update the display surface."""
self.dispaly_surface.blit(self.surface, self.rect) self.display_surface.blit(self.surface, self.rect)

View File

@ -10,7 +10,7 @@ class Score(BaseScreen, SceenElement, TextScreen):
Attributes: Attributes:
surface: Pygame surface representing the score. surface: Pygame surface representing the score.
dispaly_surface: Pygame display surface. display_surface: Pygame display surface.
rect: Pygame rectangle representing the score. rect: Pygame rectangle representing the score.
font: Pygame font used to display the score. font: Pygame font used to display the score.
text: Text to be displayed on the score. text: Text to be displayed on the score.
@ -26,7 +26,6 @@ class Score(BaseScreen, SceenElement, TextScreen):
def run(self) -> None: def run(self) -> None:
"""Display the score on the game surface.""" """Display the score on the game surface."""
self._update_diplaysurface()
self.draw() self.draw()
def update(self, lines: int, score: int, level: int) -> None: def update(self, lines: int, score: int, level: int) -> None:
@ -46,6 +45,7 @@ class Score(BaseScreen, SceenElement, TextScreen):
def draw(self) -> None: def draw(self) -> None:
"""Draw the score on the score surface.""" """Draw the score on the score surface."""
self._update_display_surface()
self._draw_background() self._draw_background()
self._draw_text() self._draw_text()
self._draw_border() self._draw_border()
@ -62,8 +62,8 @@ class Score(BaseScreen, SceenElement, TextScreen):
Display a single text element on the score surface. Display a single text element on the score surface.
Args: Args:
text (tuple[str, int]): A tuple containing the label and value of the text element. text: A tuple containing the label and value of the text element.
pos (tuple[int, int]): The position (x, y) where the text should be displayed. pos: The position (x, y) where the text should be displayed.
""" """
text_surface = self.font.render( text_surface = self.font.render(
f"{text[0]}: {text[1]}", True, CONFIG.colors.fg_sidebar f"{text[0]}: {text[1]}", True, CONFIG.colors.fg_sidebar
@ -74,7 +74,7 @@ class Score(BaseScreen, SceenElement, TextScreen):
def _draw_border(self) -> None: def _draw_border(self) -> None:
"""Draw the border of the score surface.""" """Draw the border of the score surface."""
pygame.draw.rect( pygame.draw.rect(
self.dispaly_surface, self.display_surface,
CONFIG.colors.border_highlight, CONFIG.colors.border_highlight,
self.rect, self.rect,
CONFIG.game.line_width * 2, CONFIG.game.line_width * 2,
@ -88,12 +88,12 @@ class Score(BaseScreen, SceenElement, TextScreen):
def _initialize_surface(self) -> None: def _initialize_surface(self) -> None:
"""Initialize the score surface.""" """Initialize the score surface."""
self.surface = pygame.Surface(CONFIG.sidebar.score) self.surface = pygame.Surface(CONFIG.sidebar.score)
self.dispaly_surface = pygame.display.get_surface() self.display_surface = pygame.display.get_surface()
def _initialize_rect(self) -> None: def _initialize_rect(self) -> None:
"""Initialize the score rectangle.""" """Initialize the score rectangle."""
self.rect = self.surface.get_rect( self.rect = self.surface.get_rect(
bottomright=CONFIG.window.size.sub(CONFIG.window.padding) bottomright=CONFIG.window.size - CONFIG.window.padding
) )
def _initialize_font(self) -> None: def _initialize_font(self) -> None:
@ -104,6 +104,6 @@ class Score(BaseScreen, SceenElement, TextScreen):
"""Initialize the increment height for positioning text elements.""" """Initialize the increment height for positioning text elements."""
self.increment_height = self.surface.get_height() / 3 self.increment_height = self.surface.get_height() / 3
def _update_diplaysurface(self) -> None: def _update_display_surface(self) -> None:
"""Update the display surface.""" """Update the display surface."""
self.dispaly_surface.blit(self.surface, self.rect) self.display_surface.blit(self.surface, self.rect)

View File

@ -9,7 +9,7 @@ from game.sprites.block import Block
from game.sprites.tetromino import Tetromino from game.sprites.tetromino import Tetromino
from game.timer import Timer, Timers from game.timer import Timer, Timers
from .base import BaseScreen from .base import BaseScreen, SceenElement
class Tetris(BaseScreen): class Tetris(BaseScreen):
@ -46,7 +46,7 @@ class Tetris(BaseScreen):
get_next_figure: Callable[[], Figure], get_next_figure: Callable[[], Figure],
update_score: Callable[[int, int, int], None], update_score: Callable[[int, int, int], None],
) -> None: ) -> None:
self._initialize_game_surface() self._initialize_surface()
self._initialize_sprites() self._initialize_sprites()
self.get_next_figure = get_next_figure self.get_next_figure = get_next_figure
@ -70,7 +70,7 @@ class Tetris(BaseScreen):
def draw(self) -> None: def draw(self) -> None:
"""Draw the game surface and its components.""" """Draw the game surface and its components."""
self.update() self.update()
self._fill_game_surface() self._draw_background()
self.sprites.draw(self.surface) self.sprites.draw(self.surface)
self._draw_border() self._draw_border()
self._draw_grid() self._draw_grid()
@ -249,10 +249,13 @@ class Tetris(BaseScreen):
self._draw_border() self._draw_border()
self._draw_grid() self._draw_grid()
def _initialize_game_surface(self) -> None: def _initialize_surface(self) -> None:
"""Initialize the game surface.""" """Initialize the game surface."""
self.surface = pygame.Surface(CONFIG.game.size) self.surface = pygame.Surface(CONFIG.game.size)
self.dispaly_surface = pygame.display.get_surface() self.dispaly_surface = pygame.display.get_surface()
def _initialize_rect(self) -> None:
"""Initialize the rectangle."""
self.rect = self.surface.get_rect(topleft=CONFIG.game.pos) self.rect = self.surface.get_rect(topleft=CONFIG.game.pos)
def _initialize_sprites(self) -> None: def _initialize_sprites(self) -> None:
@ -308,7 +311,7 @@ class Tetris(BaseScreen):
"""Update the display surface.""" """Update the display surface."""
self.dispaly_surface.blit(self.surface, CONFIG.game.pos) self.dispaly_surface.blit(self.surface, CONFIG.game.pos)
def _fill_game_surface(self) -> None: def _draw_background(self) -> None:
"""Fill the game surface with background color.""" """Fill the game surface with background color."""
self.surface.fill(CONFIG.colors.bg_float) self.surface.fill(CONFIG.colors.bg_float)

View File

@ -2,7 +2,7 @@ from typing import Any
import numpy as np import numpy as np
import pygame import pygame
from utils import CONFIG, Rotation, Size from utils import CONFIG, Field, Rotation, Size
class Block(pygame.sprite.Sprite): class Block(pygame.sprite.Sprite):
@ -24,7 +24,7 @@ class Block(pygame.sprite.Sprite):
self, self,
/, /,
*, *,
group: pygame.sprite.Group[Any], group: pygame.sprite.Group,
pos: pygame.Vector2, pos: pygame.Vector2,
color: str, color: str,
) -> None: ) -> None:
@ -40,7 +40,7 @@ class Block(pygame.sprite.Sprite):
self.pos.y * CONFIG.game.cell.width, self.pos.y * CONFIG.game.cell.width,
) )
def vertical_collision(self, x: int, field: np.ndarray[int, Any]) -> bool: def vertical_collision(self, x: int, field: np.ndarray[Field, Any]) -> bool:
""" """
Checks for vertical collision with the game field. Checks for vertical collision with the game field.
@ -53,7 +53,7 @@ class Block(pygame.sprite.Sprite):
""" """
return not 0 <= x < CONFIG.game.columns or field[int(self.pos.y), x] return not 0 <= x < CONFIG.game.columns or field[int(self.pos.y), x]
def horizontal_collision(self, y: int, field: np.ndarray[int, Any]) -> bool: def horizontal_collision(self, y: int, field: np.ndarray[Field, Any]) -> bool:
""" """
Checks for horizontal collision with the game field. Checks for horizontal collision with the game field.

View File

@ -2,10 +2,11 @@ from typing import Any, Callable, Optional
import numpy as np import numpy as np
import pygame import pygame
from utils import CONFIG, Direction, Figure, Rotation, Size from utils import CONFIG, Direction, Field, Figure, Rotation, Size
from game.log import log
from .block import Block from .block import Block
from .log import log
class Tetromino: class Tetromino:
@ -29,9 +30,9 @@ class Tetromino:
def __init__( def __init__(
self, self,
group: pygame.sprite.Group[Any], group: pygame.sprite.Group,
create_new: Callable[[], None], create_new: Callable[[], None],
field: np.ndarray[int, Any], field: np.ndarray[Field, Any],
shape: Optional[Figure] = None, shape: Optional[Figure] = None,
) -> None: ) -> None:
self.figure: Figure = self._generate_figure(shape) self.figure: Figure = self._generate_figure(shape)
@ -156,7 +157,7 @@ class Tetromino:
for pos in new_positions for pos in new_positions
) )
def _initialize_blocks(self, group: pygame.sprite.Group[Any]) -> list[Block]: def _initialize_blocks(self, group: pygame.sprite.Group) -> list[Block]:
""" """
Initializes Tetromino blocks. Initializes Tetromino blocks.

View File

@ -41,6 +41,11 @@ class Font:
size: int = 32 size: int = 32
@define
class Button:
size: Size = Size(200, 50)
@define @define
class Window: class Window:
title: str = "Tetris" title: str = "Tetris"
@ -49,6 +54,7 @@ class Window:
Game().size.width + SideBar().size.width + padding * 3, Game().size.width + SideBar().size.width + padding * 3,
Game().size.height + padding * 2, Game().size.height + padding * 2,
) )
button: Button = Button()
@define @define

View File

@ -5,12 +5,7 @@ class Size(NamedTuple):
width: int | float width: int | float
height: int | float height: int | float
def add(self, other: Union["Size", int, float]) -> "Size": def __sub__(self, other: Union["Size", int, float]) -> "Size":
if isinstance(other, Size):
return Size(self.width + other.width, self.height + other.height)
return Size(self.width + other, self.height + other)
def sub(self, other: Union["Size", int, float]) -> "Size":
if isinstance(other, Size): if isinstance(other, Size):
return Size(self.width - other.width, self.height - other.height) return Size(self.width - other.width, self.height - other.height)
return Size(self.width - other, self.height - other) return Size(self.width - other, self.height - other)