diff --git a/tetris.py b/tetris.py index ea0cfea..9194446 100644 --- a/tetris.py +++ b/tetris.py @@ -2,7 +2,7 @@ import pygame import pygame.draw as draw from collections import namedtuple, defaultdict from typing import NamedTuple, Any, Iterable, Optional, Callable -from functools import cache +from functools import cache, lru_cache import random import math from enum import Enum, auto @@ -45,13 +45,53 @@ class Tetromino(NamedTuple): color: str shape: list[list] + @property + def height(self): + return len(self.shape) + @property def width(self): return len(self.shape[0]) @property - def height(self): - return len(self.shape) + @lru_cache(maxsize=16) + def x_adj(self): + def clz(arr: list): + for i, c in enumerate(arr): + if c: + return i + return len(arr) + + return min(map(clz, self.shape)) + + @property + @lru_cache(maxsize=16) + def y_adj(self): + for y, row in enumerate(self.shape): + for x, cell in enumerate(row): + if cell: + return y + return self.height + + @property + @lru_cache(maxsize=16) + def bounding_box_width(self): + def ctz(arr: list): + for i, c in enumerate(reversed(arr)): + if c: + return i + return len(arr) + + return max(map(lambda r: len(r) - ctz(r), self.shape)) + + @property + @lru_cache(maxsize=16) + def bounding_box_height(self): + for y, row in reversed(list(enumerate(self.shape))): + for x, cell in enumerate(row): + if cell: + return y + 1 + return 0 def rotated(self, times: int): """Return self rotated TIMES times to the right.""" @@ -164,7 +204,7 @@ class Font: self.glyphs, ) - @cache + @lru_cache(maxsize=16) def compute_extents(self, text: str) -> Size: text = text.lower() total_width = 0 @@ -267,7 +307,7 @@ class Game: NORMAL_DROP_SPEED = 3 # cells per second FAST_DROP_SPEED = 30 # cells per second MOVED_PROP_SPEED = 1 # cells per second - CLEAR_SPEED = 5 # frames per block + CLEAR_SPEED = 10 # frames per block BOARD_SIZE = Size(10, 20) # Cell count CELL_SIZE = Size(30, 30) CELL_BORDER_SIZE = CELL_SIZE.width // 10 @@ -288,13 +328,15 @@ class Game: "green": ColorSet("#00cd00", "#00ff00", "#009a00"), } TETROMINOS = [ - Tetromino("aqua", [[1, 1, 1, 1]]), + Tetromino( + "aqua", [[0, 0, 0, 0], [1, 1, 1, 1], [0, 0, 0, 0], [0, 0, 0, 0]] + ), Tetromino("yellow", [[1, 1], [1, 1]]), - Tetromino("purple", [[1, 1, 1], [0, 1, 0]]), - Tetromino("blue", [[0, 1], [0, 1], [1, 1]]), - Tetromino("orange", [[1, 0], [1, 0], [1, 1]]), - Tetromino("green", [[0, 1, 1], [1, 1, 0]]), - Tetromino("red", [[1, 1, 0], [0, 1, 1]]), + Tetromino("purple", [[0, 0, 0], [1, 1, 1], [0, 1, 0]]), + Tetromino("blue", [[0, 0, 1], [0, 0, 1], [0, 1, 1]]), + Tetromino("orange", [[1, 0, 0], [1, 0, 0], [1, 1, 0]]), + Tetromino("green", [[0, 1, 1], [1, 1, 0], [0, 0, 0]]), + Tetromino("red", [[1, 1, 0], [0, 1, 1], [0, 0, 0]]), ] GAME_ACTION_MAP = Action.make_map( Action( @@ -380,7 +422,7 @@ class Game: CONTROLS_START_X = (BOARD_SIZE.width + 2) * CELL_SIZE.width @staticmethod - @cache + @lru_cache(maxsize=1) def make_help_string(am: dict[int, Action]): key_names = [pygame.key.name(k) for k in am.keys()] max_key_len = max(map(len, key_names)) @@ -565,23 +607,27 @@ class Game: self.next_piece = self.random_tetromino() def intersects_board(self, x: int, y: int, piece: Tetromino): - pw, ph = piece.width, piece.height - if x < 0 or y < 0: + pw, ph = piece.bounding_box_width, piece.bounding_box_height + if x + piece.x_adj < 0 or y + piece.y_adj < 0: return True elif x + pw > Game.BOARD_SIZE.width or y + ph > Game.BOARD_SIZE.height: return True shape = piece.shape for cy, row in enumerate(shape): for cx, cell in enumerate(row): - if cell and self.board[y + cy][x + cx]: - return True + # some shapes jut out of the board, prevent that from crashing + try: + if cell and self.board[y + cy][x + cx]: + return True + except IndexError: + continue return False def rotate_current_piece(self, times: int): new_piece = self.current_block.rotated(times) cx, cy = self.current_block_pos - for dx in [0, -1]: - for dy in [1, 0, -1]: + for dx in [0, 1, -1]: + for dy in [0, 1, -1]: np = Cell(cx + dx, cy + dy) if not self.intersects_board(*np, new_piece): self.current_block = new_piece @@ -594,13 +640,22 @@ class Game: self.subcell_move += dx move_cell = int(self.subcell_move) self.subcell_move -= move_cell - self.did_user_move_or_spin = True if move_cell: new_pos = Cell( self.current_block_pos.x + move_cell, self.current_block_pos.y ) if not self.intersects_board(*new_pos, self.current_block): + self.did_user_move_or_spin = True self.current_block_pos = new_pos + # test if the user is moving to a valid cell and don't place if they + # are + else: + move_cell = int(math.copysign(1, self.subcell_move)) + new_pos = Cell( + self.current_block_pos.x + move_cell, self.current_block_pos.y + ) + if not self.intersects_board(*new_pos, self.current_block): + self.did_user_move_or_spin = True def process_game_actions(self): if not ( @@ -669,7 +724,7 @@ class Game: cp.x, cp.y + move_cell, self.current_block ): self.current_block_pos = Cell(cp.x, cp.y + move_cell) - # don't place the pice if the user moved it + # don't place the piece if the user moved it elif not self.did_user_move_or_spin: for dy in range(move_cell, -1, -1): if not self.intersects_board( @@ -708,17 +763,18 @@ class Game: if all(row): self.score += self.CLEAR_POINTS self.clearing_rows.add(y) - elif not any(self.board[next(iter(self.clearing_rows))]): - # Order matters here - for y in sorted(self.clearing_rows): - drop_rows_above_for_clear(y) - self.clearing_rows = set() - self.clearing_frames = self.CLEAR_SPEED else: if self.clearing_frames == self.CLEAR_SPEED: + if not any(self.board[next(iter(self.clearing_rows))]): + # Order matters here + for y in sorted(self.clearing_rows): + drop_rows_above_for_clear(y) + self.clearing_rows = set() + self.clearing_frames = self.CLEAR_SPEED + else: + for y in self.clearing_rows: + do_single_clear(y) self.clearing_frames = 0 - for y in self.clearing_rows: - do_single_clear(y) else: self.clearing_frames += 1 @@ -738,9 +794,9 @@ class Game: ) self.TINY_FONT.render_text(text, self.screen, x, y, "white") - @cache + @lru_cache(maxsize=4) def get_scaled_piece( - self, piece: Tetromino, scale: float + self, piece: Tetromino, scale: float | int, alpha: int = 255 ) -> pygame.surface.Surface: ow = piece.width * Game.CELL_SIZE.width oh = piece.height * Game.CELL_SIZE.height @@ -755,6 +811,8 @@ class Game: os_surf, ) sw, sh = int(ow * scale), int(oh * scale) + if alpha != 255: + os_surf.set_alpha(alpha) return pygame.transform.scale(os_surf, (sw, sh)) def draw_boxed_piece( @@ -846,6 +904,24 @@ class Game: self.action_map = Game.GAME_ACTION_MAP self.help_mode = False + def draw_ghost_piece(self): + x, y = self.current_block_pos + y += self.cells_for_hard_drop() + surf = self.get_scaled_piece(self.current_block, 1, 0x80) + self.screen.blit( + surf, + ((x + 1) * Game.CELL_SIZE.width, y * Game.CELL_SIZE.height), + ) + + def process_clear_actions(self): + # allow rotating the next piece during clear + while self.pending_actions: + match self.pending_actions.pop(0): + case Action.Type.ROTATE_LEFT: + self.next_piece = self.next_piece.rotated(-1) + case Action.Type.ROTATE_RIGHT: + self.next_piece = self.next_piece.rotated(1) + def game_loop(self): self.soft_drop = False self.hard_drop = False @@ -857,18 +933,18 @@ class Game: self.help_mode = True self.action_map = Game.HELP_ACTION_MAP elif not self.clearing_rows: + if not self.current_block: + self.swap_in_next_piece() + self.process_game_actions() self.advance_piece() else: - # ignore input while clearing - self.pending_actions = [] - - if not self.current_block: - self.swap_in_next_piece() + self.process_clear_actions() self.screen.fill("black") - if not self.clearing_rows: + if self.current_block: self.draw_current_block() + self.draw_ghost_piece() self.draw_board_content() self.draw_board_border() self.draw_held_and_next_piece()