diff --git a/main.py b/main.py index e35bbd1..dc496ef 100644 --- a/main.py +++ b/main.py @@ -1,15 +1,27 @@ import pygame import pygame.draw as draw -from collections import namedtuple -from typing import NamedTuple +from collections import namedtuple, defaultdict +from typing import NamedTuple, Any 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)) @@ -17,7 +29,7 @@ def flip(mat: list[list]) -> list[list]: def transpose(mat: list[list]) -> list[list]: if not mat: return [] - out = [list([None] * len(mat)) for _ in range(len(mat[0]))] + 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] @@ -53,10 +65,38 @@ class Tetromino(NamedTuple): return Tetromino(self.color, flip(transpose(self.shape))) def __repr__(self): - return "\n".join(["".join(row) for row in self.shape]) + out = [] + for row in self.shape: + for cell in row: + out.append("*" if cell else " ") + out.append("\n") + return "".join(out) + + +class Action(NamedTuple): + key: int + type: Type + repeat: bool + desc: str + + class Type(Enum): + ROTATE_LEFT = auto() + ROTATE_RIGHT = auto() + DROP = auto() + SWAP_HOLD = auto() + MOVE_LEFT = auto() + MOVE_RIGHT = auto() + + @staticmethod + def make_map(*acts): + return {act.key: act for act in acts} class Game: + MOVE_SPEED = 20 # cells per second + NORMAL_DROP_SPEED = 3 # cells per second + FAST_DROP_SPEED = 10 # cells per second + CLEAR_SPEED = 5 # frames per block BOARD_SIZE = Size(10, 20) # Cell count CELL_SIZE = Size(30, 30) CELL_BORDER_SIZE = CELL_SIZE.width // 10 @@ -77,15 +117,51 @@ class Game: "green": ColorSet("#00cd00", "#00ff00", "#009a00"), } TETROMINOS = [ - Tetromino("aqua", [["*", "*", "*", "*"]]), - Tetromino("yellow", [["*", "*"], ["*", "*"]]), - Tetromino("purple", [["*", "*", "*"], [" ", "*", " "]]), - Tetromino("blue", [[" ", "*"], [" ", "*"], ["*", "*"]]), - Tetromino("orange", [["*", " "], ["*", " "], ["*", "*"]]), - Tetromino("green", [[" ", "*", "*"], ["*", "*", " "]]), - Tetromino("red", [["*", "*", " "], [" ", "*", "*"]]), + Tetromino("aqua", [[1, 1, 1, 1]]), + 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]]), ] + 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_s, + Action.Type.SWAP_HOLD, + False, + "Swap the current piece and the held piece.", + ), + Action( + pygame.K_DOWN, Action.Type.DROP, True, "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.", + ), + ) + @staticmethod def random_tetromino(): return random.choice(Game.TETROMINOS) @@ -176,26 +252,191 @@ class Game: 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] == "*": + 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 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) + 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(): - if event.type == pygame.QUIT: - self.running = False + 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): + pass + + def intersects_board(self, x: int, y: int, piece: Tetromino): + pw, ph = piece.width, piece.height + if x < 0 or y < 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 + 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]: + np = Cell(cx + dx, cy + dy) + if not self.intersects_board(*np, new_piece): + self.current_block = new_piece + self.current_block_pos = np + return + + def move_current_piece(self, dir: Direction): + dx = dir * Game.MOVE_SPEED / Game.FRAMERATE + self.subcell_move += dx + 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.current_block_pos = new_pos + + def process_actions(self): + if not ( + {Action.Type.MOVE_LEFT, Action.Type.MOVE_RIGHT} + & set(self.pending_actions) + ): + self.subcell_move = 0 + 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.DROP: + self.doing_drop = True + case Action.Type.MOVE_LEFT: + self.move_current_piece(Direction.LEFT) + case Action.Type.MOVE_RIGHT: + self.move_current_piece(Direction.RIGHT) + + 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 + + def advance_piece(self): + speed = ( + Game.FAST_DROP_SPEED if self.doing_drop else Game.NORMAL_DROP_SPEED + ) + self.subcell_drop += speed / Game.FRAMERATE + move_cell = int(self.subcell_drop) + self.subcell_drop -= move_cell + cp = self.current_block_pos + if not self.intersects_board( + cp.x, cp.y + move_cell, self.current_block + ): + self.current_block_pos = Cell(cp.x, cp.y + move_cell) + else: + 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 + return + # TODO Game over + print("Game over!") + + @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 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[: y + 1] = self.board[:y] + 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) + elif not any(self.board[next(iter(self.clearing_rows))]): + for y in 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: + self.clearing_frames = 0 + for y in self.clearing_rows: + do_single_clear(y) + else: + self.clearing_frames += 1 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() + else: + # ignore input while clearing + self.pending_actions = [] + + if not self.current_block: + self.generate_new_block() + self.screen.fill("black") - self.draw_board_border() + if not self.clearing_rows: + self.draw_current_block() self.draw_board_content() - self.draw_current_block() + self.draw_board_border() + pygame.display.flip() self.clock.tick(Game.FRAMERATE) @@ -210,13 +451,23 @@ class Game: def init(self): pygame.init() - self.screen = pygame.display.set_mode(Game.WINDOW_SIZE) + self.screen = pygame.display.set_mode(Game.WINDOW_SIZE, pygame.SCALED) pygame.display.set_caption("Tetris") self.clock = pygame.time.Clock() + pygame.key.stop_text_input() + self.key_states = defaultdict(lambda: False) + self.pending_actions = [] + # Game state - self.board = [[None] * Game.BOARD_SIZE.width] * Game.BOARD_SIZE.height + 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 def run(self): self.init()