diff --git a/.gitattributes b/.gitattributes old mode 100644 new mode 100755 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56d32c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Virtual environments +.venv/ +venv/ +env/ + +# Python bytecode +__pycache__/ +*.py[cod] +*.pyo + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg + +# Tool caches +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ + +# Misc +*.log +.DS_Store diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/Sounds/clear.ogg b/Sounds/clear.ogg old mode 100644 new mode 100755 diff --git a/Sounds/music.ogg b/Sounds/music.ogg old mode 100644 new mode 100755 diff --git a/Sounds/rotate.ogg b/Sounds/rotate.ogg old mode 100644 new mode 100755 diff --git a/block.py b/block.py old mode 100644 new mode 100755 index e988273..deb060c --- a/block.py +++ b/block.py @@ -2,41 +2,42 @@ import pygame from position import Position + class Block: - def __init__(self, id): - self.id = id - self.cells = {} - self.cell_size = 30 - self.row_offset = 0 - self.column_offset = 0 - self.rotation_state = 0 - self.colors = Colors.get_cell_colors() + def __init__(self, id: int) -> None: + self.id: int = id + self.cells: dict[int, list[Position]] = {} + self.cell_size: int = 30 + self.row_offset: int = 0 + self.column_offset: int = 0 + self.rotation_state: int = 0 + self.colors: list[tuple[int, int, int]] = Colors.get_cell_colors() - def move(self, rows, columns): - self.row_offset += rows - self.column_offset += columns + def move(self, rows: int, columns: int) -> None: + self.row_offset += rows + self.column_offset += columns - def get_cell_positions(self): - tiles = self.cells[self.rotation_state] - moved_tiles = [] - for position in tiles: - position = Position(position.row + self.row_offset, position.column + self.column_offset) - moved_tiles.append(position) - return moved_tiles + def get_cell_positions(self) -> list[Position]: + tiles = self.cells[self.rotation_state] + moved_tiles: list[Position] = [] + for position in tiles: + position = Position(position.row + self.row_offset, position.column + self.column_offset) + moved_tiles.append(position) + return moved_tiles - def rotate(self): - self.rotation_state += 1 - if self.rotation_state == len(self.cells): - self.rotation_state = 0 + def rotate(self) -> None: + self.rotation_state += 1 + if self.rotation_state == len(self.cells): + self.rotation_state = 0 - def undo_rotation(self): - self.rotation_state -= 1 - if self.rotation_state == -1: - self.rotation_state = len(self.cells) - 1 + def undo_rotation(self) -> None: + self.rotation_state -= 1 + if self.rotation_state == -1: + self.rotation_state = len(self.cells) - 1 - def draw(self, screen, offset_x, offset_y): - tiles = self.get_cell_positions() - for tile in tiles: - tile_rect = pygame.Rect(offset_x + tile.column * self.cell_size, - offset_y + tile.row * self.cell_size, self.cell_size -1, self.cell_size -1) - pygame.draw.rect(screen, self.colors[self.id], tile_rect) + def draw(self, screen: pygame.Surface, offset_x: int, offset_y: int) -> None: + tiles = self.get_cell_positions() + for tile in tiles: + tile_rect = pygame.Rect(offset_x + tile.column * self.cell_size, + offset_y + tile.row * self.cell_size, self.cell_size - 1, self.cell_size - 1) + pygame.draw.rect(screen, self.colors[self.id], tile_rect) diff --git a/blocks.py b/blocks.py old mode 100644 new mode 100755 index e039030..b525120 --- a/blocks.py +++ b/blocks.py @@ -1,20 +1,22 @@ from block import Block from position import Position + class LBlock(Block): - def __init__(self): - super().__init__(id = 1) - self.cells = { - 0: [Position(0, 2), Position(1, 0), Position(1, 1), Position(1, 2)], - 1: [Position(0, 1), Position(1, 1), Position(2, 1), Position(2, 2)], - 2: [Position(1, 0), Position(1, 1), Position(1, 2), Position(2, 0)], - 3: [Position(0, 0), Position(0, 1), Position(1, 1), Position(2, 1)] - } - self.move(0, 3) + def __init__(self) -> None: + super().__init__(id=1) + self.cells = { + 0: [Position(0, 2), Position(1, 0), Position(1, 1), Position(1, 2)], + 1: [Position(0, 1), Position(1, 1), Position(2, 1), Position(2, 2)], + 2: [Position(1, 0), Position(1, 1), Position(1, 2), Position(2, 0)], + 3: [Position(0, 0), Position(0, 1), Position(1, 1), Position(2, 1)] + } + self.move(0, 3) + class JBlock(Block): - def __init__(self): - super().__init__(id = 2) + def __init__(self) -> None: + super().__init__(id=2) self.cells = { 0: [Position(0, 0), Position(1, 0), Position(1, 1), Position(1, 2)], 1: [Position(0, 1), Position(0, 2), Position(1, 1), Position(2, 1)], @@ -23,9 +25,10 @@ def __init__(self): } self.move(0, 3) + class IBlock(Block): - def __init__(self): - super().__init__(id = 3) + def __init__(self) -> None: + super().__init__(id=3) self.cells = { 0: [Position(1, 0), Position(1, 1), Position(1, 2), Position(1, 3)], 1: [Position(0, 2), Position(1, 2), Position(2, 2), Position(3, 2)], @@ -34,17 +37,19 @@ def __init__(self): } self.move(-1, 3) + class OBlock(Block): - def __init__(self): - super().__init__(id = 4) + def __init__(self) -> None: + super().__init__(id=4) self.cells = { 0: [Position(0, 0), Position(0, 1), Position(1, 0), Position(1, 1)] } self.move(0, 4) + class SBlock(Block): - def __init__(self): - super().__init__(id = 5) + def __init__(self) -> None: + super().__init__(id=5) self.cells = { 0: [Position(0, 1), Position(0, 2), Position(1, 0), Position(1, 1)], 1: [Position(0, 1), Position(1, 1), Position(1, 2), Position(2, 2)], @@ -53,9 +58,10 @@ def __init__(self): } self.move(0, 3) + class TBlock(Block): - def __init__(self): - super().__init__(id = 6) + def __init__(self) -> None: + super().__init__(id=6) self.cells = { 0: [Position(0, 1), Position(1, 0), Position(1, 1), Position(1, 2)], 1: [Position(0, 1), Position(1, 1), Position(1, 2), Position(2, 1)], @@ -64,13 +70,14 @@ def __init__(self): } self.move(0, 3) + class ZBlock(Block): - def __init__(self): - super().__init__(id = 7) + def __init__(self) -> None: + super().__init__(id=7) self.cells = { 0: [Position(0, 0), Position(0, 1), Position(1, 1), Position(1, 2)], 1: [Position(0, 2), Position(1, 1), Position(1, 2), Position(2, 1)], 2: [Position(1, 0), Position(1, 1), Position(2, 1), Position(2, 2)], 3: [Position(0, 1), Position(1, 0), Position(1, 1), Position(2, 0)] } - self.move(0, 3) \ No newline at end of file + self.move(0, 3) diff --git a/colors.py b/colors.py old mode 100644 new mode 100755 index afbf6f0..09eec00 --- a/colors.py +++ b/colors.py @@ -1,16 +1,16 @@ class Colors: - dark_grey = (26, 31, 40) - green = (47, 230, 23) - red = (232, 18, 18) - orange = (226, 116, 17) - yellow = (237, 234, 4) - purple = (166, 0, 247) - cyan = (21, 204, 209) - blue = (13, 64, 216) - white = (255, 255, 255) - dark_blue = (44, 44, 127) - light_blue = (59, 85, 162) + dark_grey: tuple[int, int, int] = (26, 31, 40) + green: tuple[int, int, int] = (47, 230, 23) + red: tuple[int, int, int] = (232, 18, 18) + orange: tuple[int, int, int] = (226, 116, 17) + yellow: tuple[int, int, int] = (237, 234, 4) + purple: tuple[int, int, int] = (166, 0, 247) + cyan: tuple[int, int, int] = (21, 204, 209) + blue: tuple[int, int, int] = (13, 64, 216) + white: tuple[int, int, int] = (255, 255, 255) + dark_blue: tuple[int, int, int] = (44, 44, 127) + light_blue: tuple[int, int, int] = (59, 85, 162) - @classmethod - def get_cell_colors(cls): - return [cls.dark_grey, cls.green, cls.red, cls.orange, cls.yellow, cls.purple, cls.cyan, cls.blue] + @classmethod + def get_cell_colors(cls) -> list[tuple[int, int, int]]: + return [cls.dark_grey, cls.green, cls.red, cls.orange, cls.yellow, cls.purple, cls.cyan, cls.blue] diff --git a/game.py b/game.py old mode 100644 new mode 100755 index a610a73..4744a04 --- a/game.py +++ b/game.py @@ -1,102 +1,108 @@ from grid import Grid -from blocks import * +from blocks import IBlock, JBlock, LBlock, OBlock, SBlock, TBlock, ZBlock +from block import Block import random import pygame + +def _all_blocks() -> list[Block]: + return [IBlock(), JBlock(), LBlock(), OBlock(), SBlock(), TBlock(), ZBlock()] + + class Game: - def __init__(self): - self.grid = Grid() - self.blocks = [IBlock(), JBlock(), LBlock(), OBlock(), SBlock(), TBlock(), ZBlock()] - self.current_block = self.get_random_block() - self.next_block = self.get_random_block() - self.game_over = False - self.score = 0 - self.rotate_sound = pygame.mixer.Sound("Sounds/rotate.ogg") - self.clear_sound = pygame.mixer.Sound("Sounds/clear.ogg") - - pygame.mixer.music.load("Sounds/music.ogg") - pygame.mixer.music.play(-1) - - def update_score(self, lines_cleared, move_down_points): - if lines_cleared == 1: - self.score += 100 - elif lines_cleared == 2: - self.score += 300 - elif lines_cleared == 3: - self.score += 500 - self.score += move_down_points - - def get_random_block(self): - if len(self.blocks) == 0: - self.blocks = [IBlock(), JBlock(), LBlock(), OBlock(), SBlock(), TBlock(), ZBlock()] - block = random.choice(self.blocks) - self.blocks.remove(block) - return block - - def move_left(self): - self.current_block.move(0, -1) - if self.block_inside() == False or self.block_fits() == False: - self.current_block.move(0, 1) - - def move_right(self): - self.current_block.move(0, 1) - if self.block_inside() == False or self.block_fits() == False: - self.current_block.move(0, -1) - - def move_down(self): - self.current_block.move(1, 0) - if self.block_inside() == False or self.block_fits() == False: - self.current_block.move(-1, 0) - self.lock_block() - - def lock_block(self): - tiles = self.current_block.get_cell_positions() - for position in tiles: - self.grid.grid[position.row][position.column] = self.current_block.id - self.current_block = self.next_block - self.next_block = self.get_random_block() - rows_cleared = self.grid.clear_full_rows() - if rows_cleared > 0: - self.clear_sound.play() - self.update_score(rows_cleared, 0) - if self.block_fits() == False: - self.game_over = True - - def reset(self): - self.grid.reset() - self.blocks = [IBlock(), JBlock(), LBlock(), OBlock(), SBlock(), TBlock(), ZBlock()] - self.current_block = self.get_random_block() - self.next_block = self.get_random_block() - self.score = 0 - - def block_fits(self): - tiles = self.current_block.get_cell_positions() - for tile in tiles: - if self.grid.is_empty(tile.row, tile.column) == False: - return False - return True - - def rotate(self): - self.current_block.rotate() - if self.block_inside() == False or self.block_fits() == False: - self.current_block.undo_rotation() - else: - self.rotate_sound.play() - - def block_inside(self): - tiles = self.current_block.get_cell_positions() - for tile in tiles: - if self.grid.is_inside(tile.row, tile.column) == False: - return False - return True - - def draw(self, screen): - self.grid.draw(screen) - self.current_block.draw(screen, 11, 11) - - if self.next_block.id == 3: - self.next_block.draw(screen, 255, 290) - elif self.next_block.id == 4: - self.next_block.draw(screen, 255, 280) - else: - self.next_block.draw(screen, 270, 270) \ No newline at end of file + def __init__(self) -> None: + self.grid: Grid = Grid() + self.blocks: list[Block] = _all_blocks() + self.current_block: Block = self.get_random_block() + self.next_block: Block = self.get_random_block() + self.game_over: bool = False + self.score: int = 0 + self.rotate_sound: pygame.mixer.Sound = pygame.mixer.Sound("Sounds/rotate.ogg") + self.clear_sound: pygame.mixer.Sound = pygame.mixer.Sound("Sounds/clear.ogg") + + pygame.mixer.music.load("Sounds/music.ogg") + pygame.mixer.music.play(-1) + + def update_score(self, lines_cleared: int, move_down_points: int) -> None: + if lines_cleared == 1: + self.score += 100 + elif lines_cleared == 2: + self.score += 300 + elif lines_cleared == 3: + self.score += 500 + self.score += move_down_points + + def get_random_block(self) -> Block: + if len(self.blocks) == 0: + self.blocks = _all_blocks() + block = random.choice(self.blocks) + self.blocks.remove(block) + return block + + def move_left(self) -> None: + self.current_block.move(0, -1) + if not self.block_inside() or not self.block_fits(): + self.current_block.move(0, 1) + + def move_right(self) -> None: + self.current_block.move(0, 1) + if not self.block_inside() or not self.block_fits(): + self.current_block.move(0, -1) + + def move_down(self) -> None: + self.current_block.move(1, 0) + if not self.block_inside() or not self.block_fits(): + self.current_block.move(-1, 0) + self.lock_block() + + def lock_block(self) -> None: + tiles = self.current_block.get_cell_positions() + for position in tiles: + self.grid.grid[position.row][position.column] = self.current_block.id + self.current_block = self.next_block + self.next_block = self.get_random_block() + rows_cleared = self.grid.clear_full_rows() + if rows_cleared > 0: + self.clear_sound.play() + self.update_score(rows_cleared, 0) + if not self.block_fits(): + self.game_over = True + + def reset(self) -> None: + self.grid.reset() + self.blocks = _all_blocks() + self.current_block = self.get_random_block() + self.next_block = self.get_random_block() + self.score = 0 + + def block_fits(self) -> bool: + tiles = self.current_block.get_cell_positions() + for tile in tiles: + if not self.grid.is_empty(tile.row, tile.column): + return False + return True + + def rotate(self) -> None: + self.current_block.rotate() + if not self.block_inside() or not self.block_fits(): + self.current_block.undo_rotation() + else: + self.rotate_sound.play() + + def block_inside(self) -> bool: + tiles = self.current_block.get_cell_positions() + for tile in tiles: + if not self.grid.is_inside(tile.row, tile.column): + return False + return True + + def draw(self, screen: pygame.Surface) -> None: + self.grid.draw(screen) + self.current_block.draw(screen, 11, 11) + + if self.next_block.id == 3: + self.next_block.draw(screen, 255, 290) + elif self.next_block.id == 4: + self.next_block.draw(screen, 255, 280) + else: + self.next_block.draw(screen, 270, 270) diff --git a/grid.py b/grid.py old mode 100644 new mode 100755 index 7b146b9..1e20525 --- a/grid.py +++ b/grid.py @@ -1,64 +1,65 @@ import pygame from colors import Colors + class Grid: - def __init__(self): - self.num_rows = 20 - self.num_cols = 10 - self.cell_size = 30 - self.grid = [[0 for j in range(self.num_cols)] for i in range(self.num_rows)] - self.colors = Colors.get_cell_colors() + def __init__(self) -> None: + self.num_rows: int = 20 + self.num_cols: int = 10 + self.cell_size: int = 30 + self.grid: list[list[int]] = [[0 for j in range(self.num_cols)] for i in range(self.num_rows)] + self.colors: list[tuple[int, int, int]] = Colors.get_cell_colors() - def print_grid(self): - for row in range(self.num_rows): - for column in range(self.num_cols): - print(self.grid[row][column], end = " ") - print() + def print_grid(self) -> None: + for row in range(self.num_rows): + for column in range(self.num_cols): + print(self.grid[row][column], end=" ") + print() - def is_inside(self, row, column): - if row >= 0 and row < self.num_rows and column >= 0 and column < self.num_cols: - return True - return False + def is_inside(self, row: int, column: int) -> bool: + if row >= 0 and row < self.num_rows and column >= 0 and column < self.num_cols: + return True + return False - def is_empty(self, row, column): - if self.grid[row][column] == 0: - return True - return False + def is_empty(self, row: int, column: int) -> bool: + if self.grid[row][column] == 0: + return True + return False - def is_row_full(self, row): - for column in range(self.num_cols): - if self.grid[row][column] == 0: - return False - return True + def is_row_full(self, row: int) -> bool: + for column in range(self.num_cols): + if self.grid[row][column] == 0: + return False + return True - def clear_row(self, row): - for column in range(self.num_cols): - self.grid[row][column] = 0 + def clear_row(self, row: int) -> None: + for column in range(self.num_cols): + self.grid[row][column] = 0 - def move_row_down(self, row, num_rows): - for column in range(self.num_cols): - self.grid[row+num_rows][column] = self.grid[row][column] - self.grid[row][column] = 0 + def move_row_down(self, row: int, num_rows: int) -> None: + for column in range(self.num_cols): + self.grid[row + num_rows][column] = self.grid[row][column] + self.grid[row][column] = 0 - def clear_full_rows(self): - completed = 0 - for row in range(self.num_rows-1, 0, -1): - if self.is_row_full(row): - self.clear_row(row) - completed += 1 - elif completed > 0: - self.move_row_down(row, completed) - return completed + def clear_full_rows(self) -> int: + completed = 0 + for row in range(self.num_rows - 1, 0, -1): + if self.is_row_full(row): + self.clear_row(row) + completed += 1 + elif completed > 0: + self.move_row_down(row, completed) + return completed - def reset(self): - for row in range(self.num_rows): - for column in range(self.num_cols): - self.grid[row][column] = 0 + def reset(self) -> None: + for row in range(self.num_rows): + for column in range(self.num_cols): + self.grid[row][column] = 0 - def draw(self, screen): - for row in range(self.num_rows): - for column in range(self.num_cols): - cell_value = self.grid[row][column] - cell_rect = pygame.Rect(column*self.cell_size + 11, row*self.cell_size + 11, - self.cell_size -1, self.cell_size -1) - pygame.draw.rect(screen, self.colors[cell_value], cell_rect) + def draw(self, screen: pygame.Surface) -> None: + for row in range(self.num_rows): + for column in range(self.num_cols): + cell_value = self.grid[row][column] + cell_rect = pygame.Rect(column * self.cell_size + 11, row * self.cell_size + 11, + self.cell_size - 1, self.cell_size - 1) + pygame.draw.rect(screen, self.colors[cell_value], cell_rect) diff --git a/main.py b/main.py old mode 100644 new mode 100755 index 144ef7f..83f7089 --- a/main.py +++ b/main.py @@ -1,63 +1,65 @@ -import pygame,sys +import pygame +import sys from game import Game from colors import Colors -pygame.init() - -title_font = pygame.font.Font(None, 40) -score_surface = title_font.render("Score", True, Colors.white) -next_surface = title_font.render("Next", True, Colors.white) -game_over_surface = title_font.render("GAME OVER", True, Colors.white) - -score_rect = pygame.Rect(320, 55, 170, 60) -next_rect = pygame.Rect(320, 215, 170, 180) - -screen = pygame.display.set_mode((500, 620)) -pygame.display.set_caption("Python Tetris") - -clock = pygame.time.Clock() - -game = Game() - -GAME_UPDATE = pygame.USEREVENT -pygame.time.set_timer(GAME_UPDATE, 200) - -while True: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - pygame.quit() - sys.exit() - if event.type == pygame.KEYDOWN: - if game.game_over == True: - game.game_over = False - game.reset() - if event.key == pygame.K_LEFT and game.game_over == False: - game.move_left() - if event.key == pygame.K_RIGHT and game.game_over == False: - game.move_right() - if event.key == pygame.K_DOWN and game.game_over == False: - game.move_down() - game.update_score(0, 1) - if event.key == pygame.K_UP and game.game_over == False: - game.rotate() - if event.type == GAME_UPDATE and game.game_over == False: - game.move_down() - - #Drawing - score_value_surface = title_font.render(str(game.score), True, Colors.white) - - screen.fill(Colors.dark_blue) - screen.blit(score_surface, (365, 20, 50, 50)) - screen.blit(next_surface, (375, 180, 50, 50)) - - if game.game_over == True: - screen.blit(game_over_surface, (320, 450, 50, 50)) - - pygame.draw.rect(screen, Colors.light_blue, score_rect, 0, 10) - screen.blit(score_value_surface, score_value_surface.get_rect(centerx = score_rect.centerx, - centery = score_rect.centery)) - pygame.draw.rect(screen, Colors.light_blue, next_rect, 0, 10) - game.draw(screen) - - pygame.display.update() - clock.tick(60) \ No newline at end of file +if __name__ == "__main__": + pygame.init() + + title_font: pygame.font.Font = pygame.font.Font(None, 40) + score_surface: pygame.Surface = title_font.render("Score", True, Colors.white) + next_surface: pygame.Surface = title_font.render("Next", True, Colors.white) + game_over_surface: pygame.Surface = title_font.render("GAME OVER", True, Colors.white) + + score_rect: pygame.Rect = pygame.Rect(320, 55, 170, 60) + next_rect: pygame.Rect = pygame.Rect(320, 215, 170, 180) + + screen: pygame.Surface = pygame.display.set_mode((500, 620)) + pygame.display.set_caption("Python Tetris") + + clock: pygame.time.Clock = pygame.time.Clock() + + game: Game = Game() + + GAME_UPDATE: int = pygame.USEREVENT + pygame.time.set_timer(GAME_UPDATE, 200) + + while True: + for event in pygame.event.get(): + if event.type == pygame.QUIT: + pygame.quit() + sys.exit() + if event.type == pygame.KEYDOWN: + if game.game_over: + game.game_over = False + game.reset() + if event.key == pygame.K_LEFT and not game.game_over: + game.move_left() + if event.key == pygame.K_RIGHT and not game.game_over: + game.move_right() + if event.key == pygame.K_DOWN and not game.game_over: + game.move_down() + game.update_score(0, 1) + if event.key == pygame.K_UP and not game.game_over: + game.rotate() + if event.type == GAME_UPDATE and not game.game_over: + game.move_down() + + # Drawing + score_value_surface: pygame.Surface = title_font.render(str(game.score), True, Colors.white) + + screen.fill(Colors.dark_blue) + screen.blit(score_surface, (365, 20, 50, 50)) + screen.blit(next_surface, (375, 180, 50, 50)) + + if game.game_over: + screen.blit(game_over_surface, (320, 450, 50, 50)) + + pygame.draw.rect(screen, Colors.light_blue, score_rect, 0, 10) + screen.blit(score_value_surface, score_value_surface.get_rect(centerx=score_rect.centerx, + centery=score_rect.centery)) + pygame.draw.rect(screen, Colors.light_blue, next_rect, 0, 10) + game.draw(screen) + + pygame.display.update() + clock.tick(60) diff --git a/position.py b/position.py old mode 100644 new mode 100755 index bc33565..d6a56cb --- a/position.py +++ b/position.py @@ -1,4 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) class Position: - def __init__(self, row, column): - self.row = row - self.column = column + row: int + column: int diff --git a/preview.jpg b/preview.jpg old mode 100644 new mode 100755 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5a68421 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "python-tetris-game-pygame" +version = "1.0.0" +description = "A Tetris game built with pygame-ce" +requires-python = ">=3.12" +dependencies = [ + "pygame-ce>=2.5.0,<3.0", +] + +[project.optional-dependencies] +dev = [ + "mypy>=1.10", + "ruff>=0.4", + "pytest>=8.0", +] + +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "W", "F", "UP", "B", "I"] + +[tool.mypy] +python_version = "3.12" +strict = true + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100755 index 0000000..9ca22e0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +import sys +import os +import random +from unittest.mock import MagicMock + +# Add repo root to sys.path before any game module imports are attempted +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +# Set dummy SDL drivers so pygame.init() doesn't error on headless/audio-less systems +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + +# Seed RNG for deterministic block sequences across all tests +random.seed(42) + +import pygame +pygame.init() + +# Patch mixer before game is imported so Game.__init__ doesn't need real audio hardware +pygame.mixer.Sound = MagicMock(return_value=MagicMock()) +pygame.mixer.music = MagicMock() diff --git a/tests/test_block.py b/tests/test_block.py new file mode 100755 index 0000000..28fbf30 --- /dev/null +++ b/tests/test_block.py @@ -0,0 +1,134 @@ +import pytest + +from blocks import LBlock +from position import Position + + +@pytest.fixture() +def zeroed_lblock() -> LBlock: + block = LBlock() + block.row_offset = 0 + block.column_offset = 0 + block.rotation_state = 0 + return block + + +def test_move_updates_offsets(zeroed_lblock: LBlock) -> None: + block = zeroed_lblock + block.move(2, 3) + assert block.row_offset == 2 + assert block.column_offset == 3 + + +def test_move_accumulates(zeroed_lblock: LBlock) -> None: + block = zeroed_lblock + block.move(1, 0) + block.move(2, 0) + assert block.row_offset == 3 + + +def test_get_cell_positions_applies_offset(zeroed_lblock: LBlock) -> None: + block = zeroed_lblock + + # Get base positions (no offset) + base_positions = [ + Position(p.row, p.column) for p in block.cells[0] + ] + + block.move(1, 0) + moved_positions = block.get_cell_positions() + + for base, moved in zip(base_positions, moved_positions): + assert moved.row == base.row + 1 + assert moved.column == base.column + + +def test_rotate_increments_state(): + block = LBlock() + initial_state = block.rotation_state + block.rotate() + assert block.rotation_state == initial_state + 1 + + +def test_rotate_wraps_around(): + block = LBlock() + num_states = len(block.cells) + # Rotate through all states; next rotate should wrap back to 0 + for _ in range(num_states): + block.rotate() + assert block.rotation_state == 0 + + +def test_undo_rotation_decrements_state(): + block = LBlock() + block.rotation_state = 2 + block.undo_rotation() + assert block.rotation_state == 1 + + +def test_undo_rotation_wraps_around(): + block = LBlock() + block.rotation_state = 0 + block.undo_rotation() + # Should wrap to the last valid state + assert block.rotation_state == len(block.cells) - 1 + + +from blocks import JBlock, IBlock, OBlock, SBlock, TBlock, ZBlock + + +def test_jblock_cell_layout() -> None: + block = JBlock() + block.row_offset = 0 + block.column_offset = 0 + assert block.cells[0] == [Position(0, 0), Position(1, 0), Position(1, 1), Position(1, 2)] + assert block.cells[1] == [Position(0, 1), Position(0, 2), Position(1, 1), Position(2, 1)] + assert block.cells[2] == [Position(1, 0), Position(1, 1), Position(1, 2), Position(2, 2)] + assert block.cells[3] == [Position(0, 1), Position(1, 1), Position(2, 0), Position(2, 1)] + + +def test_iblock_cell_layout() -> None: + block = IBlock() + block.row_offset = 0 + block.column_offset = 0 + assert block.cells[0] == [Position(1, 0), Position(1, 1), Position(1, 2), Position(1, 3)] + assert block.cells[1] == [Position(0, 2), Position(1, 2), Position(2, 2), Position(3, 2)] + assert block.cells[2] == [Position(2, 0), Position(2, 1), Position(2, 2), Position(2, 3)] + assert block.cells[3] == [Position(0, 1), Position(1, 1), Position(2, 1), Position(3, 1)] + + +def test_oblock_cell_layout() -> None: + block = OBlock() + block.row_offset = 0 + block.column_offset = 0 + assert block.cells[0] == [Position(0, 0), Position(0, 1), Position(1, 0), Position(1, 1)] + + +def test_sblock_cell_layout() -> None: + block = SBlock() + block.row_offset = 0 + block.column_offset = 0 + assert block.cells[0] == [Position(0, 1), Position(0, 2), Position(1, 0), Position(1, 1)] + assert block.cells[1] == [Position(0, 1), Position(1, 1), Position(1, 2), Position(2, 2)] + assert block.cells[2] == [Position(1, 1), Position(1, 2), Position(2, 0), Position(2, 1)] + assert block.cells[3] == [Position(0, 0), Position(1, 0), Position(1, 1), Position(2, 1)] + + +def test_tblock_cell_layout() -> None: + block = TBlock() + block.row_offset = 0 + block.column_offset = 0 + assert block.cells[0] == [Position(0, 1), Position(1, 0), Position(1, 1), Position(1, 2)] + assert block.cells[1] == [Position(0, 1), Position(1, 1), Position(1, 2), Position(2, 1)] + assert block.cells[2] == [Position(1, 0), Position(1, 1), Position(1, 2), Position(2, 1)] + assert block.cells[3] == [Position(0, 1), Position(1, 0), Position(1, 1), Position(2, 1)] + + +def test_zblock_cell_layout() -> None: + block = ZBlock() + block.row_offset = 0 + block.column_offset = 0 + assert block.cells[0] == [Position(0, 0), Position(0, 1), Position(1, 1), Position(1, 2)] + assert block.cells[1] == [Position(0, 2), Position(1, 1), Position(1, 2), Position(2, 1)] + assert block.cells[2] == [Position(1, 0), Position(1, 1), Position(2, 1), Position(2, 2)] + assert block.cells[3] == [Position(0, 1), Position(1, 0), Position(1, 1), Position(2, 0)] diff --git a/tests/test_game.py b/tests/test_game.py new file mode 100755 index 0000000..b83e889 --- /dev/null +++ b/tests/test_game.py @@ -0,0 +1,213 @@ +# conftest.py has already patched pygame.mixer before this import +from game import Game +from block import Block +from blocks import IBlock, JBlock, LBlock, OBlock, SBlock, TBlock, ZBlock + +ALL_BLOCK_TYPES = {IBlock, JBlock, LBlock, OBlock, SBlock, TBlock, ZBlock} + + +def _fresh_bag() -> list[Block]: + return [IBlock(), JBlock(), LBlock(), OBlock(), SBlock(), TBlock(), ZBlock()] + + +def test_initial_score_is_zero(): + game = Game() + assert game.score == 0 + + +def test_initial_game_over_is_false(): + game = Game() + assert game.game_over is False + + +def test_update_score_one_line(): + game = Game() + game.update_score(1, 0) + assert game.score == 100 + + +def test_update_score_two_lines(): + game = Game() + game.update_score(2, 0) + assert game.score == 300 + + +def test_update_score_three_lines(): + game = Game() + game.update_score(3, 0) + assert game.score == 500 + + +def test_update_score_move_down(): + game = Game() + game.update_score(0, 5) + assert game.score == 5 + + +def test_update_score_no_lines(): + game = Game() + game.update_score(0, 0) + assert game.score == 0 + + +def test_get_random_block_removes_from_bag(): + game = Game() + # After __init__, two blocks have already been drawn (current_block and next_block). + # Reset the bag to a full set of 7 to get a predictable starting count. + game.blocks = _fresh_bag() + game.get_random_block() + assert len(game.blocks) == 6 + + +def test_get_random_block_refills_bag(): + game = Game() + # Drain all 7 blocks from a fresh bag + game.blocks = _fresh_bag() + for _ in range(7): + game.get_random_block() + # Bag is now empty; next call must refill and return a block + assert len(game.blocks) == 0 + block = game.get_random_block() + # After refill and one removal, bag has 6 items + assert block is not None + assert len(game.blocks) == 6 + + +def test_get_random_block_all_7_types(): + game = Game() + # Start with a fresh, full bag + game.blocks = _fresh_bag() + drawn_types = set() + for _ in range(7): + block = game.get_random_block() + drawn_types.add(type(block)) + assert drawn_types == ALL_BLOCK_TYPES + + +def test_lock_block_writes_cells_to_grid(): + game = Game() + block = game.current_block + block_id = block.id + positions = block.get_cell_positions() + game.lock_block() + for pos in positions: + assert game.grid.grid[pos.row][pos.column] == block_id + + +def test_lock_block_advances_current_block(): + game = Game() + old_next = game.next_block + game.lock_block() + assert game.current_block is old_next + + +def test_lock_block_clears_full_row_and_updates_score(): + game = Game() + # Fill row 19 (bottom) — all blocks start in rows 0-3 so no overlap + for col in range(game.grid.num_cols): + game.grid.grid[19][col] = 1 + old_score = game.score + game.lock_block() + assert game.score > old_score + + +def test_lock_block_sets_game_over_when_grid_full(): + game = Game() + # Fill top rows so that whatever block becomes current can't fit + for row in range(4): + for col in range(game.grid.num_cols): + game.grid.grid[row][col] = 1 + game.lock_block() + assert game.game_over is True + + +def test_move_left_decrements_column_offset(): + game = Game() + initial_col = game.current_block.column_offset + game.move_left() + assert game.current_block.column_offset == initial_col - 1 + + +def test_move_left_clamped_at_left_wall(): + game = Game() + for _ in range(10): + game.move_left() + for pos in game.current_block.get_cell_positions(): + assert pos.column >= 0 + + +def test_move_right_increments_column_offset(): + game = Game() + initial_col = game.current_block.column_offset + game.move_right() + assert game.current_block.column_offset == initial_col + 1 + + +def test_move_right_clamped_at_right_wall(): + game = Game() + for _ in range(15): + game.move_right() + for pos in game.current_block.get_cell_positions(): + assert pos.column < game.grid.num_cols + + +def test_move_down_increments_row_offset(): + game = Game() + initial_row = game.current_block.row_offset + game.move_down() + assert game.current_block.row_offset == initial_row + 1 + + +def test_move_down_locks_block_at_floor(): + game = Game() + first_block = game.current_block + for _ in range(25): + game.move_down() + assert game.current_block is not first_block + + +def test_rotate_changes_rotation_state(): + game = Game() + # Use a known multi-state block at a safe grid position to guarantee rotation succeeds + safe_block = LBlock() + safe_block.move(3, 0) + game.current_block = safe_block + initial_state = safe_block.rotation_state + game.rotate() + assert game.current_block.rotation_state != initial_state + + +def test_rotate_undone_when_blocked(): + game = Game() + for _ in range(10): + game.move_left() + initial_state = game.current_block.rotation_state + states_after = set() + for _ in range(len(game.current_block.cells) * 2): + game.rotate() + states_after.add(game.current_block.rotation_state) + for s in states_after: + assert 0 <= s < len(game.current_block.cells) + + +def test_reset_clears_score(): + game = Game() + game.update_score(3, 0) + game.reset() + assert game.score == 0 + + +def test_reset_clears_grid(): + game = Game() + game.grid.grid[10][5] = 3 + game.reset() + for row in range(game.grid.num_rows): + for col in range(game.grid.num_cols): + assert game.grid.grid[row][col] == 0 + + +def test_reset_refills_block_bag(): + game = Game() + game.blocks = [] + game.reset() + assert len(game.blocks) == 5 diff --git a/tests/test_grid.py b/tests/test_grid.py new file mode 100755 index 0000000..62c9b55 --- /dev/null +++ b/tests/test_grid.py @@ -0,0 +1,105 @@ +from grid import Grid + + +def test_is_inside_valid(): + grid = Grid() + # All corners and a middle cell should be inside + assert grid.is_inside(0, 0) is True + assert grid.is_inside(19, 9) is True + assert grid.is_inside(0, 9) is True + assert grid.is_inside(19, 0) is True + assert grid.is_inside(10, 5) is True + + +def test_is_inside_invalid(): + grid = Grid() + assert grid.is_inside(-1, 0) is False + assert grid.is_inside(20, 0) is False + assert grid.is_inside(0, -1) is False + assert grid.is_inside(0, 10) is False + + +def test_is_empty_returns_true_for_zero(): + grid = Grid() + # Fresh grid cells are all 0, so they should be empty + assert grid.is_empty(0, 0) is True + assert grid.is_empty(10, 5) is True + assert grid.is_empty(19, 9) is True + + +def test_is_empty_returns_false_for_nonzero(): + grid = Grid() + grid.grid[5][3] = 1 + assert grid.is_empty(5, 3) is False + + +def test_is_row_full_false_when_empty(): + grid = Grid() + assert grid.is_row_full(0) is False + assert grid.is_row_full(10) is False + assert grid.is_row_full(19) is False + + +def test_is_row_full_true_when_all_filled(): + grid = Grid() + for col in range(grid.num_cols): + grid.grid[7][col] = 1 + assert grid.is_row_full(7) is True + + +def test_clear_row(): + grid = Grid() + # Fill row 5 with non-zero values + for col in range(grid.num_cols): + grid.grid[5][col] = 3 + grid.clear_row(5) + for col in range(grid.num_cols): + assert grid.grid[5][col] == 0 + + +def test_move_row_down(): + grid = Grid() + # Place a distinct pattern in row 3 + for col in range(grid.num_cols): + grid.grid[3][col] = col + 1 # values 1..10 + grid.move_row_down(3, 2) + # Row 3+2=5 should now contain the old row 3 values + for col in range(grid.num_cols): + assert grid.grid[5][col] == col + 1 + # Row 3 should be cleared + for col in range(grid.num_cols): + assert grid.grid[3][col] == 0 + + +def test_clear_full_rows_single(): + grid = Grid() + # Fill the bottom row completely + for col in range(grid.num_cols): + grid.grid[19][col] = 1 + cleared = grid.clear_full_rows() + assert cleared == 1 + # Row 19 should now be empty + for col in range(grid.num_cols): + assert grid.grid[19][col] == 0 + + +def test_clear_full_rows_multiple(): + grid = Grid() + # Fill bottom two rows completely + for col in range(grid.num_cols): + grid.grid[18][col] = 2 + grid.grid[19][col] = 1 + cleared = grid.clear_full_rows() + assert cleared == 2 + + +def test_reset(): + grid = Grid() + # Set some cells + grid.grid[0][0] = 5 + grid.grid[10][5] = 3 + grid.grid[19][9] = 7 + grid.reset() + for row in range(grid.num_rows): + for col in range(grid.num_cols): + assert grid.grid[row][col] == 0