from typing import Any, Callable, Optional import numpy as np import pygame from utils import CONFIG, Direction, Figure, GameMode, Rotation from game.log import log from game.sprites import Block, Tetromino from game.timer import Timer, Timers from .base import BaseScreen, SceenElement class Tetris(BaseScreen): """ Game class for managing the game state. Args: get_next_figure: A function to get the next figure. update_score: A function to update the score. Attributes: surface: Surface representing the game. dispaly_surface: Surface representing the display. rect: Rect representing the game surface. sprites: Sprite group for managing blocks. get_next_figure: A function to get the next figure. update_score: A function to update the score. grid_surface: Surface representing the grid. field: 2D array representing the game field. tetromino: The current tetromino. timers: Game timers. initial_block_speed: Initial block speed. increased_block_speed: Increased block speed. down_pressed: True if the down key is pressed, False otherwise. level: Current game level. score: Current game score. lines: Number of lines cleared. game_over: True if the game is over, False otherwise. landing_sound: Sound effect for landing blocks. """ def __init__( self, get_next_figure: Callable[[], Figure], update_score: Callable[[int, int, int], None], game_mode: GameMode, settings: dict[str, Any], ) -> None: self._initialize_surface() self._initialize_rect() self._initialize_sprites() self.settings = settings self.get_next_figure = get_next_figure self.update_score = update_score self.game_mode = game_mode self._initialize_grid_surface() self._initialize_field_and_tetromino() self.tetromino: Tetromino self._initialize_game_state() self._initialize_timers() self.timers: Timers self._initialize_sound() def run(self) -> None: """Run a single iteration of the game loop.""" self.draw() self._timer_update() self.handle_event() def draw(self) -> None: """Draw the game surface and its components.""" self.update() self._draw_background() self.sprites.draw(self.surface) self._draw_border() self._draw_grid() def update(self) -> None: self._update_display_surface() self.sprites.update() def handle_event(self) -> None: """Handle player input events.""" keys: pygame.key.ScancodeWrapper = pygame.key.get_pressed() self._handle_movement_keys(keys) self._handle_rotation_keys(keys) self._handle_down_key(keys) self._handle_drop_key(keys) def move_down(self) -> bool: """ Move the current tetromino down. Returns: True if the movement was successful, False otherwise. """ return self.tetromino.move_down() def move_left(self) -> bool: """ Move the current tetromino to the left. Returns: True if the movement was successful, False otherwise. """ return self.tetromino.move_horizontal(Direction.LEFT) def move_right(self) -> bool: """ Move the current tetromino to the right. Returns: True if the movement was successful, False otherwise. """ return self.tetromino.move_horizontal(Direction.RIGHT) def rotate(self) -> bool: """ Rotate the current tetromino clockwise. Returns: True if the rotation was successful, False otherwise. """ return self.tetromino.rotate() def rotate_reverse(self) -> bool: """ Rotate the current tetromino counter-clockwise. Returns: True if the rotation was successful, False otherwise. """ return self.tetromino.rotate(Rotation.COUNTER_CLOCKWISE) def drop(self) -> bool: """ Drop the current tetromino. Returns: True if the movement was successful, False otherwise. """ return self.tetromino.drop() def create_new_tetromino(self, shape: Optional[Figure] = None) -> Tetromino: """Create a new tetromino and perform necessary actions.""" self._play_landing_sound() self._check_finished_rows() self.game_over: bool = self._check_game_over() if self.game_over: self.restart() self.tetromino = Tetromino( self.sprites, self.create_new_tetromino, self.field, shape or self.get_next_figure(), ) return self.tetromino def _check_game_over(self) -> bool: """ Check if the game is over. Returns: True if the game is over, False otherwise. """ for block in self.tetromino.blocks: if block.pos.y <= 0: log.info("Game over!") return True return False def restart(self) -> None: """Restart the game.""" log.info("Restarting the game") self._reset_game_state() self._initialize_field_and_tetromino() self.game_over = False def mute(self) -> None: """Mute the game.""" self.landing_sound.set_volume(0) def _draw_grid(self) -> None: """Draw the grid on the game surface.""" for col in range(1, CONFIG.game.columns): x = col * CONFIG.game.cell.width self._draw_vertical_grid_line(x) for row in range(1, CONFIG.game.rows): y = row * CONFIG.game.cell.width self._draw_horizontal_grid_line(y) self.surface.blit(self.grid_surface, (0, 0)) def _draw_border(self) -> None: """Draw the border of the game surface.""" pygame.draw.rect( self.dispaly_surface, CONFIG.colors.border_highlight, self.rect, CONFIG.game.line_width * 2, CONFIG.game.border_radius, ) def _timer_update(self) -> None: """Update the timers.""" for timer in self.timers: timer.update() def _check_finished_rows(self) -> None: """Check and handle finished rows.""" delete_rows: list[int] = [] for idx, row in enumerate(self.field): if all(row): delete_rows.append(idx) self._delete_rows(delete_rows) def _delete_rows(self, delete_rows: list[int]) -> None: """Delete the specified rows.""" if not delete_rows: return self._calculate_score(len(delete_rows)) for row in delete_rows: self._remove_blocks_in_row(row) self._move_rows_down(row) self._rebuild_field() def _remove_blocks_in_row(self, row: int) -> None: """Remove blocks in the specified row.""" for block in self.field[row]: if block: block.kill() def _move_rows_down(self, deleted_row: int) -> None: """Move rows down after deleting a row.""" for row in self.field: for block in row: if block and block.pos.y < deleted_row: block.pos.y += 1 def _rebuild_field(self) -> None: """Rebuild the game field after deleting rows.""" self.field = self._generate_empty_field() for block in self.sprites: self.field[int(block.pos.y), int(block.pos.x)] = block def _generate_empty_field(self) -> np.ndarray[Optional[Block], Any]: """Generate an empty game field.""" return np.full((CONFIG.game.rows, CONFIG.game.columns), None) def _calculate_score(self, rows_deleted: int) -> None: """Calculate and update the game score.""" self.lines += rows_deleted self.score += CONFIG.game.score.get(rows_deleted, 0) * self.level self._check_level_up() self.update_score(self.lines, self.score, self.level) def _check_level_up(self) -> None: """Check if the player should level up.""" # incerement level every 10 lines if self.lines // 10 + 1 > self.level: self._level_up() def _level_up(self) -> None: """Level up.""" self.level += 1 self.initial_block_speed *= 0.75 self.increased_block_speed *= 0.75 self.timers.vertical.duration = self.initial_block_speed def _draw_components(self) -> None: """Draw additional components like borders and grid.""" self.sprites.draw(self.surface) self._draw_border() self._draw_grid() def _initialize_surface(self) -> None: """Initialize the game surface.""" self.surface = pygame.Surface(CONFIG.game.size) 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) def _initialize_sprites(self) -> None: """Initialize the game sprites.""" self.sprites: pygame.sprite.Group[Block] = pygame.sprite.Group() def _initialize_grid_surface(self) -> None: """Initialize the grid surface.""" self.grid_surface = self.surface.copy() self.grid_surface.fill("#00ff00") self.grid_surface.set_colorkey("#00ff00") self.grid_surface.set_alpha(100) def _initialize_field_and_tetromino(self) -> None: """Initialize the game field and tetromino.""" self.field = self._generate_empty_field() self.tetromino = Tetromino( self.sprites, self.create_new_tetromino, self.field, ) def _initialize_timers(self) -> None: """Initialize game timers.""" self.timers = Timers( Timer(self.initial_block_speed, True, self.move_down), Timer(CONFIG.game.movment_delay), Timer(CONFIG.game.rotation_delay), Timer(CONFIG.game.drop_delay), ) self.timers.vertical.activate() def _initialize_game_state(self) -> None: """Initialize the game state.""" self.initial_block_speed = CONFIG.game.initial_speed self.increased_block_speed = self.initial_block_speed * 0.4 self.down_pressed = False self.drop_pressed = False self.level: int = 1 self.score: int = 0 self.lines: int = 0 self.game_over = False def _initialize_sound(self) -> None: """Initialize game sounds.""" if ( self.game_mode is GameMode.PLAYER and self.settings["Volume"]["SFX"]["enabled"] ): self.landing_sound = pygame.mixer.Sound(CONFIG.music.landing) self.landing_sound.set_volume(self.settings["Volume"]["SFX"]["level"]) def _play_landing_sound(self) -> None: """Play the landing sound effect.""" if ( self.game_mode is GameMode.PLAYER and self.settings["Volume"]["SFX"]["enabled"] ): self.landing_sound.play() def _update_display_surface(self) -> None: """Update the display surface.""" self.dispaly_surface.blit(self.surface, CONFIG.game.pos) def _draw_background(self) -> None: """Fill the game surface with background color.""" self.surface.fill(CONFIG.colors.bg_float) def _handle_movement_keys(self, keys: pygame.key.ScancodeWrapper) -> None: """ Handle movement keys. See `settings.toml` for the default key bindings. """ right_keys = [ pygame.key.key_code(key) for key in self.settings["Movement"]["right"] ] right_key_pressed = any(keys[key] for key in right_keys) left_keys = [ pygame.key.key_code(key) for key in self.settings["Movement"]["left"] ] left_key_pressed = any(keys[key] for key in left_keys) if not self.timers.horizontal.active: if left_key_pressed: self.move_left() self.timers.horizontal.activate() elif right_key_pressed: self.move_right() self.timers.horizontal.activate() def _handle_rotation_keys(self, keys: pygame.key.ScancodeWrapper) -> None: """ Handle rotation keys. See `settings.toml` for the default key bindings. """ cw_keys = [pygame.key.key_code(key) for key in self.settings["Rotation"]["cw"]] cw_key_pressed = any(keys[key] for key in cw_keys) ccw_keys = [ pygame.key.key_code(key) for key in self.settings["Rotation"]["ccw"] ] ccw_key_pressed = any(keys[key] for key in ccw_keys) if not self.timers.rotation.active: if cw_key_pressed: self.rotate() self.timers.rotation.activate() if ccw_key_pressed: self.rotate_reverse() self.timers.rotation.activate() def _handle_down_key(self, keys: pygame.key.ScancodeWrapper) -> None: """ Handle the down key. See `settings.toml` for the default key bindings. """ down_keys = [ pygame.key.key_code(key) for key in self.settings["Movement"]["down"] ] down_key_pressed = any(keys[key] for key in down_keys) if not self.down_pressed and down_key_pressed: self.down_pressed = True self.timers.vertical.duration = self.increased_block_speed if self.down_pressed and not down_key_pressed: self.down_pressed = False self.timers.vertical.duration = self.initial_block_speed def _handle_drop_key(self, keys: pygame.key.ScancodeWrapper) -> None: """ Handle the drop key. See `settings.toml` for the default key bindings. """ drop_keys = [ pygame.key.key_code(key) for key in self.settings["Action"]["drop"] ] drop_key_pressed = any(keys[key] for key in drop_keys) if not self.timers.drop.active and drop_key_pressed: self.drop() self.timers.drop.activate() def _reset_game_state(self) -> None: """Reset the game state.""" self.sprites.empty() self._initialize_field_and_tetromino() self._initialize_game_state() self.update_score(self.lines, self.score, self.level) def _draw_vertical_grid_line(self, x: int | float) -> None: """Draw a vertical grid line.""" pygame.draw.line( self.grid_surface, CONFIG.colors.border_highlight, (x, 0), (x, self.grid_surface.get_height()), CONFIG.game.line_width, ) def _draw_horizontal_grid_line(self, y: int | float) -> None: """Draw a horizontal grid line.""" pygame.draw.line( self.grid_surface, CONFIG.colors.border_highlight, (0, y), (self.grid_surface.get_width(), y), CONFIG.game.line_width, )