diff --git a/tetris.py b/tetris.py index 38bb5b2..aa8b6dc 100644 --- a/tetris.py +++ b/tetris.py @@ -80,6 +80,17 @@ class Tetromino(NamedTuple): 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): key: int type: Type @@ -94,13 +105,17 @@ class Action(NamedTuple): SWAP_HOLD = auto() MOVE_LEFT = auto() MOVE_RIGHT = auto() + OPEN_HELP = auto() # Game over RESTART = auto() + # Help + CLOSE_HELP = auto() + @staticmethod def make_map(*acts): - return {act.key: act for act in acts} + return HashableDict({act.key: act for act in acts}) class Font: @@ -151,25 +166,29 @@ class Font: @cache def compute_extents(self, text: str) -> Size: text = text.lower() - multi_row = False total_width = 0 total_height = 0 last_width = 0 last_height = 0 for char in text: if char == "\n": - multi_row = True 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 + 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 + (multi_row * self.row_kern)) + return Size(total_width, total_height) def render_text( self, text: str, dest: pygame.surface.Surface, x: int, y: int, color @@ -179,6 +198,9 @@ class Font: 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 @@ -271,7 +293,7 @@ class Game: pygame.K_s, Action.Type.SWAP_HOLD, False, - "Swap the current piece and the held piece.", + "Swap the current and held piece.", ), Action( pygame.K_DOWN, Action.Type.DROP, True, "Drop the current piece." @@ -288,10 +310,30 @@ class Game: True, "Move the current piece right.", ), + Action( + pygame.K_h, + Action.Type.OPEN_HELP, + False, + "Toggle the help menu.", + ), ) 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.", + ), + ) FONT = Font( 6, @@ -721,20 +763,35 @@ class Game: ], ) SMALL_FONT = FONT.scaled(4, 2, 2) - TINY_FONT = FONT.scaled(2, 1, 1) + TINY_FONT = FONT.scaled(2, 2, 2) CONTROL_DISPLAY_BORDER = 5 - NEXT_HELD_BOX_SIZE = 5 + INFO_BOX_BORDER_SIZE = 5 + HELP_MENU_INSET = 10 CONTROLS_START_X = (BOARD_SIZE.width + 2) * CELL_SIZE.width + @staticmethod + @cache + 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): + def game_over(self) -> bool: return self._game_over @game_over.setter - def game_over(self, newval): + def game_over(self, newval: bool): if newval != self._game_over: self.pending_actions = [] self._game_over = newval @@ -743,6 +800,16 @@ class Game: 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) @@ -999,7 +1066,6 @@ class Game: elif not any(self.board[next(iter(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 @@ -1011,8 +1077,13 @@ class Game: else: self.clearing_frames += 1 - def draw_score(self): - text = f"Score: {self.score:05}" + def draw_score_and_instructions(self): + text = ( + "Press \nfor help.\n" + "\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 = ( @@ -1067,7 +1138,7 @@ class Game: box_size, box_size, ), - width=Game.NEXT_HELD_BOX_SIZE, + width=Game.INFO_BOX_BORDER_SIZE, ) if piece: piece_surf = self.get_scaled_piece(piece, 0.75) @@ -1101,11 +1172,44 @@ class Game: 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 game_loop(self): self.doing_drop = False self.maybe_clear_rows() - if not self.clearing_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: self.process_game_actions() self.advance_piece() else: @@ -1122,7 +1226,10 @@ class Game: self.draw_board_border() self.draw_held_and_next_piece() - self.draw_score() + self.draw_score_and_instructions() + + if self.help_mode: + self.process_and_draw_help_overlay() def start_new_game(self): self.clear_board() @@ -1162,29 +1269,57 @@ class Game: self.draw_board_border() self.draw_held_and_next_piece() - self.draw_score() + self.draw_score_and_instructions() self.draw_gray_overlay() - ss = self.screen.get_size() + sw, sh = self.screen.get_size() exts = self.FONT.compute_extents("Game Over!") - self.FONT.center_text( - "Game Over!", self.screen, ss[0] // 2, ss[1] // 2, "white" - ) score_text = f"Score: {self.score:05}" - self.SMALL_FONT.center_text( - score_text, + 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, - ss[0] // 2, - ss[1] // 2 + exts.height + self.FONT.row_kern, + 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", ) - exts2 = self.SMALL_FONT.compute_extents(score_text) - self.SMALL_FONT.center_text( + 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, - ss[0] // 2, - ss[1] // 2 + exts.height + exts2.height + 2 * self.FONT.row_kern, + sw // 2 - exts3.width // 2, + sh // 2 + - text_height // 2 + + exts.height + + exts2.height + + 2 * self.FONT.row_kern, "white", ) @@ -1232,6 +1367,8 @@ class Game: self.key_states = defaultdict(lambda: False) self.pending_actions = [] + self.help_mode = False + self.high_score = 0 self.start_new_game() def run(self):