이번 편의 목표: 블록이 굳을 때마다 가득 찬 줄을 지우고, 그 위의 줄들을 아래로 내립니다.

남길 줄만 모으기

줄 하나가 가득 찼다는 것은 그 줄에 None이 하나도 없다는 뜻입니다. 그래서 None in row가 True인 줄, 즉 빈 칸이 하나라도 있는 줄만 골라 kept에 모읍니다. 가득 찬 줄은 이 과정에서 저절로 빠집니다.

지운 줄 수는 원래 줄 수 20에서 kept의 길이를 빼면 나옵니다. 그만큼 빈 줄을 kept의 맨 앞, 즉 판의 맨 위에 끼워 넣으면 줄 수가 다시 20이 되고, 지운 줄 위에 있던 줄들은 그만큼 아래로 내려온 셈이 됩니다.

board[:] = kept는 board라는 이름이 가리키는 리스트의 내용물을 통째로 바꿉니다. board = kept라고 쓰면 함수 안의 이름만 새 리스트를 가리키고, game.board는 그대로 남습니다. 함수에 넘겨받은 리스트를 고칠 때 자주 만나는 함정입니다.

clear_lines는 지운 줄 수를 돌려줍니다. 이번 편에서는 그 값을 쓰지 않지만, 다음 편에서 점수를 계산할 때 씁니다.

Game.lock은 블록을 판에 적은 직후에 clear_lines를 부릅니다. 새 블록을 만들기 전에 지워야 새 블록이 나올 자리를 제대로 확인할 수 있습니다.

전체 코드

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
DROP_INTERVAL = 800

BACKGROUND = (18, 18, 24)
GRID = (40, 40, 52)

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),
}
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]


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.piece = Piece(random.choice(list(SHAPES)))
        self.drop_timer = 0

    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 not self.move(0, 1):
            self.lock()
        self.drop_timer = 0

    def lock(self):
        color = COLORS[self.piece.kind]
        for x, y in self.piece.positions():
            self.board[y][x] = color
        clear_lines(self.board)
        self.piece = Piece(random.choice(list(SHAPES)))
        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 >= DROP_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()


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


def draw(screen, game):
    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():
        draw_cell(screen, x, y, color)


def main():
    pygame.init()
    pygame.display.set_caption("Tetris")
    screen = pygame.display.set_mode((WIDTH, HEIGHT))
    clock = pygame.time.Clock()
    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)
        pygame.display.flip()


if __name__ == "__main__":
    main()

한 줄을 가득 채우면 그 줄이 사라지고 위의 블록이 한 칸 내려오면 성공입니다. 세로로 세운 I로 네 줄을 한 번에 지워 보세요.

직접 해 보기

가득 찬 줄을 for 문으로 돌면서 del board[y]로 바로 지우면 어떤 문제가 생길지 생각해 보세요.

해설

for y in range(ROWS)로 돌면서 18번 줄을 지우면, 19번 줄이 18번 자리로 당겨집니다. 다음 반복은 19번을 보는데, 그 자리에는 이제 아무것도 없어서 IndexError가 나거나, 반대로 위에서 아래로 돌면 당겨진 줄 하나를 건너뜁니다. 연속한 두 줄이 동시에 찼을 때 하나만 지워지는 버그가 이렇게 생깁니다. 지울 것을 고르는 대신 남길 것을 새로 모으면 이 문제가 없습니다.