이번 편의 목표: 게임 오버, 일시정지, 다시 시작을 붙여 테트리스를 완성합니다.
상태를 처음으로 되돌리기
다시 시작하려면 판, 주머니, 블록, 점수를 모두 처음 값으로 돌려야 합니다. __init__에 있던 초기화 코드를 reset 메서드로 옮기고, __init__은 reset을 부르기만 하게 바꿨습니다. 같은 초기화를 두 곳에 적지 않아도 됩니다. paused와 over 두 상태도 여기서 False로 시작합니다.
게임 오버
06편에서 판을 비우던 자리에서 이제 over를 True로 바꿉니다. 새 블록이 나올 자리가 이미 막혀 있다는 것이 게임 오버의 조건입니다.
update는 over나 paused가 True이면 아무것도 하지 않고 돌아갑니다. 블록이 더 내려오지 않고 타이머도 멈춥니다.
키 처리 순서
handle_key는 R을 먼저 봅니다. 게임 오버 상태에서만 reset을 부릅니다. 놀다가 실수로 R을 눌러 판이 날아가지 않게 하려는 것입니다. 다음으로 P는 일시정지를 켜고 끕니다. 그 밖의 키는 게임 오버나 일시정지 중이면 무시합니다. 이 검사가 없으면 멈춘 화면에서도 블록이 몰래 움직입니다.
덮개 그리기
일시정지나 게임 오버일 때는 판 위에 반투명한 검은 덮개를 씌우고 글자를 씁니다. pygame.SRCALPHA로 만든 Surface는 투명도를 가질 수 있고, fill((0, 0, 0, 170))의 마지막 숫자 170이 불투명도입니다. 0이면 완전히 투명하고 255면 완전히 가립니다. 게임 오버 상태에서는 판과 겹쳐 나온 마지막 블록의 고스트가 의미 없으니 그리지 않습니다.
완성본
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.reset()
def reset(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
self.paused = False
self.over = False
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.over = True
def update(self, dt):
if self.over or self.paused:
return
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_r and game.over:
game.reset()
return
if key == pygame.K_p and not game.over:
game.paused = not game.paused
return
if game.over or game.paused:
return
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]
if not game.over:
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)
if game.paused or game.over:
shade = pygame.Surface((BOARD_WIDTH, HEIGHT), pygame.SRCALPHA)
shade.fill((0, 0, 0, 170))
screen.blit(shade, (0, 0))
if game.over:
draw_text(screen, font, "GAME OVER", 85, HEIGHT // 2 - 30)
draw_text(screen, font, "R: restart", 95, HEIGHT // 2 + 10)
else:
draw_text(screen, font, "PAUSED", 105, HEIGHT // 2 - 10)
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()블록이 천장까지 쌓이면 GAME OVER와 R: restart가 보이고, R을 누르면 빈 판에서 다시 시작하면 완성입니다. P를 누르면 PAUSED가 뜨고 다시 누르면 이어서 진행됩니다.
구조 돌아보기
완성본은 260줄 남짓입니다. 위에서부터 설정 상수, 블록 하나(Piece), 블록 주머니(Bag), 판을 다루는 함수(new_board, fits, clear_lines), 게임 한 판의 규칙(Game), 입력(handle_key), 그리기(draw와 도우미들), 게임 루프(main) 순서입니다. 규칙을 담은 Game은 pygame을 전혀 모릅니다. 화면 없이 Game만 만들어 hard_drop을 부르고 score를 확인하는 식으로 시험할 수 있다는 뜻입니다. 규칙과 화면을 나눠 두면 나중에 화면 라이브러리를 바꿔도 규칙은 그대로 씁니다.
직접 해 보기
ESC 키를 누르면 게임이 끝나도록 해 보세요.
해설
ESC는 게임 규칙이 아니라 프로그램을 끝내는 일이라 main의 이벤트 처리에 두는 편이 맞습니다. KEYDOWN일 때 event.key == pygame.K_ESCAPE이면 QUIT과 똑같이 pygame.quit()과 sys.exit()를 부르고, 아니면 handle_key로 넘깁니다.
COMMENTS
댓글 0
이 글에 대한 의견이나 질문을 남겨 주세요.
댓글은 구글이나 카카오로 시작한 뒤에 남길 수 있습니다.
등록된 댓글이 없습니다.