이번 편의 목표: 좌표 변환 공식 하나로 블록을 시계 방향으로 돌리고, 벽에 막히면 옆으로 비켜서 다시 시도합니다.
회전 공식
n × n 상자 안의 칸 (x, y)를 시계 방향으로 90도 돌리면 (n - 1 - y, x)로 갑니다. 3×3 상자의 T로 확인해 보면, 위 가운데 (1, 0)은 (2, 1), 즉 오른쪽 가운데로 갑니다. 위에 있던 것이 오른쪽으로 갔으니 시계 방향입니다. 나머지 세 칸 (0, 1), (1, 1), (2, 1)은 (1, 0), (1, 1), (1, 2)가 되어 세로 막대가 됩니다. 오른쪽을 가리키는 T입니다.
y가 아래로 커지는 화면 좌표라서 수학 교과서의 회전 공식과 부호가 다르게 보일 수 있습니다. 공식을 외우기보다 위처럼 한 칸을 직접 넣어 보는 편이 빠릅니다.
모양마다 상자 크기를 따로 둔 이유도 여기 있습니다. O는 2×2 상자에서 돌리면 제자리에 그대로 있고, I는 4×4 상자에서 돌려야 가로 막대와 세로 막대가 번갈아 나옵니다. 모든 모양을 4×4로 돌리면 T 같은 블록이 회전할 때마다 한쪽으로 밀려납니다.
Piece.rotated는 돌린 좌표 목록을 새로 만들어 돌려줄 뿐, 블록을 바꾸지는 않습니다. 이동과 마찬가지로 먼저 따져 보기 위해서입니다.
벽 차기
블록이 벽에 붙어 있으면 돌린 모양이 벽 밖으로 삐져나와 회전이 막힙니다. 그럴 때 옆으로 한두 칸 비켜서 들어가면 돌려 주는 것을 벽 차기(wall kick)라고 합니다. KICKS = (0, -1, 1, -2, 2)는 제자리, 왼쪽 1칸, 오른쪽 1칸, 왼쪽 2칸, 오른쪽 2칸 순서로 시도한다는 뜻입니다. 공식 테트리스의 회전 규칙은 모양과 방향마다 시도표가 따로 있지만, 혼자 즐기는 게임에는 이 정도로 충분합니다.
Game.rotate는 돌린 모양을 KICKS 순서대로 옮겨 보며 fits를 확인하고, 처음 들어가는 자리에서 모양과 위치를 바꾸고 끝냅니다. 다섯 자리 모두 막히면 아무것도 바꾸지 않습니다.
전체 코드
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)
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
class Game:
def __init__(self):
self.board = new_board()
self.piece = Piece(random.choice(list(SHAPES)))
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 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.move(0, 1)
elif key in (pygame.K_UP, pygame.K_x):
game.rotate()
elif key == pygame.K_SPACE:
game.piece = Piece(random.choice(list(SHAPES)))
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:
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)
draw(screen, game)
pygame.display.flip()
if __name__ == "__main__":
main()위 방향키나 X를 누를 때마다 블록이 시계 방향으로 돌고, 벽에 붙인 I를 돌려도 판 밖으로 나가지 않으면 성공입니다.
직접 해 보기
Z 키를 누르면 반시계 방향으로 도는 기능을 추가해 보세요. 반시계 방향 공식은 (x, y)에서 (y, n - 1 - x)입니다.
해설
Piece에 rotated_left 메서드를 만들어 [(cy, n - 1 - cx) for cx, cy in self.cells]를 돌려주고, Game.rotate가 어느 방향으로 돌릴지 인자로 받게 고칩니다. 그리고 handle_key에서 pygame.K_z일 때 반시계로 돌리면 됩니다. 시계 방향 공식을 한 번 적용한 뒤 반시계 공식을 적용하면 (x, y)로 돌아오는 것도 직접 계산해 보세요.
COMMENTS
댓글 0
이 글에 대한 의견이나 질문을 남겨 주세요.
댓글은 구글이나 카카오로 시작한 뒤에 남길 수 있습니다.
등록된 댓글이 없습니다.