본문 바로가기

프로그래밍

pygame으로 고전 테트리스 만들기

고전 테트리스를 pygame 기반으로 만들었습니다.

1. 게임 규칙과 UI 구성
  - 10 x 20 고전 규격 보드, 7종 블록(I, O, T, S, Z, J, L)
  - 좌우 이동, 회전, 빨리 내리기, 즉시 떨어뜨리기
  - 화면 우측에 점수와 레벨 표시
  - 레벨이 오르면 낙하 속도가 빨라짐
  - 대기 상태의 화면에서 시작 키를 누르면 게임이 시작됨

2. 개발 환경
  - 파이썬 버전 : 3.13
  - pygame 버전 : 2.6.1

3. 실행 화면

[그림 1] 실행 화면

 

4. 조작키
  - S : 게임 시작
  - 좌/우 방향 키 : 블록 좌우 이동
  - 위 방향키 : 시계 방향으로 회전
  - Z : 반시계 방향으로 회전
  - 아래 방향키 : 빨리 내리기
  - Space 키 : 즉시 떨어뜨리기
  - P : 일시 정지
  - R : 다시 시작
  - ESC 키 : 프로그램 종료

5. 프로그램 구조
5.1 Piece class : 블록 하나. 모양 행렬과 좌표만 가지고 있음
5.2 Tetris class : 게임 규칙. 화면 제어는 관여하지 않음
5.3 Renderer clase : 화면 제어. 게임 규칙에는 관여하지 않음

6. 주요 적용 기술
6.1 블록 모양을 정사각형 행렬로 두어 회전 함수를 구현

def rotate_cw(shape):
    """행렬을 시계 방향으로 90도 회전."""
    return [list(row) for row in zip(*shape[::-1])]

def rotate_ccw(shape):
    return [list(row) for row in zip(*shape)][::-1]

 

6.2 벽에 붙어서 회전하기
벽에 붙은 상태에서 회전하게 되면 결과가 벽을 뚫기 때문에, 블록을 옆으로 밀어서 회전하도록 구현합니다.

for kick in (0, -1, 1, -2, 2):
    if self.valid(shape=new_shape, ox=self.piece.x + kick):
        self.piece.shape = new_shape
        self.piece.x += kick
        return

 

6.3 판정은 한 곳에서만 실행
이동, 회전, 낙하는 전부 먼저 가상의 위치를 만들어 검사하고, 통과하면 그때 확정하는 방식으로 했습니다. 그래서 충돌 판정 함수는 딱 하나만 두고, 검사할 값만 인자로 받게 했습니다.

def valid(self, shape=None, ox=None, oy=None):
    for x, y in self.piece.cells(shape, ox, oy):
        if x < 0 or x >= COLS or y >= ROWS:
            return False
        if y >= 0 and self.board[y][x] is not None:
            return False
    return True

 

인자를 안 주면 현재 상태를 검사하고, 주면 그 자리로 이동했을 때 어떤 상황인지를 판정합니다. 이동은 valid(ox=x+1), 회전은 valid(shape=회전본), 낙하는 valid(oy=y+1) 입니다. 여기서 y >= 0 조건이 중요합니다. 블록은 보드 위쪽 화면 밖에서 시작하기 때문에 y가 음수인 칸이 생깁니다. 이 조건이 없으면 board[-1] 이 파이썬 규칙상 맨 아래 줄을 가리켜서, 바닥에 쌓인 블록 때문에 새 블록이 나오자마자 막히는 문제가 생깁니다.

6.4 줄 삭제는 지우지 말고 남길 것만 추림
남길 줄만 골라서 새 보드를 만들고 모자란 만큼 위에 빈 줄을 끼워 넣습니다.

def clear_lines(self):
    remain = [row for row in self.board if any(c is None for c in row)]
    cleared = ROWS - len(remain)
    if cleared:
        for _ in range(cleared):
            remain.insert(0, [None] * COLS)
        self.board = remain
        self.lines += cleared
        self.score += LINE_SCORES[cleared] * self.level
        self.level = self.lines // 10 + 1

 

"빈칸이 하나라도 있는 줄"만 남기면 꽉 찬 줄은 자동으로 사라집니다. 점수는 1줄 100점, 2줄 300점, 3줄 500점, 4줄 800점에 레벨을 곱합니다. 한 줄씩 네 번 지우면 400점, 네 줄을 한 번에 지우면 800점이니 두 배 차이가 납니다.

6.5 낙하 속도를 프레임이 아니라 시간에 맞추기
"몇 프레임마다 한 칸 내린다"로 짜면 PC 성능이나 프레임 흔들림에 속도가 끌려다닙니다. 경과 시간을 모았다가 정해진 간격을 넘을 때마다 한 칸씩 내립니다.

def update(self, dt):
    self.fall_timer += dt
    speed = FALL_SPEEDS[min(self.level - 1, len(FALL_SPEEDS) - 1)]
    while self.fall_timer >= speed:
        self.fall_timer -= speed
        if not self.soft_drop():
            break

 

6.6 7-bag 랜덤
매번 랜덤으로 블록을 고르면 특정 블록이 한참 동안 나오지 않는 기아 현상이 생길 수 있습니다. 그래서 7 블록을 한 곳에 저장해서 랜덤으로 내보내고 다시 새로 채우는 방식을 사용하면 특정 블록이 12번째 이상 벌어지는 일은 없게 됩니다.

def new_piece(self):
    if not self.bag:
        self.bag = list(SHAPES.keys())
        random.shuffle(self.bag)
    return Piece(self.bag.pop())

 

6.7 고스트와 입체감
떨어질 자리를 미리 보여주는 고스트는 더 못 내려갈 때까지 y만 늘려서 처리합니다.

def ghost_y(self):
    y = self.piece.y
    while self.valid(oy=y + 1):
        y += 1
    return y

 

입체감을 위해서는 실한 색에서 위와 왼쪽 선은 +60 밝게, 아래와 오른쪽 선은 -70 어둡게 그립니다.

7. 함수별 기능
load_font(size, bold)
맑은 고딕, 애플고딕, 나눔 고딕 순으로 시스템 폰트를 찾아 한글이 깨지지 않게 한다. 하나도 없으면 pygame 내장 폰트로 대체한다.

 

rotate_cw(shape) / rotate_ccw(shape)
모양 행렬을 시계 / 반시계 방향으로 90도 회전시킨 새 행렬을 돌려준다.

 

draw_cell(surface, x, y, color, size)
블록 한 칸을 밝은 선과 어두운 선을 넣어 입체감 있게 그린다.

 

__init__(kind)
종류 문자(I, O, T ...)로 모양 행렬과 색을 정하고 보드 위쪽 가운데에 배치한다.

 

cells(shape, ox, oy)
블록이 차지하는 칸을 보드 좌표 목록으로 돌려준다. 인자를 주면 "그 모양으로 그 자리에 있다고 가정했을 때"의 목록이 나온다. 충돌 판정과 그리기가 모두 이 함수 하나를 본다.

 

reset()
보드, 점수, 레벨, 블록을 처음 상태로 되돌리고 준비 화면 상태로 둔다. R 키가 이걸 부른다.

 

new_piece()
7-bag 방식으로 다음 블록을 하나 꺼낸다.

 

valid(shape, ox, oy)
주어진 모양과 위치가 벽 밖으로 나가거나 쌓인 블록과 겹치지 않는지 검사한다. 이동, 회전, 낙하, 게임오버 판정이 전부 이 함수를 쓴다.

 

move(dx)
좌우로 한 칸 옮긴다. 막히면 아무 일도 일어나지 않는다.

 

rotate(clockwise)
회전시킨다. 막히면 좌우로 최대 두 칸까지 밀어 보고(월 킥) 그래도 안 되면 포기한다.

 

soft_drop()
한 칸 내린다. 더 못 내려가면 그 자리에서 블록을 고정한다.

 

hard_drop()
바닥까지 한 번에 내리고 고정한다. 내려간 칸 수 x 2점을 준다.

 

ghost_y()
현재 블록이 최종적으로 놓일 y 좌표를 계산한다. 고스트 표시에 쓴다.

 

lock_piece()
블록 색을 보드에 새기고, 줄 삭제를 확인한 뒤 다음 블록을 꺼낸다. 화면 위로 넘쳐서 고정되거나, 새 블록을 놓을 자리가 없으면 게임 오버.

 

clear_lines()
꽉 찬 줄을 없애고 점수, 지운 줄 수, 레벨을 갱신한다.

 

update(dt)
경과 시간만큼 낙하를 진행한다. 게임의 시간 축을 담당하는 함수. 시작 전, 일시정지, 게임 오버 상태에서는 아무것도 하지 않고 바로 빠져나온다.

< Renderer 클래스 : 화면 >
draw_board(game)
격자, 쌓인 블록, 고스트, 현재 블록 순으로 보드를 그린다.

 

draw_panel(game)
우측 패널에 현재 상태 안내, 점수, 레벨, 지운 줄 수, 다음 블록, 조작법을 그린다.

 

draw_preview(piece, box)
다음 블록을 미리 보기 상자 한가운데에 맞춰 그린다. 모양 행렬에 빈 행과 빈 열이 있어서, 실제로 채워진 범위만 골라 그 크기를 기준으로 가운데 정렬해야 한쪽으로 치우치지 않는다.

 

draw_overlay(title, subtitle)
준비 화면, 일시정지, 게임 오버 때 보드 위에 반투명 검은 막과 안내 문구를 덮는다.

 

draw(game)
위 함수들을 순서대로 불러 한 프레임을 완성하고 화면에 반영한다.

8. 소스 코드와 실행 파일
소스 코드 길이가 500라인이 넘어 코드를 붙여넣기 하면 스크롤이 상당하므로 첨부 파일 형식으로 등록합니다. 코드를 보지 않고 실행하는 게 목적이면 압축 파일을 다운로드하시면 됩니다.

tetris.py
0.02MB
ClassicTetris.zip
5.40MB
ClassicTetris.z01
10.00MB
ClassicTetris.z02
10.00MB