본문 바로가기

Development/Tetris

Python - 테트리스(Tetris) 만들기 (9) - Refactoring

*이번 시간은 설계를 제대로 하지 않은 업보를 확인하는 시간입니다. 전체적인 설계 없이 4(Draw rect) ~ 8(Field)까지 기능 구현만 집중했더니 Refactoring이 얼마나 힘들어 졌는지 확인해 보세요.

 

 


이번 글에서는 Refactoring을 진행해보도록 하겠습니다. Refactoring을 할 코드는 아래와 같습니다.

 

* Tetris 객체 안에서 요소들이 혼잡한 형태라는 것만 파악하셔도 충분합니다.

import pygame as pg
from typing import List
from copy import deepcopy

 
class Mino:
    def __init__(self, name:str, blocks:List[List], color:tuple):
        self.name = name
        self.blocks = blocks
        self.color = color


class Tetris:
    DROP_EVENT = pg.USEREVENT + 1
    EMPTY = "E"
    I_MINO = "I"
    O_MINO = "O"
    S_MINO = "S"
    Z_MINO = "Z"
    L_MINO = "L"
    J_MINO = "J"
    T_MINO = "T"

    def __init__(self, width: int, height: int,
                 field_w:int=10, field_h:int=20, tile_size:int=10, h_padding:int=None):
        self._size = self.width, self.height = width, height
        self._field_w, self._field_h, self._t_size = field_w, field_h, tile_size
        self._h_padding = field_h if h_padding is None else h_padding

        self._field = [[self.EMPTY for _ in range(self._field_w)] for _ in range(self._field_h + self._h_padding)]
        self._mino_dict = {
            self.I_MINO: Mino(self.I_MINO, [[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]], (54, 167, 141)),
            self.O_MINO: Mino(self.O_MINO, [[-1,-1], [ 0,-1], [-1, 0], [ 0, 0]], (166, 166, 81)),
            self.S_MINO: Mino(self.S_MINO, [[-1, 0], [ 0, 0], [ 0,-1], [ 1,-1]], (177, 86,  90)),
            self.Z_MINO: Mino(self.Z_MINO, [[-1,-1], [ 0,-1], [ 0, 0], [ 1, 0]], (143, 173, 60)),
            self.L_MINO: Mino(self.L_MINO, [[-1, 0], [ 0, 0], [ 1, 0], [ 1,-1]], (73,  94, 158)),
            self.J_MINO: Mino(self.J_MINO, [[-1,-1], [-1, 0], [ 0, 0], [ 1, 0]], (202, 121, 65)),
            self.T_MINO: Mino(self.T_MINO, [[-1, 0], [ 0, 0], [ 0,-1], [ 1, 0]], (183, 92, 165))
        }

        self._display_surf = None
        self._running = False
        self._current_mino = None
        self._drop_delay_status = False
        self._color_map = dict()

    def _init(self):
        pg.init()
        self._display_surf = pg.display.set_mode(self._size)
        self._draw_field_rect_list()
        self._running = True
        self._current_mino = self._set_mino_init_position(self.I_MINO)

    def _set_mino_init_position(self, key):
        curr_mino = deepcopy(self._mino_dict[key])
        for b in curr_mino.blocks:
            b[0] += self._field_w//2
        return curr_mino

    def _draw_field_rect_list(self):
        for w in range(self._field_w):
            for h in range(self._field_h):
                _new_rect = pg.Rect(w*self._t_size, h*self._t_size, self._t_size, self._t_size)
                if self._field[h+self._h_padding][w] == self.EMPTY:
                    pg.draw.rect(self._display_surf, (255, 255, 255), _new_rect, 1)
                else:
                    color = self._mino_dict[self._field[h+self._h_padding][w]].color
                    pg.draw.rect(self._display_surf, color, _new_rect)

    def _update_field(self):
        for b in self._current_mino.blocks:
            self._field[b[1] + self._h_padding][b[0]] = self._current_mino.name

    def _event(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self._running = False
            elif event.type == pg.KEYDOWN:
                if event.key == pg.K_UP:
                    self._move( 0,-1)
                elif event.key == pg.K_DOWN:
                    self._move( 0, 1)
                elif event.key == pg.K_RIGHT:
                    self._move( 1, 0)
                elif event.key == pg.K_LEFT:
                    self._move(-1, 0)
            elif event.type == self.DROP_EVENT:
                self._update_field()
                self._current_mino = self._set_mino_init_position(self.I_MINO)

    def _system(self):
        if self._enable_down():
            self._drop_delay_status = False
            pg.time.set_timer(self.DROP_EVENT, 0)
        elif not self._drop_delay_status:
            self._drop_delay_status = True
            pg.time.set_timer(self.DROP_EVENT, 500)

    def _enable_down(self):
        is_enable = True
        for b in self._current_mino.blocks:
            if b[1] + 1 >= self._field_h or self._field[b[1] + 1 + self._h_padding][b[0]] != self.EMPTY :
                is_enable = False
                break;
        return is_enable

    def _update(self):
        self._display_surf.fill((0, 0, 0))
        self._draw_field_rect_list()
        for b in self._current_mino.blocks:
            _rect = pg.Rect(b[0]*self._t_size, b[1]*self._t_size, self._t_size, self._t_size)
            pg.draw.rect(self._display_surf, (self._current_mino.color), _rect)
        pg.display.flip()

    def _move(self, x, y):
        move_enable = True
        for block in self._current_mino.blocks:
            if 0 > block[0] + x or block[0] + x >= self._field_w or block[1] + y >= self._field_h:
                move_enable = False
                break;

        if not move_enable:
            return False

        for block in self._current_mino.blocks:
            block[0] += x
            block[1] += y

    def _quit(self):
        pg.quit()
 
    def execute(self):
        self._init()

        while self._running:
            self._event()
            self._system()
            self._update()
        self._quit()
 
def main():
    tetris = Tetris(640, 400)
    tetris.execute()

if __name__ == "__main__" :
    main()

 

위 코드의 문제점이 무엇일까요? Tetris를 실행하기 위한 모든 요소들이 한 객체 안에 있다는 것입니다.

테트리스를 실행하기 위한 요소들을 구분하면 다음과 같습니다.

 

1. Event Queue

2. Backend system

3. Display Window

간단한 Application의 흐름을 나타낸 그림이다. Backend에서 발생하는 event는 event queue에 바로 저장된다.

 

이 요소들을 하나씩 살펴봅시다.

 

 

 


1. Event Queue

Event Queue는 pygame에서 지원하는 event 기능을 사용하고 있습니다.

테트리스에서는 현재 아래와 같은 주요 event가 발생합니다.

 

1. 사용자의 key 입력

2. timer 이벤트

 

이러한 Event 관리는 _event() 함수에서 관리되고 있습니다.

    def _event(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self._running = False
            elif event.type == pg.KEYDOWN:
                if event.key == pg.K_UP:
                    self._move( 0,-1)
                elif event.key == pg.K_DOWN:
                    self._move( 0, 1)
                elif event.key == pg.K_RIGHT:
                    self._move( 1, 0)
                elif event.key == pg.K_LEFT:
                    self._move(-1, 0)
            elif event.type == self.DROP_EVENT:
                self._update_field()
                self._current_mino = self._set_mino_init_position(self.I_MINO)

 

 


2. Backend System

Event queue에 담긴 Event들은 Backend system에서 처리됩니다. 뿐만 아니라 system 자체적으로도 발생하는 일들도 system에서 처리해야 합니다. 지금까지 개발된 system level의 기능은 다음과 같습니다.

 

1. Field 규격

2. mino 모양 설정

3. key event에 따른 mino move

4. mino move의 enable 설정(boundary 등)

5. drop time event trigger

...

 

이러한 system적인 부분들은 Code 안에 이곳저곳 흩어져 있습니다. 코드의 품질을 낮추는 원인 중 하나가 되었습니다.

 

 

 


3. Display window

System에서 처리된 것들을 pygame의 surface로 전달하게 됩니다. surface에서 나타낸 것들은 window에 Display되고, 사용자에게 최종적으로 전달됩니다. 지금까지 개발된 display 기능은 다음과 같습니다.

 

1. window size (현재 640 x 400)

2. tile pixel size

3. fill black surface

4. draw field

5. draw current field

 

안타깝게도 이런 display 부분도 code에 파편적으로 존재합니다.

 

즉, 지금 코드는 backend와 display의 코드가 무분별하게 섞여있다는 점입니다.

 

 


* 문제 1번 : h_padding은 어디에서 다뤄야 할까요?

보기 1번 : field를 다루는 system 영역

    def _update_field(self):
        for b in self._current_mino.blocks:
            self._field[b[1] + self._h_padding][b[0]] = self._current_mino.name

 

보기 2번 : rect를 다루는 display 영역

    def _draw_field_rect_list(self):
        for w in range(self._field_w):
            for h in range(self._field_h):
                _new_rect = pg.Rect(w*self._t_size, h*self._t_size, self._t_size, self._t_size)
                if self._field[h+self._h_padding][w] == self.EMPTY:
                    pg.draw.rect(self._display_surf, (255, 255, 255), _new_rect, 1)
                else:
                    color = self._mino_dict[self._field[h+self._h_padding][w]].color
                    pg.draw.rect(self._display_surf, color, _new_rect)

 

정답은...

 

 

1번인 system에서 다뤄야 합니다. rect를 그리는 부분에서는 padding값이 어떤 값인지 알 필요가 없어야 하죠.

즉 2번의 함수는 완전 잘못되었다는 것입니다.

 

* 문제 2번 : mino의 Color는 어디에서 다뤄야 할까요?

보기 1번 : System 영역

    def __init__(self, width: int, height: int,
                 field_w:int=10, field_h:int=20, tile_size:int=10, h_padding:int=None):
        self._size = self.width, self.height = width, height
        self._field_w, self._field_h, self._t_size = field_w, field_h, tile_size
        self._h_padding = field_h if h_padding is None else h_padding

        self._field = [[self.EMPTY for _ in range(self._field_w)] for _ in range(self._field_h + self._h_padding)]
        self._mino_dict = {
            self.I_MINO: Mino(self.I_MINO, [[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]], (54, 167, 141)),
            self.O_MINO: Mino(self.O_MINO, [[ 0,-1], [ 0, 0], [ 1, 0], [ 1,-1]], (166, 166, 81)),
            self.S_MINO: Mino(self.S_MINO, [[-1,-1], [ 0,-1], [ 0, 0], [ 1, 0]], (177, 86,  90)),
            self.Z_MINO: Mino(self.Z_MINO, [[-1, 0], [ 0, 0], [ 0,-1], [ 1,-1]], (143, 173, 60)),
            self.L_MINO: Mino(self.L_MINO, [[-1,-1], [-1, 0], [ 0, 0], [ 1, 0]], (73,  94, 158)),
            self.J_MINO: Mino(self.J_MINO, [[-1, 0], [ 0, 0], [ 1, 0], [ 1,-1]], (202, 121, 65)),
            self.T_MINO: Mino(self.T_MINO, [[ 0,-1], [ 0, 0], [-1, 0], [ 1, 0]], (183, 92, 165))
        }

 

보기 2번 : display 영역

    def _draw_field_rect_list(self):
        for w in range(self._field_w):
            for h in range(self._field_h):
                _new_rect = pg.Rect(w*self._t_size, h*self._t_size, self._t_size, self._t_size)
                if self._field[h+self._h_padding][w] == self.EMPTY:
                    pg.draw.rect(self._display_surf, (255, 255, 255), _new_rect, 1)
                else:
                    color = self._mino_dict[self._field[h+self._h_padding][w]].color
                    pg.draw.rect(self._display_surf, color, _new_rect)

 

정답은...

 

 

Display 2번입니다. 어떤 mino인지에 대한 분류까지는 system에서 다루고, 그 분류에 대한 Color는 display에서 다뤄야 할 문제입니다. 만약 skin이 변경된다면 system에서 skin 변경을 신경써야 할까요? 그렇지 않습니다. system은 L-mino, I-mino라는 정보만 들고 있고, 그 mino를 어떤 색으로, 어떤 패턴으로 칠할지는 display에서 정해야 합니다.

 

이제, Tetris Class를 요소에 맞게 분리해 봅시다.

 

 


[Refactoring] File 분리하기

app_tetris.py에 있는 event는 건들지 않고, system과 display만 옮기려 합니다.

폴더와 파일 생성

 

그리고 system에 Tetris system을 다룰 class를 작성합니다.

vscode text compare를 통해 기존(좌) 코드에서 Refactoring(우) 코드가 어떻게 변했는지 알 수 있다.

 

Refactoring 과정을 통해, Tetris system Class는 다음과 같이 __init__() method가 정리됩니다.

from copy import deepcopy
from typing import List
import pygame as pg
from enum import Enum, auto


class UserEvent(Enum):
    DROP_EVENT = auto()


class Mino:
    def __init__(self, blocks: List[List]):
        self.blocks = blocks


class System:
    drop_event = pg.USEREVENT + UserEvent.DROP_EVENT.value
    mino_dict = {
        "I": Mino([[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]]),
        "O": Mino([[ 0,-1], [ 0, 0], [ 1, 0], [ 1,-1]]),
        "S": Mino([[-1,-1], [ 0,-1], [ 0, 0], [ 1, 0]]),
        "Z": Mino([[-1, 0], [ 0, 0], [ 0,-1], [ 1,-1]]),
        "L": Mino([[-1,-1], [-1, 0], [ 1, 0], [ 1, 0]]),
        "J": Mino([[-1, 0], [ 0, 0], [ 1, 0], [ 1,-1]]),
        "T": Mino([[ 0,-1], [ 0, 0], [-1, 0], [ 1, 0]])
    }
    empty = "E"
    mino_names = mino_dict.keys()

    def __init__(self, field_w: int=10, field_h: int=20, h_padding: int=None):
        self._field_w = field_w
        self._field_h = field_h
        self._h_padding = field_h if h_padding is None else h_padding
        self._field = [[False for _ in range(self._field_w)] for _ in range(self._field_h + self._h_padding)]
        self._current_mino: Mino = None
        self._drop_delay_status: bool = False

* Enum은 살짝 넣어봤습니다.

 

그 다음에는 Class Display를 작성해볼 차례입니다. 먼저 __init__() 부분입니다.

import pygame as pg

class Display:
    mino_color_map = {
        "I": (54, 167, 141),
        "O": (166, 166, 81),
        "S": (177, 86,  90),
        "Z": (143, 173, 60),
        "L": (73,  94, 158),
        "J": (202, 121, 65),
        "T": (183, 92, 165), 
    }

    def __init__(self, width: int, height: int, tile_size: int=10):
        self.width, self.height, self._t_size = width, height, tile_size

        pg.init()
        self.display_surf = pg.display.set_mode(self.width, self.height)

 

이전 코드에 비해서 더욱 명확해졌습니다. 이와 같은 방법을 계속 합니다.

 

이번에는 _set_mino_init_position()를 refactoring 한 후, system class에 작성하겠습니다.

    def _get_mino(self) -> Mino:
    	key = random.choice(self.mino_names)
        return deepcopy(self.mino_dict[key])
    
    def _set_init_position(self, mino: Mino) -> Mino:
        for b in mino.blocks:
            b[0] += self._field_w // 2
            b[1] += self._h_padding
        return mino
    
    def _set_next_mino(self, key):
        _mino = self._get_mino(key)
        self._current_mino = self._set_init_position(_mino)

 

get_mino를 random하게 바꿔주었습니다. 그리고 padding항목이 _set_init_position으로 들어왔습니다. 앞으로는 field의 y열이 20~39까지를 보여주는 것으로 진행합니다. display에는 field를 잘라서 주면 됩니다.

 

이번에는 _draw_field_rect_list()를 refactoring 한 후 display class에 작성하겠습니다.

    def _draw_field_block(self, field: List[List]):
        height, width = len(field), len(field[0])
        for h in range(height):
            for w in range(width):
                if field[h][w] is not False:
                    _new_rect = pg.Rect(w*self._t_size, h*self._t_size, self._t_size, self._t_size)
                    _color = self.mino_color_map[field[h][w]]
                    pg.draw.rect(self._display_surf, _color, _new_rect)
    
    def _draw_grid(self, height, width):
        for h in range(height):
            for w in range(width):
                _new_rect = pg.Rect(w*self._t_size, h*self._t_size, self._t_size, self._t_size)
                pg.draw.rect(self._display_surf, self.grid_color, _new_rect, 1)

 

field에 있는 block을 그리는 것과, _draw_grid로 나뉘었습니다. 당장에 for문 연산량이 2배 늘은 것처럼 보이지만, 실제 컴퓨터가 수행해야 하는 양은 400번 추가입니다. 만약 성능에 문제가 있을 경우 이 부분을 최적화 하면 되지만, 성능에 영향이 없을 것으로 예상됩니다.

 

이번에는 _update_field()를 refactoring한 후 system class에 작성하겠습니다.

    def _field_update(self):
        for b in self._current_mino.blocks:
            self._field[b[1]][b[0]] = self._current_mino.name

 

_update_field를 _field_update로 이름을 변경하고, padding을 제외하였습니다.

 

_event()나중에 refactoring한 후 app_tetris.py에 작성하겠습니다.

 

이번에는 _system()을 refactoring한 후 system class에 작성하겠습니다.

    def _is_enable_lock_mino(self):
        if self._is_enable_move_down():
            self._drop_delay_status = False
            pg.time.set_timer(self.DROP_EVENT, UserEvent.STOP_TIMER.value)
        else:   ## don't move down
            if not self._drop_delay_status:
                self._drop_delay_status = True
                pg.time.set_timer(self.DROP_EVENT, self._drop_delay_millisecond)

 

_system을 _is_enable_lock_mino라는 이름으로 고쳤습니다. 이 함수는 lock 상태(Field에 남겨지고 다음 mino를 호출할 수 있는 상태)인지 확인하는 함수입니다. 참조문서에서 사용되는 용어인  lock delay에서 따왔습니다.

그리고 기존의 _enable_down()도 _is_enable_move_down()으로 더 명확하게 만들었습니다.

 

이번에는 _enable_down()을 refactoring한 후 system class에 작성하겠습니다.

위에서 _is_enable_move_down()으로 이름은 수정했지만, 여전히 동작이 깔끔하지 않다는 생각이 듭니다.

동작 방법이 다음과 같습니다.

 

  1. mino가 최하단에 있는지 check

  2. 블럭의 밑이 비어있는지 check

 

그런데 이 2가지 조건을 한번에 합칠 수 있는 아이디어가 생각났습니다.

 

 


* Boundary를 똑똑하게 Check할 수 있는 방법

Field의 테두리를 블럭으로 둘러 싼다면

 

  1. 바닥에 도달했는지

  2. 조종하는 mino 밑에 블럭이 있는지

 

이 2가지 조건을 한 가지 조건으로 검사할 수 있습니다.

이런 식으로 Field 구조가 잡힌다면, boundary와 Block의 move 조건을 구분하지 않아도 된다.

 

 

아이디어가 나왔으니 바로 Field에 적용해 보겠습니다.

class System:
    drop_event = pg.USEREVENT + UserEvent.DROP_EVENT.value
    mino_dict = {
        "I": Mino("I", [[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]]),
        "O": Mino("O", [[ 0,-1], [ 0, 0], [ 1, 0], [ 1,-1]]),
        "S": Mino("S", [[-1,-1], [ 0,-1], [ 0, 0], [ 1, 0]]),
        "Z": Mino("Z", [[-1, 0], [ 0, 0], [ 0,-1], [ 1,-1]]),
        "L": Mino("L", [[-1,-1], [-1, 0], [ 1, 0], [ 1, 0]]),
        "J": Mino("J", [[-1, 0], [ 0, 0], [ 1, 0], [ 1,-1]]),
        "T": Mino("T", [[ 0,-1], [ 0, 0], [-1, 0], [ 1, 0]])
    }
    empty = "E"
    mino_names = mino_dict.keys()
    boundary = "B"	## 새로 정의

    def __init__(self, field_w: int=10, field_h: int=20, h_padding: int=None):
        self._field_w = field_w
        self._field_h = field_h
        self._h_padding = field_h if h_padding is None else h_padding
        self._field = self._make_boundary_block(field_w, field_h, self._h_padding) ## 새로 정의
        
        self._current_mino: Mino = None
        self._drop_delay_status: bool = False
        self._drop_delay_millisecond = 500
    
    ## 새로 만든 함수
    def _make_boundary_block(self, field_w, field_h, h_padding):
        _field = [[self.empty for _ in range(field_w + 2)] for _ in range(field_h + h_padding + 2)]
        for i in range(len(_field)):
            _field[0][i] = self.boundary
            _field[-1][i] = self.boundary
        for i in range(len(_field[0])):
            _field[i][0] = self.boundary
            _field[i][-1] = self.boundary
        return _field

 

그 다음에 _enable_down을 system에 작성합니다.

    def _enable_down(self):
        is_enable = True
        for b in self._current_mino.blocks:
            if b[1] + 1 != self.empty:
                is_enable = False
                break
        return is_enable

 

기존 함수와 비교해 보시면 훨씬 간단하게 표현됨을 알 수 있습니다.

 

이번에는_update() 함수를 refactoring한 후, display class에 작성해보겠습니다.

    def _draw_current_mino(self, current_mino: Mino):
        for b in current_mino.blocks:
            _rect = pg.Rect(b[0]*self._t_size, b[1]*self._t_size, self._t_size, self._t_size)
            pg.draw.rect(self.display_surf, self.mino_color_map[current_mino.name], _rect)

    def _update(self, field: List[List], current_mino: Mino):
        self.display_surf.fill(self.BLACK)
        self._draw_field_block(field)
        self._draw_current_mino(current_mino)
        pg.display.filp()

 

흠...작성해보고 나니 뭔가 이상합니다. Mino라는 객체는 Display에는 없습니다. Mino의 Color값만 가져온 상태입니다.

Display에는 Mino의 Color값만 있다.

 

하지만 결국 _update()라는 함수에서는 mino의 위치정보까지 알아야 합니다.

Mino자체의 형식을 정의하는 부분에서도 문제가 생겼다.

 

이 문제는 display와 system에서 mino를 한 곳에만 두려고 해서 생긴 문제입니다. 사실 Mino는 양쪽에서 사용하는 중점인 개체입니다.

Mino를 한 곳에 둘 수는 없다.

 

그렇다면 어떻게 해야 할까요?

Core라는 file을 만들어서 Mino를 관리하고, system과 display 양쪽에서 불러오는 방식으로 진행하면 됩니다.

 


[Refectoring] 파일을 분리해서 관리하자

Mino 객체를 core에 소환했다.

그리고 Mino들도 (I, O, S, Z, L, J, T) core에서 소환합니다. 그럼 core.py는 이렇게 작성됩니다.

from typing import List


class Mino:
    def __init__(self, name:str, blocks:List[List], color:tuple):
        self.name = name
        self.blocks = blocks
        self.color = color

mino_dict = {
    "I": Mino("I", [[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]], (54, 167, 141)),
    "O": Mino("O", [[ 0,-1], [ 0, 0], [ 1, 0], [ 1,-1]], (166, 166, 81)),
    "S": Mino("S", [[-1,-1], [ 0,-1], [ 0, 0], [ 1, 0]], (177, 86,  90)),
    "Z": Mino("Z", [[-1, 0], [ 0, 0], [ 0,-1], [ 1,-1]], (143, 173, 60)),
    "L": Mino("L", [[-1,-1], [-1, 0], [ 1, 0], [ 1, 0]], (73,  94, 158)),
    "J": Mino("J", [[-1, 0], [ 0, 0], [ 1, 0], [ 1,-1]], (202, 121, 65)),
    "T": Mino("T", [[ 0,-1], [ 0, 0], [-1, 0], [ 1, 0]], (183, 92, 165))
}

* tetris_module/core.py

 

그리고 이 Keyword는 field에서 사용되는 고유한 Field key값입니다. 이런 key값이 또 empty와 boundary가 있었습니다. 이런 것들도 Core에서 관리합니다.

from typing import List


class Mino:
    def __init__(self, name:str, blocks:List[List], color:tuple):
        self.name = name
        self.blocks = blocks
        self.color = color

mino_dict = {
    "I": Mino("I", [[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]], (54, 167, 141)),
    "O": Mino("O", [[ 0,-1], [ 0, 0], [ 1, 0], [ 1,-1]], (166, 166, 81)),
    "S": Mino("S", [[-1,-1], [ 0,-1], [ 0, 0], [ 1, 0]], (177, 86,  90)),
    "Z": Mino("Z", [[-1, 0], [ 0, 0], [ 0,-1], [ 1,-1]], (143, 173, 60)),
    "L": Mino("L", [[-1,-1], [-1, 0], [ 1, 0], [ 1, 0]], (73,  94, 158)),
    "J": Mino("J", [[-1, 0], [ 0, 0], [ 1, 0], [ 1,-1]], (202, 121, 65)),
    "T": Mino("T", [[ 0,-1], [ 0, 0], [-1, 0], [ 1, 0]], (183, 92, 165))
}

empty = "E"
boundary = "B"

 

 

이렇게 되면, display와 system은 다음과 같이 작성할 수 있습니다.

 

* Display 영역

from typing import List
import pygame as pg

import core
from core import Mino


class Display:
    mino_dict = core.mino_dict
    grid_color = (255, 255, 255)
    BLACK = (0, 0, 0)

* tetris_module/display.py

 

* System 영역

from copy import deepcopy
from typing import List
import pygame as pg
from enum import Enum, auto

import core
from core import Mino

class UserEvent(Enum):
    STOP_TIMER = 0
    DROP_EVENT = auto()


class System:
    drop_event = pg.USEREVENT + UserEvent.DROP_EVENT.value
    mino_dict = core.mino_dict
    empty = core.empty
    boundary = core.boundary
    mino_names = mino_dict.keys()

* tetris_module/system.py

 

그리고, Core에서는 key값으로 사용하는 value들을 enum을 적용해서 겹치지 않도록 합시다. 

from typing import List
from enum import Enum, auto

class Mino:
    def __init__(self, name:str, blocks:List[List], color:tuple):
        self.name = name
        self.blocks = blocks
        self.color = color

class KeyString(Enum):
    I = auto()
    O = auto()
    S = auto()
    Z = auto()
    L = auto()
    J = auto()
    T = auto()
    empty = auto()
    boundary = auto()

mino_dict = {
    KeyString.I.value: Mino(KeyString.I.value, [[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]], (54, 167, 141)),
    KeyString.O.value: Mino(KeyString.O.value, [[-1,-1], [ 0,-1], [-1, 0], [ 0, 0]], (166, 166, 81)),
    KeyString.S.value: Mino(KeyString.S.value, [[-1, 0], [ 0, 0], [ 0,-1], [ 1,-1]], (177, 86,  90)),
    KeyString.Z.value: Mino(KeyString.Z.value, [[-1,-1], [ 0,-1], [ 0, 0], [ 1, 0]], (143, 173, 60)),
    KeyString.L.value: Mino(KeyString.L.value, [[-1, 0], [ 0, 0], [ 1, 0], [ 1,-1]], (73,  94, 158)),
    KeyString.J.value: Mino(KeyString.J.value, [[-1,-1], [-1, 0], [ 0, 0], [ 1, 0]], (202, 121, 65)),
    KeyString.T.value: Mino(KeyString.T.value, [[-1, 0], [ 0, 0], [ 0,-1], [ 1, 0]], (183, 92, 165))
}

* tetris_module/core.py

 

그리고 display, system은 core에 맞춰서 다시 바뀝니다.

from copy import deepcopy
from typing import List
import pygame as pg
from enum import Enum, auto

import core
from core import Mino, KeyString

class UserEvent(Enum):
    STOP_TIMER = 0
    DROP_EVENT = auto()


class System:
    drop_event = pg.USEREVENT + UserEvent.DROP_EVENT.value
    mino_dict = core.mino_dict
    empty = KeyString.empty.value
    boundary = KeyString.boundary.value
    mino_names = mino_dict.keys()

* tetris_module/system.py

 

 

이번에는 _move()라는 함수를 refactoring 후, system에 작성하겠습니다. boundary라는 개념이 없어진 지금은 move 후에 겹치는 도형이 있는지 check만 하면 됩니다.

    def _move(self, x, y):
        move_enable = True
        _test_mino = deepcopy(self._current_mino)
        for b in _test_mino.blocks:
            if self._field[b[1] + y, b[0] + x] == self.empty:
                move_enable = False
                break;

        if move_enable:
            for b in self._current_mino.blocks:
                b[0] += x
                b[1] += y

* tetris_module/system.py

 

이제 거의 다 왔습니다!

마지막으로, display와 system을 합쳐줍시다. display와 system에서 빠진 것들을 제외한 것들을 app.py에 작성해봅시다.

import pygame as pg
from typing import List
from copy import deepcopy
from tetris_module.display import Display
from tetris_module.system import System
 
class Mino:
    def __init__(self, name:str, blocks:List[List], color:tuple):
        self.name = name
        self.blocks = blocks
        self.color = color


class TetrisApp:
    def __init__(self, width: int, height: int, tile_size: int=10,
                 field_w: int=10, field_h: int=20, h_padding: int=None):
        self.display = Display(width, height, tile_size)
        self.system = System(field_w, field_h, h_padding)
        self._running = False

    def _init(self):
        pg.init()
        self._running = True

    def _event(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self._running = False
            elif event.type == pg.KEYDOWN:
                if event.key == pg.K_UP:
                    self.system._move( 0,-1)
                elif event.key == pg.K_DOWN:
                    self.system._move( 0, 1)
                elif event.key == pg.K_RIGHT:
                    self.system._move( 1, 0)
                elif event.key == pg.K_LEFT:
                    self.system._move(-1, 0)
            elif event.type == self.DROP_EVENT:
                self.system._field_update()

    def _quit(self):
        pg.quit()
 
    def execute(self):
        self._init()

        while self._running:
            self._event()
            self.display._update()
        self._quit()
 
def main():
    tetris = TetrisApp(640, 400)
    tetris.execute()

if __name__ == "__main__" :
    main()

* tetris_module/app.py

 

한번 실행해 봅시다. 그리고 안되는 것들을 정리해 봅시다.

 

1. 객체 밖에서 사용하는데 underscore 붙은 method 수정

2. 사용하지 않는 import, import error 정리(예 : core)

3. 용어에 안맞는 method(예 : field_update) 수정

4. display, system간의 연결 정리

 

기타 곁가지들까지 정리하면 다음과 같습니다.

 

* app.py

import pygame as pg
from typing import List
from tetris_module.display import Display
from tetris_module.system import System
 

class Mino:
    def __init__(self, name:str, blocks:List[List], color:tuple):
        self.name = name
        self.blocks = blocks
        self.color = color


class TetrisApp:
    def __init__(self, width: int, height: int, tile_size: int=10,
                 field_w: int=10, field_h: int=20, h_padding: int=None):
        self.display = Display(width, height, tile_size)
        self.system = System(field_w, field_h, h_padding)
        self._running = False

    def _init(self):
        self.system.init()
        self.display.init()
        self._running = True

    def _event(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:
                self._running = False
            elif event.type == pg.KEYDOWN:
                if event.key == pg.K_UP:
                    self.system.move( 0,-1)
                elif event.key == pg.K_DOWN:
                    self.system.move( 0, 1)
                elif event.key == pg.K_RIGHT:
                    self.system.move( 1, 0)
                elif event.key == pg.K_LEFT:
                    self.system.move(-1, 0)
            elif event.type == self.system.drop_event:
                ## reset시 일어나는 drop_event는 filter
                if self.system.drop_delay_status:
                    self.system.lock_mino()
                    self.system.get_next_mino()

    def _quit(self):
        pg.quit()
 
    def execute(self):
        self._init()

        while self._running:
            self._event()
            self.system.update()
            field = self.system.get_field()
            curr_mino = self.system.get_current_mino()
            self.display.update(field, curr_mino)
        self._quit()
 
def main():
    tetris = TetrisApp(640, 400)
    tetris.execute()

if __name__ == "__main__" :
    main()

 

* display.py

import pygame as pg
from numpy.typing import ArrayLike

from . import core
from .core import Mino, KeyString


class Display:
    mino_dict = core.mino_dict
    empty = KeyString.empty
    grid_color = (255, 255, 255)
    BLACK = (0, 0, 0)

    def __init__(self, width: int, height: int, tile_size: int=10):
        self.width, self.height, self._t_size = width, height, tile_size
        self.display_surf = None

    def init(self):
        pg.init()
        self.display_surf = pg.display.set_mode((self.width, self.height))

    def _draw_field_block(self, field: ArrayLike):
        height, width = len(field), len(field[0])
        for h in range(height):
            for w in range(width):
                if field[h, w] != self.empty:
                    _new_rect = pg.Rect(w*self._t_size, h*self._t_size, self._t_size, self._t_size)
                    _color =  self.mino_dict[field[h, w]].color
                    pg.draw.rect(self.display_surf, _color, _new_rect)
    
    def _draw_grid(self, field: ArrayLike):
        height, width = len(field), len(field[0])
        for h in range(height):
            for w in range(width):
                _new_rect = pg.Rect(w*self._t_size, h*self._t_size, self._t_size, self._t_size)
                pg.draw.rect(self.display_surf, self.grid_color, _new_rect, 1)
    
    def _draw_current_mino(self, current_mino: Mino):
        for b in current_mino.blocks:
            _rect = pg.Rect(b[0]*self._t_size, b[1]*self._t_size, self._t_size, self._t_size)
            pg.draw.rect(self.display_surf, self.mino_dict[current_mino.name].color, _rect)

    def update(self, field: ArrayLike, current_mino: Mino):
        self.display_surf.fill(self.BLACK)
        self._draw_grid(field)
        self._draw_field_block(field)
        self._draw_current_mino(current_mino)
        pg.display.flip()

 

* system.py

from copy import deepcopy
import pygame as pg
from enum import Enum, auto
import random
import numpy as np
from numpy.typing import ArrayLike

from . import core
from .core import Mino, KeyString


class UserEvent(Enum):
    DROP_EVENT = 1


class System:
    drop_event = pg.USEREVENT + UserEvent.DROP_EVENT.value
    mino_dict = core.mino_dict
    empty = KeyString.empty
    boundary = KeyString.boundary
    mino_names = list(mino_dict.keys())
    drop_delay_status: bool = False

    def __init__(self, field_w: int=10, field_h: int=20, h_padding: int=None):
        self._field_w = field_w
        self._field_w_center = field_w // 2
        self._field_h = field_h
        self._h_padding = field_h if h_padding is None else h_padding
        self._field = self._make_field(field_w, field_h, self._h_padding)
        self._current_mino: Mino = None

        self._reset_event = 0
        self._drop_delay_millisecond = 500
    
    def _make_field(self, field_w, field_h, h_padding) -> ArrayLike:
        ## boundary, padding
        field = np.array([[self.empty for _ in range(field_w + 2)] for _ in range(field_h + h_padding + 2)])
        return self._make_boundary(field)

    def _make_boundary(self, field: ArrayLike) -> ArrayLike:
        for i in range(len(field[0])):
            field[0, i] = self.boundary
            field[-1, i] = self.boundary
        for i in range(len(field)):
            field[i, 0] = self.boundary
            field[i, -1] = self.boundary
        return field

    def init(self):
        self.get_next_mino()

    def get_next_mino(self):
        _next_mino_name = self._get_mino_next_name()
        self._current_mino = self._get_next_mino_object(_next_mino_name)

    def _get_mino_next_name(self):
        return random.choice(self.mino_names)

    def lock_mino(self):
        for b in self._current_mino.blocks:
            self._field[b[1], b[0]] = self._current_mino.name

    def get_field(self) -> ArrayLike:
        ## boundary, padding
        return self._field[1 + self._h_padding : -1, 1 : -1]

    def get_current_mino(self) -> Mino:
        output_mino = deepcopy(self._current_mino)
        ## boundary, padding
        for b in output_mino.blocks:
            b[0] -= 1
            b[1] -= (self._h_padding + 1)
        return output_mino
    
    def _get_next_mino_object(self, key) -> Mino:
        _mino = deepcopy(self.mino_dict[key])
        return self._set_mino_init_position(_mino)
    
    def _set_mino_init_position(self, mino: Mino) -> Mino:
        ## boundary, padding
        for b in mino.blocks:
            b[0] += self._field_w_center
            b[1] += (self._h_padding + 1)
        return mino
    
    def _check_enable_lock_mino(self):
        if self._enable_down() and self.drop_delay_status:
            self.drop_delay_status = False
            pg.time.set_timer(self.drop_event, self._reset_event)
        else:
            self._set_drop_delay_timer()
    
    def _set_drop_delay_timer(self):
        if not self.drop_delay_status:
            self.drop_delay_status = True
            pg.time.set_timer(self.drop_event, self._drop_delay_millisecond)

    def _enable_down(self):
        is_enable = True
        for b in self._current_mino.blocks:
            if self._field[b[1] + 1, b[0]] != self.empty:
                is_enable = False
                break;
        
        return is_enable

    def move(self, x, y):
        move_enable = True
        _test_mino = deepcopy(self._current_mino)
        for b in _test_mino.blocks:
            if self._field[b[1] + y, b[0] + x] != self.empty:
                move_enable = False
                break;

        if move_enable:
            for b in self._current_mino.blocks:
                b[0] += x
                b[1] += y

    def update(self):
        self._check_enable_lock_mino()

 

* core.py

from typing import List
from enum import Enum, auto


class Mino:
    def __init__(self, name:str, blocks:List[List], color:tuple):
        self.name = name
        self.blocks = blocks
        self.color = color


class KeyString(Enum):
    I = auto()
    O = auto()
    S = auto()
    Z = auto()
    L = auto()
    J = auto()
    T = auto()
    empty = auto()
    boundary = auto()


mino_dict = {
    KeyString.I: Mino(KeyString.I, [[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]], (  54, 167, 141)),
    KeyString.O: Mino(KeyString.O, [[-1,-1], [ 0,-1], [-1, 0], [ 0, 0]], ( 166, 166,  81)),
    KeyString.S: Mino(KeyString.S, [[-1, 0], [ 0, 0], [ 0,-1], [ 1,-1]], ( 177,  86,  90)),
    KeyString.Z: Mino(KeyString.Z, [[-1,-1], [ 0,-1], [ 0, 0], [ 1, 0]], ( 143, 173,  60)),
    KeyString.L: Mino(KeyString.L, [[-1, 0], [ 0, 0], [ 1, 0], [ 1,-1]], (  73,  94, 158)),
    KeyString.J: Mino(KeyString.J, [[-1,-1], [-1, 0], [ 0, 0], [ 1, 0]], ( 202, 121,  65)),
    KeyString.T: Mino(KeyString.T, [[-1, 0], [ 0, 0], [ 0,-1], [ 1, 0]], ( 183,  92, 165))
}

 

 


결과

점점 tetris와 같아진다.

 

잘 동작하는 것을 확인할 수 있습니다. 만세

 

왜 이렇게 Refactoring 을 하는 작업이 길어졌을까요? 그것은 바로 설계부터 어떤 함수를 만들지 정하지 않고, 우선 구현하고싶은 기능만 빠르게 구현하다보니, 이렇게 refactoring하는 작업이 길어진 것입니다.

만약에 설계Level부터 객체 분할을 지정하고 동작해야 하는 method를 미리 나열했으면 작업량은 어떻게 되었을까요? 시작하는 때에는 힘든 작업이 되겠지만, 설계가 끝나고 나면 그 뒤에는 큰 수정이 없는 단순한 작업만 남게 됩니다. 그러면 전체 작업량이 매우 감소하게 됩니다.

 

다음 시간에는, tetris 요구사항 재 설계를 진행해보도록 하겠습니다.