diff --git a/main.py b/main.py index dc496ef..02756fa 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,8 @@ import pygame import pygame.draw as draw from collections import namedtuple, defaultdict -from typing import NamedTuple, Any +from typing import NamedTuple, Any, Iterable, Optional +from functools import cache import random import math from enum import Enum, auto @@ -80,6 +81,7 @@ class Action(NamedTuple): desc: str class Type(Enum): + # In Game ROTATE_LEFT = auto() ROTATE_RIGHT = auto() DROP = auto() @@ -87,15 +89,136 @@ class Action(NamedTuple): MOVE_LEFT = auto() MOVE_RIGHT = auto() + # Game over + RESTART = auto() + @staticmethod def make_map(*acts): return {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, + ) + + @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) + last_width = 0 + total_height += last_height + 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 + total_height += last_height + return Size(total_width, total_height + (multi_row * self.row_kern)) + + 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": + 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 Game: + PLACE_POINTS = 10 + CLEAR_POINTS = 100 MOVE_SPEED = 20 # cells per second NORMAL_DROP_SPEED = 3 # cells per second - FAST_DROP_SPEED = 10 # cells per second + FAST_DROP_SPEED = 30 # cells per second CLEAR_SPEED = 5 # frames per block BOARD_SIZE = Size(10, 20) # Cell count CELL_SIZE = Size(30, 30) @@ -125,8 +248,7 @@ class Game: Tetromino("green", [[0, 1, 1], [1, 1, 0]]), Tetromino("red", [[1, 1, 0], [0, 1, 1]]), ] - - ACTION_MAP = Action.make_map( + GAME_ACTION_MAP = Action.make_map( Action( pygame.K_z, Action.Type.ROTATE_LEFT, @@ -161,15 +283,462 @@ class Game: "Move the current piece right.", ), ) + GAME_OVER_ACTION_MAP = Action.make_map( + Action(pygame.K_r, Action.Type.RESTART, False, "Start a new game.") + ) + + FONT = Font( + 6, + 3, + 3, + [ + 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( + ">", + [ + "** ", + " ** ", + " *", + " ** ", + "** ", + ], + ), + ], + ) + SMALL_FONT = FONT.scaled(4, 2, 2) + TINY_FONT = FONT.scaled(2, 1, 1) + SCORE_DISPLAY_BORDER = 5 + CONTROLS_START_X = (BOARD_SIZE.width + 2) * CELL_SIZE.width @staticmethod def random_tetromino(): return random.choice(Game.TETROMINOS) def __init__(self): - self.screen = None - self.clock = None - self.running = False + self._game_over = False + + @property + def game_over(self): + return self._game_over + + @game_over.setter + def game_over(self, newval): + 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 def draw_block(self, x: int, y: int, color): """Draw a block at (X, Y). Coordinates are for the top left corner.""" @@ -259,12 +828,17 @@ class Game: 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 key not in Game.ACTION_MAP: - return - elif typ == pygame.KEYDOWN: - act = Game.ACTION_MAP[key] - self.pending_actions.append(act.type) + 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 @@ -274,10 +848,10 @@ class Game: for key, state in self.key_states.items(): if ( state - and key in self.ACTION_MAP - and self.ACTION_MAP[key].repeat + and key in self.action_map + and self.action_map[key].repeat ): - self.pending_actions.append(self.ACTION_MAP[key].type) + self.pending_actions.append(self.action_map[key].type) # now process new events for event in pygame.event.get(): match event.type: @@ -287,7 +861,10 @@ class Game: self.handle_key_event(event.type, event.key) def swap_with_hold(self): - pass + to_swap = self.held_piece or Game.random_tetromino() + if not self.intersects_board(*self.current_block_pos, to_swap): + self.held_piece = self.current_block + self.current_block = to_swap def intersects_board(self, x: int, y: int, piece: Tetromino): pw, ph = piece.width, piece.height @@ -314,7 +891,7 @@ class Game: return def move_current_piece(self, dir: Direction): - dx = dir * Game.MOVE_SPEED / Game.FRAMERATE + dx = dir * Game.MOVE_SPEED * self.frame_time() self.subcell_move += dx move_cell = int(self.subcell_move) self.subcell_move -= move_cell @@ -325,7 +902,7 @@ class Game: if not self.intersects_board(*new_pos, self.current_block): self.current_block_pos = new_pos - def process_actions(self): + def process_game_actions(self): if not ( {Action.Type.MOVE_LEFT, Action.Type.MOVE_RIGHT} & set(self.pending_actions) @@ -347,6 +924,7 @@ class Game: self.move_current_piece(Direction.RIGHT) def place_piece(self, x: int, y: int, piece: Tetromino): + self.score += Game.PLACE_POINTS for dy, row in enumerate(piece.shape): for dx, cell in enumerate(row): if cell: @@ -356,7 +934,7 @@ class Game: speed = ( Game.FAST_DROP_SPEED if self.doing_drop else Game.NORMAL_DROP_SPEED ) - self.subcell_drop += speed / Game.FRAMERATE + self.subcell_drop += speed * self.frame_time() move_cell = int(self.subcell_drop) self.subcell_drop -= move_cell cp = self.current_block_pos @@ -372,8 +950,7 @@ class Game: self.place_piece(cp.x, cp.y + dy, self.current_block) self.current_block = None return - # TODO Game over - print("Game over!") + self.game_over = True @staticmethod def generate_clear_cells(): @@ -399,6 +976,7 @@ class Game: if not self.clearing_rows: for y, row in enumerate(self.board): if all(row): + 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: @@ -413,29 +991,105 @@ class Game: else: self.clearing_frames += 1 + 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 + self.TINY_FONT.render_text(text, self.screen, x, y, "white") + + def game_loop(self): + self.doing_drop = False + + self.maybe_clear_rows() + if not self.clearing_rows: + self.process_game_actions() + self.advance_piece() + else: + # ignore input while clearing + self.pending_actions = [] + + if not self.current_block: + self.generate_new_block() + + self.screen.fill("black") + if not self.clearing_rows: + self.draw_current_block() + self.draw_board_content() + self.draw_board_border() + + self.draw_score() + + def start_new_game(self): + self.clear_board() + self.generate_new_block() + self.held_piece = None + self.subcell_move = 0 + self.subcell_drop = 0 + self.clearing_rows = set() + # immediately clear the first block + self.clearing_frames = Game.CLEAR_SPEED + self.score = 0 + 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_score() + + self.draw_gray_overlay() + + ss = 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, + self.screen, + ss[0] // 2, + ss[1] // 2 + exts.height + self.FONT.row_kern, + "white", + ) + exts2 = self.SMALL_FONT.compute_extents(score_text) + self.SMALL_FONT.center_text( + "Press to retry.", + self.screen, + ss[0] // 2, + ss[1] // 2 + exts.height + exts2.height + 2 * self.FONT.row_kern, + "white", + ) + def loop(self): self.running = True while self.running: - self.doing_drop = False - self.handle_events() - - self.maybe_clear_rows() - if not self.clearing_rows: - self.process_actions() - self.advance_piece() + if self.game_over: + self.game_over_loop() else: - # ignore input while clearing - self.pending_actions = [] - - if not self.current_block: - self.generate_new_block() - - self.screen.fill("black") - if not self.clearing_rows: - self.draw_current_block() - self.draw_board_content() - self.draw_board_border() + self.game_loop() pygame.display.flip() self.clock.tick(Game.FRAMERATE) @@ -449,25 +1103,22 @@ class Game: 0, ) + def clear_board(self): + self.board = make_matrix(*Game.BOARD_SIZE) + def init(self): pygame.init() self.screen = pygame.display.set_mode(Game.WINDOW_SIZE, pygame.SCALED) 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 = [] - # Game state - self.board = make_matrix(*Game.BOARD_SIZE) - self.generate_new_block() - self.held_piece = None - self.subcell_move = 0 - self.subcell_drop = 0 - self.clearing_rows = set() - # immediately clear the first block - self.clearing_frames = Game.CLEAR_SPEED + self.start_new_game() def run(self): self.init()