이번 편의 목표: 스페이스로 블록을 바닥까지 한 번에 떨어뜨리고, 떨어질 자리를 테두리만 있는 블록으로 미리 보여 줍니다.

하드 드롭

hard_drop은 move(0, 1)이 실패할 때까지 반복해서 블록을 내리고, 내려간 칸 수만큼 2점씩 더한 뒤 바로 lock을 부릅니다. move가 성공 여부를 돌려주게 만들어 둔 덕분에 while 한 줄로 끝납니다.

고스트

하드 드롭을 쓰려면 블록이 어디에 떨어질지 알아야 합니다. ghost_offset은 지금 블록을 몇 칸 내려도 들어가는지 세어 돌려줍니다. 실제로 옮기지 않고 positions(0, dy + 1)로 계산만 해 보기 때문에 지금 블록은 그대로입니다. hard_drop과 같은 원리를 계산으로만 하는 것입니다.

draw_cell에는 outline 인자를 더했습니다. True이면 칸을 채우지 않고 두께 2픽셀 테두리만 그립니다. draw는 지금 블록을 그리기 전에 ghost_offset만큼 내린 자리에 테두리 블록을 먼저 그립니다. 고스트를 먼저 그려야 블록이 바닥 가까이 내려와 둘이 겹칠 때 진짜 블록이 위에 보입니다.

전체 코드

Python
import random
import sys

import pygame

COLS, ROWS = 10, 20
CELL = 30
BOARD_WIDTH = COLS * CELL
SIDE_WIDTH = 180
WIDTH, HEIGHT = BOARD_WIDTH + SIDE_WIDTH, ROWS * CELL
FPS = 60

BACKGROUND = (18, 18, 24)
GRID = (40, 40, 52)
TEXT = (235, 235, 240)

SHAPES = {
    "I": [(0, 1), (1, 1), (2, 1), (3, 1)],
    "O": [(0, 0), (1, 0), (0, 1), (1, 1)],
    "T": [(1, 0), (0, 1), (1, 1), (2, 1)],
    "S": [(1, 0), (2, 0), (0, 1), (1, 1)],
    "Z": [(0, 0), (1, 0), (1, 1), (2, 1)],
    "J": [(0, 0), (0, 1), (1, 1), (2, 1)],
    "L": [(2, 0), (0, 1), (1, 1), (2, 1)],
}
SIZES = {"I": 4, "O": 2, "T": 3, "S": 3, "Z": 3, "J": 3, "L": 3}
COLORS = {
    "I": (0, 200, 220),
    "O": (230, 200, 0),
    "T": (160, 80, 200),
    "S": (80, 200, 80),
    "Z": (220, 60, 60),
    "J": (60, 100, 220),
    "L": (240, 140, 30),
}
LINE_SCORES = {1: 100, 2: 300, 3: 500, 4: 800}
KICKS = (0, -1, 1, -2, 2)


class Piece:
    def __init__(self, kind):
        self.kind = kind
        self.cells = list(SHAPES[kind])
        self.size = SIZES[kind]
        self.x = (COLS - self.size) // 2
        self.y = 0

    def positions(self, dx=0, dy=0, cells=None):
        if cells is None:
            cells = self.cells
        return [(self.x + cx + dx, self.y + cy + dy) for cx, cy in cells]

    def rotated(self):
        n = self.size
        return [(n - 1 - cy, cx) for cx, cy in self.cells]


class Bag:
    def __init__(self):
        self.items = []

    def next(self):
        if not self.items:
            self.items = list(SHAPES)
            random.shuffle(self.items)
        return self.items.pop()


def new_board():
    return [[None] * COLS for _ in range(ROWS)]


def fits(board, positions):
    for x, y in positions:
        if x < 0 or x >= COLS or y < 0 or y >= ROWS:
            return False
        if board[y][x] is not None:
            return False
    return True


def clear_lines(board):
    kept = [row for row in board if None in row]
    cleared = ROWS - len(kept)
    for _ in range(cleared):
        kept.insert(0, [None] * COLS)
    board[:] = kept
    return cleared


class Game:
    def __init__(self):
        self.board = new_board()
        self.bag = Bag()
        self.piece = Piece(self.bag.next())
        self.next_piece = Piece(self.bag.next())
        self.score = 0
        self.lines = 0
        self.level = 1
        self.drop_timer = 0

    def interval(self):
        return max(100, 800 - (self.level - 1) * 70)

    def move(self, dx, dy):
        if fits(self.board, self.piece.positions(dx, dy)):
            self.piece.x += dx
            self.piece.y += dy
            return True
        return False

    def rotate(self):
        cells = self.piece.rotated()
        for kick in KICKS:
            if fits(self.board, self.piece.positions(kick, 0, cells)):
                self.piece.cells = cells
                self.piece.x += kick
                return

    def soft_drop(self):
        if self.move(0, 1):
            self.score += 1
        else:
            self.lock()
        self.drop_timer = 0

    def hard_drop(self):
        distance = 0
        while self.move(0, 1):
            distance += 1
        self.score += distance * 2
        self.lock()

    def ghost_offset(self):
        dy = 0
        while fits(self.board, self.piece.positions(0, dy + 1)):
            dy += 1
        return dy

    def lock(self):
        color = COLORS[self.piece.kind]
        for x, y in self.piece.positions():
            self.board[y][x] = color
        cleared = clear_lines(self.board)
        if cleared:
            self.score += LINE_SCORES[cleared] * self.level
            self.lines += cleared
            self.level = self.lines // 10 + 1
        self.piece = self.next_piece
        self.next_piece = Piece(self.bag.next())
        self.drop_timer = 0
        if not fits(self.board, self.piece.positions()):
            self.board = new_board()

    def update(self, dt):
        self.drop_timer += dt
        if self.drop_timer >= self.interval():
            self.drop_timer = 0
            if not self.move(0, 1):
                self.lock()


def handle_key(game, key):
    if key == pygame.K_LEFT:
        game.move(-1, 0)
    elif key == pygame.K_RIGHT:
        game.move(1, 0)
    elif key == pygame.K_DOWN:
        game.soft_drop()
    elif key in (pygame.K_UP, pygame.K_x):
        game.rotate()
    elif key == pygame.K_SPACE:
        game.hard_drop()


def draw_cell(screen, x, y, color, outline=False):
    rect = pygame.Rect(x * CELL, y * CELL, CELL, CELL)
    if outline:
        pygame.draw.rect(screen, color, rect.inflate(-4, -4), 2)
    else:
        pygame.draw.rect(screen, color, rect.inflate(-2, -2))


def draw_text(screen, font, text, x, y):
    screen.blit(font.render(text, True, TEXT), (x, y))


def draw(screen, game, font):
    screen.fill(BACKGROUND)
    for x in range(COLS + 1):
        pygame.draw.line(screen, GRID, (x * CELL, 0), (x * CELL, HEIGHT))
    for y in range(ROWS + 1):
        pygame.draw.line(screen, GRID, (0, y * CELL), (BOARD_WIDTH, y * CELL))

    for y, row in enumerate(game.board):
        for x, color in enumerate(row):
            if color is not None:
                draw_cell(screen, x, y, color)

    color = COLORS[game.piece.kind]
    for x, y in game.piece.positions(0, game.ghost_offset()):
        draw_cell(screen, x, y, color, outline=True)
    for x, y in game.piece.positions():
        draw_cell(screen, x, y, color)

    left = BOARD_WIDTH + 20
    draw_text(screen, font, "NEXT", left, 20)
    for cx, cy in game.next_piece.cells:
        rect = pygame.Rect(left + cx * CELL, 60 + cy * CELL, CELL, CELL)
        pygame.draw.rect(screen, COLORS[game.next_piece.kind], rect.inflate(-2, -2))
    draw_text(screen, font, f"SCORE {game.score}", left, 200)
    draw_text(screen, font, f"LINES {game.lines}", left, 240)
    draw_text(screen, font, f"LEVEL {game.level}", left, 280)


def main():
    pygame.init()
    pygame.display.set_caption("Tetris")
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    clock = pygame.time.Clock()
    font = pygame.font.Font(None, 34)
    pygame.key.set_repeat(170, 50)
    game = Game()

    while True:
        dt = clock.tick(FPS)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                pygame.quit()
                sys.exit()
            if event.type == pygame.KEYDOWN:
                handle_key(game, event.key)
        game.update(dt)
        draw(screen, game, font)
        pygame.display.flip()


if __name__ == "__main__":
    main()

블록 아래쪽 떨어질 자리에 같은 색 테두리가 보이고, 스페이스를 누르면 블록이 그 자리로 바로 떨어지면 성공입니다.

직접 해 보기

빈 판에서 맨 위의 세로 I를 하드 드롭하면 몇 점을 얻는지 계산해 보세요. 세로 I는 4×4 상자의 2번 열을 차지하고, 상자는 y = 0에서 시작합니다.

해설

세로 I의 칸은 y = 0부터 3까지입니다. 바닥 줄이 19이므로 맨 아래 칸이 19에 닿을 때까지 16칸 내려갑니다. 16 × 2 = 32점입니다. 사실 블록이 나오자마자 위 방향키로 세우면 그동안 중력이 한두 칸 내렸을 수 있으니, 실제 점수는 그만큼 적게 나올 수 있습니다.