import pygame import pygame.draw as draw from collections import namedtuple, defaultdict from typing import NamedTuple, Any, Iterable, Optional, Callable from functools import cache, lru_cache import random import math from enum import Enum, auto Cell = namedtuple("Cell", ["x", "y"]) Size = namedtuple("Size", ["width", "height"]) ColorSet = namedtuple("ColorSet", ["norm", "light", "dark"]) class Direction(int, Enum): LEFT = -1 RIGHT = 1 def make_matrix(w: int, h: int, elt: Any = None) -> list[list]: if not w: return [] return [[elt] * w for _ in range(h)] def flip(mat: list[list]) -> list[list]: return list(reversed(mat)) def transpose(mat: list[list]) -> list[list]: if not mat: return [] out = make_matrix(len(mat), len(mat[0])) for x in range(len(mat[0])): for y in range(len(mat)): out[x][y] = mat[y][x] return out def mirror(mat: list[list]) -> list[list]: return [list(reversed(row)) for row in mat] 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 @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.""" times %= 4 if times == 0: return self if times == 1: return Tetromino(self.color, mirror(transpose(self.shape))) if times == 2: return Tetromino(self.color, mirror(flip(self.shape))) if times == 3: return Tetromino(self.color, flip(transpose(self.shape))) def __repr__(self): out = [] for row in self.shape: for cell in row: out.append("*" if cell else " ") out.append("\n") return "".join(out) def __eq__(self, other): return id(self) == id(other) def __hash__(self): return id(self) class HashableDict(dict): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def __eq__(self, other): return id(self) == id(other) def __hash__(self): return id(self) class Action(NamedTuple): class Type(Enum): # In Game ROTATE_LEFT = auto() ROTATE_RIGHT = auto() SOFT_DROP = auto() HARD_DROP = auto() SWAP_HOLD = auto() MOVE_LEFT = auto() MOVE_RIGHT = auto() OPEN_HELP = auto() # Game over (also in game) RESTART = auto() # Help CLOSE_HELP = auto() key: int type: Type repeat: bool desc: str @staticmethod def make_map(*acts): return HashableDict({act.key: act for act in acts}) class Font: class Glyph(NamedTuple): char: str bitmap: list[str] @property def width(self): return len(self.bitmap[0]) @property def height(self): return len(self.bitmap) @property def size(self) -> Size: return Size(self.width, self.height) def __init__( self, scale: int, kern: int, row_kern: int, glyphs: Iterable[Glyph] | dict[str, Glyph], ): self.scale = scale self.kern = kern self.row_kern = row_kern if isinstance(glyphs, dict): self.glyphs: dict[str, Font.Glyph] = dict(glyphs) else: self.glyphs: dict[str, Font.Glyph] = {g.char: g for g in glyphs} def scaled( self, new_scale: int, new_kern: Optional[int], new_row_kern: Optional[int], ): return Font( new_scale, self.kern if new_kern is None else new_kern, self.row_kern if new_row_kern is None else new_row_kern, self.glyphs, ) @lru_cache(maxsize=16) def compute_extents(self, text: str) -> Size: text = text.lower() total_width = 0 total_height = 0 last_width = 0 last_height = 0 for char in text: if char == "\n": total_width = max(total_width, last_width) if last_width == 0: # empty line last_height = self.glyphs[" "].height * self.scale last_width = 0 total_height += last_height + self.row_kern last_height = 0 else: glyph = self.glyphs[char] last_width += glyph.width * self.scale + self.kern last_height = max(last_height, glyph.height * self.scale) total_width = max(last_width, total_width) - self.kern if total_width == 0: # empty line last_height = self.glyphs[" "].height * self.scale total_height += last_height return Size(total_width, total_height) def render_text( self, text: str, dest: pygame.surface.Surface, x: int, y: int, color ): text = text.lower() initial_x = x max_height = 0 for char in text: if char == "\n": if x == initial_x: # empty row max_height = self.glyphs[" "].height * self.scale y += max_height + self.row_kern max_height = 0 x = initial_x else: glyph = self.glyphs[char] dest.blit(self.render_glyph(char, color), (x, y)) x += glyph.width * self.scale + self.kern max_height = max(max_height, glyph.height * self.scale) def center_text( self, text: str, dest: pygame.surface.Surface, x: int, y: int, color ): text = text.lower() exts = self.compute_extents(text) self.render_text( text, dest, x - (exts.width // 2), y - (exts.height // 2), color ) @cache def render_glyph(self, char: str, color) -> pygame.surface.Surface: char = char.lower() glyph = self.glyphs[char] size = tuple(map(lambda d: d * self.scale, glyph.size)) surf = pygame.surface.Surface(size, pygame.SRCALPHA) for y, row in enumerate(glyph.bitmap): for x, cell in enumerate(row): if cell != " ": pygame.draw.rect( surf, color, ( x * self.scale, y * self.scale, self.scale, self.scale, ), ) return surf class LazyVariable: def __init__(self, thunk: Callable[[], Any]): self.thunk = thunk self.need_eval = True self.value = None def __get__(self, instance: Any, owner: Optional[type] = None): if self.need_eval: self.value = self.thunk() self.need_eval = False return self.value def __set__(self, instance: Any, value: Any): self.value = value self.need_eval = False class Game: # Points DROP_POINTS = 1 HARD_DROP_POINTS = 2 POINTS_PER_LINE = [100, 300, 500, 800] LINES_PER_LEVEL = 10 MOVE_SPEED = 20 # cells per second MOVE_INITIAL_SLOWDOWN = 3.0 # cells DROP_LEVEL_SCALE = 0.025 # These are *= 1 + (DROP_LEVEL_SCALE * (LEVEL - 1)) NORMAL_DROP_SPEED = 3 # cells per second FAST_DROP_SPEED = 30 # cells per second MOVED_DROP_SPEED = 1 # cells per second DROP_CLEAR_SCALE = 0.025 # This is *= 1 - (DROP_CLEAR_SCALE * (LEVEL - 1)) CLEAR_SPEED = 15 # frames per block BOARD_SIZE = Size(10, 20) # Cell count CELL_SIZE = Size(30, 30) CELL_BORDER_SIZE = CELL_SIZE.width // 10 CONTROLS_WIDTH = 160 FRAMERATE = 120 WINDOW_SIZE = Size( (BOARD_SIZE.width + 2) * CELL_SIZE.width + CONTROLS_WIDTH, (BOARD_SIZE.height + 2) * CELL_SIZE.height, ) PALETTE = { "gray": ColorSet("#787878", "#9a9a9a", "#303030"), "purple": ColorSet("#9a00cd", "#cd00ff", "#66009a"), "yellow": ColorSet("#cdcd00", "#ffff00", "#9a9a00"), "red": ColorSet("#cd0000", "#ff0000", "#9a0000"), "orange": ColorSet("#cd6600", "#ff8900", "#9a4200"), "blue": ColorSet("#0000cd", "#0000ff", "#00009a"), "aqua": ColorSet("#00cdcd", "#00ffff", "#009a9a"), "green": ColorSet("#00cd00", "#00ff00", "#009a00"), } TETROMINOS = [ 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", [[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( pygame.K_z, Action.Type.ROTATE_LEFT, False, "Rotate the current piece left.", ), Action( pygame.K_x, Action.Type.ROTATE_RIGHT, False, "Rotate the current piece right.", ), Action( pygame.K_c, Action.Type.SWAP_HOLD, False, "Swap the current and held pieces.", ), Action( pygame.K_DOWN, Action.Type.SOFT_DROP, True, "Soft drop the current piece.", ), Action( pygame.K_SPACE, Action.Type.HARD_DROP, True, "Hard drop the current piece.", ), Action( pygame.K_LEFT, Action.Type.MOVE_LEFT, True, "Move the current piece left.", ), Action( pygame.K_RIGHT, Action.Type.MOVE_RIGHT, True, "Move the current piece right.", ), Action( pygame.K_r, Action.Type.RESTART, False, "Start a new game.", ), Action( pygame.K_h, Action.Type.OPEN_HELP, False, "Toggle the help menu/pause.", ), ) GAME_OVER_ACTION_MAP = Action.make_map( Action(pygame.K_r, Action.Type.RESTART, False, "Start a new game.") ) HELP_ACTION_MAP = Action.make_map( Action( pygame.K_ESCAPE, Action.Type.CLOSE_HELP, False, "Close the help menu.", ), Action( pygame.K_h, Action.Type.CLOSE_HELP, False, "Close the help menu.", ), ) # The actual font data is long, so I put it at the end FONT = LazyVariable(lambda: Font(6, 3, 3, GLYPH_DATA)) SMALL_FONT = LazyVariable(lambda: Game.FONT.scaled(4, 2, 2)) TINY_FONT = LazyVariable(lambda: Game.FONT.scaled(2, 2, 2)) CONTROL_DISPLAY_BORDER = 5 INFO_BOX_BORDER_SIZE = 5 HELP_MENU_INSET = 10 CONTROLS_START_X = (BOARD_SIZE.width + 2) * CELL_SIZE.width @staticmethod @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)) out = "" for act in am.values(): if out: out += "\n" out += f"{pygame.key.name(act.key).rjust(max_key_len)}: {act.desc}" return out def __init__(self): self._game_over = False self.high_score = 0 self._score = 0 @property def game_over(self) -> bool: return self._game_over @game_over.setter def game_over(self, newval: bool): if newval != self._game_over: self.pending_actions = [] self._game_over = newval if self._game_over: self.action_map = Game.GAME_OVER_ACTION_MAP else: self.action_map = Game.GAME_ACTION_MAP @property def score(self) -> int: return self._score @score.setter def score(self, newval: int): self._score = newval if newval > self.high_score: self.high_score = newval 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( target, Game.PALETTE[color].norm, (x, y, Game.CELL_SIZE.width, Game.CELL_SIZE.height), ) draw.polygon( target, Game.PALETTE[color].light, [ (x, y), (x + Game.CELL_SIZE.width, y), ( x + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE, y + Game.CELL_BORDER_SIZE, ), (x + Game.CELL_BORDER_SIZE, y + Game.CELL_BORDER_SIZE), ( x + Game.CELL_BORDER_SIZE, y + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE, ), (x, y + Game.CELL_SIZE.height), (x, y), ], ) draw.polygon( target, Game.PALETTE[color].dark, [ (x + Game.CELL_SIZE.width, y), ( x + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE, y + Game.CELL_BORDER_SIZE, ), ( x + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE, y + Game.CELL_SIZE.height - Game.CELL_BORDER_SIZE, ), ( x + Game.CELL_BORDER_SIZE, y + Game.CELL_SIZE.width - Game.CELL_BORDER_SIZE, ), (x, y + Game.CELL_SIZE.height), (x + Game.CELL_SIZE.width, y + Game.CELL_SIZE.height), (x + Game.CELL_SIZE.width, y), ], ) def draw_board_border(self): bwidth = (Game.BOARD_SIZE.width + 2) * Game.CELL_SIZE.width bheight = Game.WINDOW_SIZE.height for x in range(Game.BOARD_SIZE.width + 2): self.draw_block(x * Game.CELL_SIZE.width, 0, "gray") self.draw_block( x * Game.CELL_SIZE.width, bheight - Game.CELL_SIZE.height, "gray", ) for y in range(1, Game.BOARD_SIZE.height + 1): self.draw_block(0, y * game.CELL_SIZE.height, "gray") self.draw_block( bwidth - Game.CELL_SIZE.width, y * game.CELL_SIZE.height, "gray", ) def draw_board_content(self): for x in range(Game.BOARD_SIZE.width): for y in range(Game.BOARD_SIZE.height): if self.board[y][x]: self.draw_block( (x + 1) * Game.CELL_SIZE.width, (y + 1) * Game.CELL_SIZE.height, self.board[y][x], ) def draw_current_block(self): start_x, start_y = self.current_block_pos for x in range(self.current_block.width): for y in range(self.current_block.height): if self.current_block.shape[y][x]: screen_x = (start_x + x + 1) * Game.CELL_SIZE.width screen_y = (start_y + y + 1) * Game.CELL_SIZE.height self.draw_block( screen_x, screen_y, self.current_block.color ) def frame_time(self) -> float: ticks = self.clock.get_time() if not ticks: return 1 / Game.FRAMERATE else: return ticks / 1000 def handle_key_event(self, typ, key): if typ == pygame.KEYDOWN: if key in self.action_map: self.pending_actions.append(self.action_map[key].type) self.key_states[key] = True elif typ == pygame.KEYUP: self.key_states[key] = False def handle_events(self): # process already pushed keys for key, state in self.key_states.items(): if ( state and key in self.action_map and self.action_map[key].repeat ): self.pending_actions.append(self.action_map[key].type) # now process new events for event in pygame.event.get(): match event.type: case pygame.QUIT: self.running = False case pygame.KEYDOWN | pygame.KEYUP: self.handle_key_event(event.type, event.key) def swap_with_hold(self): if self.did_swap: return 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.move_piece_back_to_top() self.did_swap = True 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.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): # 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: # a cell was out of bounds of the board, this is a hit! return True 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, -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 self.current_block_pos = np self.did_user_move_or_spin = True return def move_current_piece(self, dir: Direction): if self.was_move_down != dir: self.subcell_move = 0 move_cell = dir self.was_move_down = dir self.inhibit_next_move = Game.MOVE_INITIAL_SLOWDOWN else: dx = dir * Game.MOVE_SPEED * self.frame_time() self.subcell_move += dx if self.inhibit_next_move > 0.0: oi = self.inhibit_next_move self.inhibit_next_move -= abs(self.subcell_move) if self.inhibit_next_move <= 0.0: self.inhibit_next_move = 0.0 self.subcell_move -= oi else: self.subcell_move = 0 move_cell = int(self.subcell_move) self.subcell_move -= move_cell 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): move_set = {Action.Type.MOVE_LEFT, Action.Type.MOVE_RIGHT} & set( self.pending_actions ) move_both = len(move_set) == 2 if not move_set: self.was_move_down = None self.inhibit_next_move = 0.0 self.subcell_move = 0 if not ( {Action.Type.SOFT_DROP, Action.Type.HARD_DROP} & set(self.pending_actions) ): self.stop_drop = False while self.pending_actions: match self.pending_actions.pop(0): case Action.Type.ROTATE_LEFT: self.rotate_current_piece(Direction.LEFT) case Action.Type.ROTATE_RIGHT: self.rotate_current_piece(Direction.RIGHT) case Action.Type.SWAP_HOLD: self.swap_with_hold() case Action.Type.SOFT_DROP if not self.stop_drop: self.soft_drop = True case Action.Type.HARD_DROP if not self.stop_drop: self.hard_drop = True case Action.Type.MOVE_LEFT if not move_both: self.move_current_piece(Direction.LEFT) self.was_move_down = Direction.LEFT case Action.Type.MOVE_RIGHT if not move_both: self.move_current_piece(Direction.RIGHT) self.was_move_down = Direction.RIGHT case Action.Type.RESTART: self.start_new_game() self.pending_actions = [] def place_piece(self, x: int, y: int, piece: Tetromino): for dy, row in enumerate(piece.shape): for dx, cell in enumerate(row): if cell: self.board[y + dy][x + dx] = piece.color # prevent user from accidentally dropping next piece too self.stop_drop = True self.subcell_drop = 0 def cells_for_hard_drop(self): cx, cy = self.current_block_pos dy = 0 while not self.intersects_board(cx, cy + dy, self.current_block): dy += 1 return dy def advance_piece(self): speed = Game.NORMAL_DROP_SPEED if self.did_user_move_or_spin: speed = Game.MOVED_DROP_SPEED elif self.soft_drop: speed = Game.FAST_DROP_SPEED speed *= 1 + (Game.DROP_CLEAR_SCALE * (self.level - 1)) self.subcell_drop += speed * self.frame_time() move_cell = int(self.subcell_drop) self.subcell_drop -= move_cell if self.hard_drop: move_cell = self.cells_for_hard_drop() if not move_cell: return cp = self.current_block_pos if not self.intersects_board( cp.x, cp.y + move_cell, self.current_block ): self.score += move_cell * Game.DROP_POINTS * self.level self.current_block_pos = Cell(cp.x, cp.y + move_cell) # don't place the piece if the user moved it elif not self.did_user_move_or_spin: if self.hard_drop: self.score += move_cell * Game.HARD_DROP_POINTS * self.level for dy in range(move_cell, -1, -1): if not self.intersects_board( cp.x, cp.y + dy, self.current_block ): self.place_piece(cp.x, cp.y + dy, self.current_block) self.current_block = None self.did_swap = False return self.game_over = True @staticmethod def generate_clear_cells(): bw = Game.BOARD_SIZE.width if bw % 2 == 0: return zip(range(bw // 2 - 1, -1, -1), range(bw // 2, bw)) else: return zip(range(bw // 2, -1, -1), range(bw // 2, bw)) def calc_clear_speed(self): return Game.CLEAR_SPEED * ( 1 - (Game.DROP_CLEAR_SCALE * (self.level - 1)) ) def maybe_clear_rows(self): def do_single_clear(y: int): row = self.board[y] for x1, x2 in Game.generate_clear_cells(): if row[x1]: row[x1] = None row[x2] = None break def drop_rows_above_for_clear(y: int): 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: for y, row in enumerate(self.board): if all(row): self.clearing_rows.add(y) if self.clearing_rows: self.score += ( Game.POINTS_PER_LINE[len(self.clearing_rows) - 1] * self.level ) else: speed = self.calc_clear_speed() if self.clearing_frames >= speed: if not any(self.board[next(iter(self.clearing_rows))]): self.cleared_this_level += len(self.clearing_rows) if self.cleared_this_level >= 10: self.level += self.cleared_this_level // 10 self.cleared_this_level %= 10 # Order matters here for y in sorted(self.clearing_rows): drop_rows_above_for_clear(y) self.clearing_rows = set() self.clearing_frames = speed else: for y in self.clearing_rows: do_single_clear(y) self.clearing_frames = 0 else: self.clearing_frames += 1 def draw_score_and_instructions(self): text = ( "Press \nfor help\nor pause.\n" "\n" f"Level: {self.level:02}\n" f"Score: {self.score:05}\n" f" HS: {self.high_score:05}" ) exts = self.TINY_FONT.compute_extents(text) 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") @lru_cache(maxsize=4) def get_scaled_piece( 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 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) if alpha != 255: os_surf.set_alpha(alpha) 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.INFO_BOX_BORDER_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 process_and_draw_help_overlay(self): self.draw_gray_overlay() hs = Game.make_help_string(Game.GAME_ACTION_MAP) exts = Game.TINY_FONT.compute_extents(hs) sw, sh = self.screen.get_size() tot_border = Game.INFO_BOX_BORDER_SIZE + Game.HELP_MENU_INSET box_x = sw // 2 - exts.width // 2 - tot_border box_y = sh // 2 - exts.height // 2 - tot_border box_w = exts.width + 2 * tot_border box_h = exts.height + 2 * tot_border pygame.draw.rect(self.screen, "black", (box_x, box_y, box_w, box_h)) pygame.draw.rect( self.screen, Game.PALETTE["gray"].norm, (box_x, box_y, box_w, box_h), width=Game.INFO_BOX_BORDER_SIZE, ) Game.TINY_FONT.center_text(hs, self.screen, sw // 2, sh // 2, "white") if Action.Type.CLOSE_HELP in self.pending_actions: self.pending_actions = [] 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 self.did_user_move_or_spin = False self.maybe_clear_rows() if Action.Type.OPEN_HELP in self.pending_actions: # do no matter what 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: self.process_clear_actions() self.screen.fill("black") 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() self.draw_score_and_instructions() if self.help_mode: self.process_and_draw_help_overlay() def start_new_game(self): self.was_move_down = None self.inhibit_next_move = 0.0 self.cur_random_peice_seq = None self.clear_board() self.next_piece = None self.swap_in_next_piece(True) self.held_piece = None self.did_swap = False self.subcell_move = 0 self.subcell_drop = 0 self.clearing_rows = set() # immediately clear the first block self.score = 0 self.level = 1 self.cleared_this_level = 0 # depends on level self.clearing_frames = self.calc_clear_speed() self.held_piece = None self.game_over = False def handle_game_over_actions(self): while self.pending_actions: act = self.pending_actions.pop(0) if act == Action.Type.RESTART: self.start_new_game() def make_gray_overlay(self): s = pygame.surface.Surface(self.screen.get_size(), pygame.SRCALPHA) pygame.draw.rect(s, "#44444466", (0, 0, *s.get_size())) return s def draw_gray_overlay(self): self.screen.blit(self.gray_overlay, (0, 0)) def game_over_loop(self): self.handle_game_over_actions() if not self.game_over: return self.screen.fill("black") self.draw_board_content() self.draw_current_block() self.draw_board_border() self.draw_held_and_next_piece() self.draw_score_and_instructions() self.draw_gray_overlay() sw, sh = self.screen.get_size() exts = self.FONT.compute_extents("Game Over!") score_text = f"Score: {self.score:05}" exts2 = self.SMALL_FONT.compute_extents(score_text) exts3 = self.SMALL_FONT.compute_extents("Press to retry.") text_width = max(exts.width, exts2.width, exts3.width) text_height = ( exts.height + exts2.height + exts3.height + 2 * self.FONT.row_kern ) tot_border = Game.INFO_BOX_BORDER_SIZE + Game.HELP_MENU_INSET box_x = sw // 2 - text_width // 2 - tot_border box_y = sh // 2 - text_height // 2 - tot_border box_w = text_width + 2 * tot_border box_h = text_height + 2 * tot_border pygame.draw.rect(self.screen, "black", (box_x, box_y, box_w, box_h)) pygame.draw.rect( self.screen, Game.PALETTE["gray"].norm, (box_x, box_y, box_w, box_h), width=Game.INFO_BOX_BORDER_SIZE, ) self.FONT.render_text( "Game Over!", self.screen, sw // 2 - exts.width // 2, sh // 2 - text_height // 2, "white", ) self.SMALL_FONT.render_text( score_text, self.screen, sw // 2 - exts2.width // 2, sh // 2 - text_height // 2 + exts.height + self.FONT.row_kern, "white", ) self.SMALL_FONT.render_text( "Press to retry.", self.screen, sw // 2 - exts3.width // 2, sh // 2 - text_height // 2 + exts.height + exts2.height + 2 * self.FONT.row_kern, "white", ) def loop(self): self.running = True while self.running: self.handle_events() if self.game_over: self.game_over_loop() else: self.game_loop() pygame.display.flip() self.clock.tick(Game.FRAMERATE) def move_piece_back_to_top(self): self.current_block_pos = Cell( self.BOARD_SIZE.width // 2 - math.ceil(self.current_block.bounding_box_width / 2), 0 - self.current_block.y_adj, ) def swap_in_next_piece(self, force: bool = False): 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() self.move_piece_back_to_top() def clear_board(self): self.board = make_matrix(*Game.BOARD_SIZE) # not in __init__ to facilitate debugging in ipython def init(self): pygame.init() self.screen = pygame.display.set_mode( Game.WINDOW_SIZE, pygame.SCALED | pygame.HWACCEL ) pygame.display.set_caption("Tetris") self.clock = pygame.time.Clock() self.gray_overlay = self.make_gray_overlay() pygame.key.stop_text_input() self.key_states = defaultdict(lambda: False) self.pending_actions = [] self.stop_drop = False self.help_mode = False self.high_score = 0 self.start_new_game() def run(self): self.init() self.loop() GLYPH_DATA = [ Font.Glyph( " ", [ " ", " ", " ", " ", " ", ], ), Font.Glyph( "a", [ "*****", "* *", "*****", "* *", "* *", ], ), Font.Glyph( "b", [ "**** ", "* *", "*****", "* *", "**** ", ], ), Font.Glyph( "c", [ "*****", "* *", "* ", "* *", "*****", ], ), Font.Glyph( "d", [ "**** ", "* *", "* *", "* *", "**** ", ], ), Font.Glyph( "e", [ "*****", "* ", "**** ", "* ", "*****", ], ), Font.Glyph( "f", [ "*****", "* ", "**** ", "* ", "* ", ], ), Font.Glyph( "g", [ "*****", "* ", "* **", "* *", "*****", ], ), Font.Glyph( "h", [ "* *", "* *", "*****", "* *", "* *", ], ), Font.Glyph( "i", [ "*****", " * ", " * ", " * ", "*****", ], ), Font.Glyph( "j", [ "*****", " * ", " * ", "* * ", " ** ", ], ), Font.Glyph( "k", [ "* *", "* * ", "*** ", "* * ", "* *", ], ), Font.Glyph( "l", [ "* ", "* ", "* ", "* ", "*****", ], ), Font.Glyph( "m", [ "* *", "** **", "* * *", "* *", "* *", ], ), Font.Glyph( "n", [ "* *", "** *", "* * *", "* **", "* *", ], ), Font.Glyph( "o", [ " *** ", "* *", "* *", "* *", " *** ", ], ), Font.Glyph( "p", [ "**** ", "* *", "**** ", "* ", "* ", ], ), Font.Glyph( "q", [ " *** ", "* *", "* * *", "* * ", " ** *", ], ), Font.Glyph( "r", [ "**** ", "* *", "**** ", "* * ", "* *", ], ), Font.Glyph( "s", [ " ****", "* ", " *** ", " *", "**** ", ], ), Font.Glyph( "t", [ "*****", " * ", " * ", " * ", " * ", ], ), Font.Glyph( "u", [ "* *", "* *", "* *", "* *", " *** ", ], ), Font.Glyph( "v", [ "* *", "* *", "* *", " * * ", " * ", ], ), Font.Glyph( "w", [ "* *", "* *", "* * *", "** **", "* *", ], ), Font.Glyph( "x", [ "* *", " * * ", " * ", " * * ", "* *", ], ), Font.Glyph( "y", [ "* *", " * * ", " * ", " * ", " * ", ], ), Font.Glyph( "z", [ "*****", " * ", " * ", " * ", "*****", ], ), Font.Glyph( "0", [ " *** ", "* *", "* * *", "* *", " *** ", ], ), Font.Glyph( "1", [ " ** ", "* * ", " * ", " * ", "*****", ], ), Font.Glyph( "2", [ " *** ", "* *", " * ", " * ", " ****", ], ), Font.Glyph( "3", [ " *** ", "* *", " ***", "* *", " *** ", ], ), Font.Glyph( "4", [ "* * ", "* * ", "*****", " * ", " * ", ], ), Font.Glyph( "5", [ "*****", "* ", "**** ", " *", "**** ", ], ), Font.Glyph( "6", [ " ****", "* ", "**** ", "* *", " *** ", ], ), Font.Glyph( "7", [ "*****", " * ", " * ", " * ", "* ", ], ), Font.Glyph( "8", [ " *** ", "* *", " *** ", "* *", " *** ", ], ), Font.Glyph( "9", [ " *** ", "* *", " ****", " *", "**** ", ], ), Font.Glyph( ".", [ " ", " ", " ", " ", " *", ], ), Font.Glyph( "!", [ " *", " *", " *", " ", " *", ], ), Font.Glyph( ":", [ " ", " *", " ", " *", " ", ], ), Font.Glyph( "<", [ " **", " ** ", "* ", " ** ", " **", ], ), Font.Glyph( ">", [ "** ", " ** ", " *", " ** ", "** ", ], ), Font.Glyph( "/", [ " *", " * ", " * ", " * ", "* ", ], ), ] if __name__ == "__main__": game = Game() game.run()