본문 바로가기

Development/Tetris

Python - 테트리스(Tetris) 만들기 (8) - Field

이전 시간에서의 문제점은, current mino가 drop까지는 되었는데, Field에 남지 않는 문제가 있었습니다.

긴 막대기가 사라졌다...?

 

이렇게 된 원인은 2가지가 있습니다.

1. field 자체가 정의되어 있지 않다.

2. mino를 field에 남기는 함수가 없다.

 

그래서, 이번시간에는 field를 제대로 만들어 보고자 합니다.

 

1. Field를 만들어보자.

이전에 처음 field를 표현할 때는 10 x 20으로 보인다 그래서 사각형을 해당 크기만큼 그렸었습니다.

실제 게임상에서 요구하는 Field도 10 x 20일까요?

 

10 x 40으로 설명되어있다. Playfield의 문서를 참조해도 같은 내용이다.

 

10 x 40을 안내하면서 20만 보인다고 하고 있습니다. 즉 이런 그림의 형태로 필드라고 생각할 수 있습니다.

tetris의 Field 위에는 20칸이 더 숨어있다.

 

이해가 어려우신 분들은 아래 영상까지 같이 참고 바랍니다. 

10 x 40 Field 크기를 활용한 글리치 현상

 

위의 버그를 고려하기 위해 10x40보다 좀 더 큰 크기로 만들 수도 있지만, 우선은 10x40을 기준으로 만들어 보도록 합시다.

 

class Tetris:
    DROP_EVENT = pg.USEREVENT + 1

    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._display_surf = None
        self._running = False
        
        ## 추가 작성된 코드
        self._h_padding = field_h if h_padding is None else h_padding
        self._field = [["E" for _ in range(self.w)] for _ in range(self.h + self._h_padding)]
        
        self._mino_list = [
            Mino("I", [[-2, 0], [-1, 0], [ 0, 0], [ 1, 0]], (54, 167, 141)),
            Mino("O", [[-1,-1], [ 0,-1], [-1, 0], [ 0, 0]], (166, 166, 81)),
            Mino("S", [[-1, 0], [ 0, 0], [ 0,-1], [ 1,-1]], (177, 86,  90)),
            Mino("Z", [[-1,-1], [ 0,-1], [ 0, 0], [ 1, 0]], (143, 173, 60)),
            Mino("L", [[-1, 0], [ 0, 0], [ 1, 0], [ 1,-1]], (73,  94, 158)),
            Mino("J", [[-1,-1], [-1, 0], [ 0, 0], [ 1, 0]], (202, 121, 65)),
            Mino("T", [[-1, 0], [ 0, 0], [ 0,-1], [ 1, 0]], (183, 92, 165))
        ]
        self._current_mino = None

        self._drop_delay_status = False

 

h_padding을 통해서 10 * 20의 play공간과 위의 숨겨진 공간을 표현해 보았습니다. 이러면 자연스럽게 10 x 20 테트리스 설정을 하면 10 x 40 field가 만들어지게 됩니다. 만약 10 x 30의 긴 테트리스가 하고 싶어지게 되면, 10 x 60 field가 만들어질 것입니다.

 

"E"는 비었다는 뜻으로 작성했습니다. 비어있는 "E" 정보에 mino가 "I", "O" 등으로 바뀌게 될 것입니다.

그런데 "E"는 너무 내용이 생략되어 모르는 사람들이 보면 의미를 알기 어렵습니다. 그러므로 의미를 알 수 있도록 다시 작성합니다.

 

class Tetris:
    DROP_EVENT = pg.USEREVENT + 1
    ## 작성된 부분
    EMPTY = "E"

    def __init__(self, weight: int, height: int,
                 field_w:int=10, field_h:int=20, tile_size:int=10, h_padding:int=None):
        self._size = self.weight, self.height = weight, height
        self._field_w, self._field_h, self._t_size = field_w, field_h, tile_size
        self._display_surf = None
        self._running = False
        self._h_padding = field_h if h_padding is None else h_padding
        
        ## 작성된 부분
        self._field = [[self.EMPTY for _ in range(self.w)] for _ in range(self.h + self._h_padding)]

 

 

2. Field에 mino가 쌓이도록 해보자

mino가 쌓이려면, Field를 불러와서 현재의 mino가 drop되었을 때, Field에 있는 Empty 정보를 mino정보로 대체해야 합니다. mino의 text를 Field에 대입하고, draw할 때는 해당 text를 mino에서 읽어서 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._display_surf = None
        self._running = False
        self._h_padding = field_h if h_padding is None else h_padding
        self._field = [[self.EMPTY for _ in range(self.w)] for _ in range(self.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._current_mino = None
        self._drop_delay_status = False

 

수시로 mino의 정보를 가져와야 하기에, _mino_list를 dictionary type으로 바뀌었습니다. 이유는 block에서 Color정보를 받아야 할 때, list에서 찾는 것보다 dict에서 찾는 것이 더욱 효율적이기 때문입니다.

 

_field가 그리는 부분을 다음과 같이 바꿔줍시다.

    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)

 

흠...if문이 끼어든게 맘에 들지는 않습니다만 일단은 넘어가도록 하겠습니다.

 

mino_dict으로 바꾸니, index를 통해 mino를 출력했던 것들이 문제가 생깁니다. 바꿔줍시다.

 

우선은 I_mino만 나오도록 설정

    def _event(self):
        for event in pg.event.get():
            if event.type == pg.QUIT:   ## Window 상단 X버튼
                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._current_mino = self._set_mino_init_position(self.I_MINO)

 

input을 key로 name 변경

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

 

drop되면 field를 update하도록 추가

    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)

 

field를 update하는 부분. name으로 update합니다.

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

 

자 구동을 한번 해보겠습니다.

 

 

한 번에 되는게 없군요. 무엇이 문제일까요?

drop 되니까 갑자기 블럭이 옆으로 빠져나간다...?

 

이유는 바로 mino_dict에서 block을 불러올 때 원본을 그대로 가져와서 사용하다 보니 생기는 문제입니다.

Mino를 불러올 때 set_init동작에서 x position을 옮기는 함수가 있습니다.

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

 

이 함수가 curr_mino를 불러올 때, 새 mino를 가져오는 것이 아니라 동작했던 mino를 가져오면서 생긴 문제입니다.

 

이 부분을 해결하려면, 처음부터 mino를 원본 통채로 가져오지 않고 copy해서 가져와야 합니다. 그래야 원본의 정보가 변경되지 않습니다.

 

객체를 copy하는 방법으로는 deepcopy library를 사용하면 됩니다.

import pygame as pg
from typing import List
from copy import deepcopy	## 추가된 부분

 

이제는 set_mino가 동작하면 curr_mino를 deepcopy해서 가져옵니다.

    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

 

그리고 실행해 보겠습니다

 

여전히 Field에 mino가 쌓이질 않습니다.

field 10 x 40에 대한 설정은 끝났지만 field에 mino가 쌓이질 않는다.

 

여전히 Field에 쌓이지는 않는 모습입니다. 제대로 쌓이고 있는지 field update에 디버깅을 해보겠습니다.

 

update_field에 break point를 설정

 

python을 실행하면

drop 동작에서 update_field가 동작한다.

 

무수히 찍혀있는 'E'를 볼 수 있다.

 

이제 _update_field가 loop를 실행하게 하면 Field에 'I'값(I_MINO의 정보)이 있어야 합니다.

index가 19인 영역에서 I로 바뀌었다.

'I'로 바뀌긴 했는데, field viewing 영역(20~39)이 아닌 다른 곳에서 I로 바뀐 것을 볼 수 있습니다. padding이 적용이 안되어서 그런듯 합니다. update에도 padding을 적용해 보겠습니다.

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

 

이번에는 동작하는지 볼까요??

드디어 제대로 동작합니다!!

 

drop 기준이 바닥으로 되어 있고 블럭끼리 겹쳤을 때에 대한 조건은 없어서 mino끼리 겹치게 됩니다. drop기준을 바닥이 아닌 block으로 바꿔봅시다.

drop status를 결정하는 것은 바로 enable_down이었습니다. 수정합시다.

    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][b[0]] != self.EMPTY :
                is_enable = False
                break;
        return is_enable

 

 

블럭에 대한 drop조건은 만족했지만 move에서 문제가 있습니다.

drop 조건(0.5초)은 맞춰졌지만, 빠른 move에는 블럭이 통과 됩니다.

 

계속 기능 구현에 실패하고 있습니다. 뿐만 아니라 h_padding이란 개념이 들어가면서 code가 점점 지저분해지고 있습니다. 슬슬 코딩을 멈추고, refactoring을 해야 할 때입니다.

 

* 계속되는 기능 추가로 인해 코드가 매우 복잡해졌습니다.

기능 구현을 실패하고 있는 지금 시점에서는, 기능추가보다는 코드를 한 번 다듬어야 합니다. 모듈화가 제대로 진행되고 있는지, 함수나 객체가 분리되서 작업을 하고 있는지 등을 점검하고 복잡한 부분을 풀어야 합니다. 이러한 활동을 Refactoring이라 합니다. 코드가 복잡해 졌을 때, Refactoring없이 맹목적인 기능 추가는 기술적 부채(technical debt)를 발생시킵니다. 코드가 한번 정리된 후에 기능추가를 해야만 앞으로의 개발 속도도 유지될 것입니다. 만약 기능추가를 강행한다면, 지금 당장은 개발되는 속도가 더 빨라 보일지는 몰라도 나중에는 손댈 수 없는 코드가 나올 것입니다. 그렇게 되면 누구도 유지보수 하고 싶어하지 않는 쓰레기 코드가 탄생하게 됩니다. 심지어 본인을 포함해서 말이죠.

 

다음 시간에는 Refectoring을 하는 시간을 가져보도록 하겠습니다.

 

 

현재 엉망인 코드는 아래와 같습니다. 어떻게 해야 멋있는 코드로 바꿀 수 있을까요?

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()

 


*reference

https://www.youtube.com/watch?v=0RmObY-x9Fc 

https://tetris.fandom.com/wiki/Tetris_Guideline