diff --git a/tetris.py b/tetris.py index 02756fa..38bb5b2 100644 --- a/tetris.py +++ b/tetris.py @@ -43,7 +43,7 @@ def mirror(mat: list[list]) -> list[list]: class Tetromino(NamedTuple): color: str - shape: list[list[str]] + shape: list[list] @property def width(self): @@ -73,6 +73,12 @@ class Tetromino(NamedTuple): out.append("\n") return "".join(out) + def __eq__(self, other): + return id(self) == id(other) + + def __hash__(self): + return id(self) + class Action(NamedTuple): key: int @@ -716,13 +722,10 @@ class Game: ) SMALL_FONT = FONT.scaled(4, 2, 2) TINY_FONT = FONT.scaled(2, 1, 1) - SCORE_DISPLAY_BORDER = 5 + CONTROL_DISPLAY_BORDER = 5 + NEXT_HELD_BOX_SIZE = 5 CONTROLS_START_X = (BOARD_SIZE.width + 2) * CELL_SIZE.width - @staticmethod - def random_tetromino(): - return random.choice(Game.TETROMINOS) - def __init__(self): self._game_over = False @@ -740,15 +743,23 @@ class Game: else: self.action_map = Game.GAME_ACTION_MAP - def draw_block(self, x: int, y: int, color): + def random_tetromino(self): + if not self.cur_random_peice_seq: + self.cur_random_peice_seq = list(Game.TETROMINOS) + random.shuffle(self.cur_random_peice_seq) + return self.cur_random_peice_seq.pop(0) + + def draw_block(self, x: int, y: int, color, target=None): """Draw a block at (X, Y). Coordinates are for the top left corner.""" + if target is None: + target = self.screen draw.rect( - self.screen, + target, Game.PALETTE[color].norm, (x, y, Game.CELL_SIZE.width, Game.CELL_SIZE.height), ) draw.polygon( - self.screen, + target, Game.PALETTE[color].light, [ (x, y), @@ -767,7 +778,7 @@ class Game: ], ) draw.polygon( - self.screen, + target, Game.PALETTE[color].dark, [ (x + Game.CELL_SIZE.width, y), @@ -861,10 +872,16 @@ class Game: self.handle_key_event(event.type, event.key) def swap_with_hold(self): - to_swap = self.held_piece or Game.random_tetromino() + to_swap = self.held_piece + used_next = False + if not to_swap: + to_swap = self.next_piece + used_next = True if not self.intersects_board(*self.current_block_pos, to_swap): self.held_piece = self.current_block self.current_block = to_swap + if used_next: + self.next_piece = self.random_tetromino() def intersects_board(self, x: int, y: int, piece: Tetromino): pw, ph = piece.width, piece.height @@ -970,7 +987,8 @@ class Game: break def drop_rows_above_for_clear(y: int): - self.board[: y + 1] = self.board[:y] + self.board.pop(y) + # this also restores indices for future clears self.board.insert(0, [None] * Game.BOARD_SIZE.width) if not self.clearing_rows: @@ -979,7 +997,9 @@ class Game: self.score += self.CLEAR_POINTS self.clearing_rows.add(y) elif not any(self.board[next(iter(self.clearing_rows))]): - for y in self.clearing_rows: + # Order matters here + for y in sorted(self.clearing_rows): + print(y) drop_rows_above_for_clear(y) self.clearing_rows = set() self.clearing_frames = self.CLEAR_SPEED @@ -994,10 +1014,93 @@ class Game: def draw_score(self): text = f"Score: {self.score:05}" exts = self.TINY_FONT.compute_extents(text) - x = Game.CONTROLS_START_X + Game.SCORE_DISPLAY_BORDER - y = self.screen.get_size()[1] - Game.SCORE_DISPLAY_BORDER - exts.height + x = Game.CONTROLS_START_X + Game.CONTROL_DISPLAY_BORDER + y = ( + self.screen.get_size()[1] + - Game.CONTROL_DISPLAY_BORDER + - exts.height + ) self.TINY_FONT.render_text(text, self.screen, x, y, "white") + @cache + def get_scaled_piece( + self, piece: Tetromino, scale: float + ) -> pygame.surface.Surface: + ow = piece.width * Game.CELL_SIZE.width + oh = piece.height * Game.CELL_SIZE.height + os_surf = pygame.surface.Surface((ow, oh), pygame.SRCALPHA) + for y, row in enumerate(piece.shape): + for x, cell in enumerate(row): + if cell: + self.draw_block( + x * Game.CELL_SIZE.width, + y * Game.CELL_SIZE.height, + piece.color, + os_surf, + ) + sw, sh = int(ow * scale), int(oh * scale) + return pygame.transform.scale(os_surf, (sw, sh)) + + def draw_boxed_piece( + self, + text: str, + piece: Tetromino | None, + x: int, + y: int, + box_size: int, + ) -> Size: + """Return the overall bounding box.""" + text_ext = Game.TINY_FONT.compute_extents(text) + Game.TINY_FONT.center_text( + text, + self.screen, + x + box_size // 2, + y + text_ext.height // 2, + "white", + ) + pygame.draw.rect( + self.screen, + Game.PALETTE["gray"].norm, + ( + x, + y + text_ext.height + Game.TINY_FONT.row_kern, + box_size, + box_size, + ), + width=Game.NEXT_HELD_BOX_SIZE, + ) + if piece: + piece_surf = self.get_scaled_piece(piece, 0.75) + pw, ph = piece_surf.get_size() + self.screen.blit( + piece_surf, + ( + x + box_size // 2 - pw // 2, + y + + text_ext.height + + Game.TINY_FONT.row_kern + + box_size // 2 + - ph // 2, + ), + ) + return Size( + max(box_size, text_ext.width), + text_ext.height + box_size + Game.TINY_FONT.row_kern, + ) + + def draw_held_and_next_piece(self): + width = int(Game.CONTROLS_WIDTH * 0.8) + y = Game.CONTROL_DISPLAY_BORDER + x = Game.CONTROLS_START_X + Game.CONTROLS_WIDTH // 2 - width // 2 + exts = self.draw_boxed_piece("Hold", self.held_piece, x, y, width) + self.draw_boxed_piece( + "Next", + self.next_piece, + x, + y + exts.height + 2 * Game.CONTROL_DISPLAY_BORDER, + width, + ) + def game_loop(self): self.doing_drop = False @@ -1010,19 +1113,21 @@ class Game: self.pending_actions = [] if not self.current_block: - self.generate_new_block() + self.swap_in_next_piece() self.screen.fill("black") if not self.clearing_rows: self.draw_current_block() self.draw_board_content() self.draw_board_border() + self.draw_held_and_next_piece() self.draw_score() def start_new_game(self): self.clear_board() - self.generate_new_block() + self.next_piece = None + self.swap_in_next_piece(True) self.held_piece = None self.subcell_move = 0 self.subcell_drop = 0 @@ -1055,6 +1160,7 @@ class Game: self.draw_board_content() self.draw_current_block() self.draw_board_border() + self.draw_held_and_next_piece() self.draw_score() @@ -1094,8 +1200,14 @@ class Game: pygame.display.flip() self.clock.tick(Game.FRAMERATE) - def generate_new_block(self): - self.current_block = Game.random_tetromino() + def swap_in_next_piece(self, force: bool = False): + self.cur_random_peice_seq = None + if not self.next_piece or force: + self.next_piece = self.random_tetromino() + self.current_block = self.random_tetromino() + else: + self.current_block = self.next_piece + self.next_piece = self.random_tetromino() # top left corner self.current_block_pos = Cell( self.BOARD_SIZE.width // 2 @@ -1108,7 +1220,9 @@ class Game: def init(self): pygame.init() - self.screen = pygame.display.set_mode(Game.WINDOW_SIZE, pygame.SCALED) + self.screen = pygame.display.set_mode( + Game.WINDOW_SIZE, pygame.SCALED | pygame.HWACCEL + ) pygame.display.set_caption("Tetris") self.clock = pygame.time.Clock()