본문 바로가기

Development/Tetris

Python - 테트리스(Tetris) 만들기 (7) - Drop Timer

이전 시간에는 mino를 경계선 안에서 자유자재로 움직일 수 있도록 했습니다. 하지만 다음 블럭이 나오는 일은 없었죠.

이번 글에서는, 블럭이 놓이는 조건을 작성하고, 블럭이 놓인 후 다음 블럭이 나올 수 있도록 해보겠습니다.

 

블럭이 놓이는 조건이 뭘까요? 문서를 찾아보니 Lock Delay라는 문서가 있습니다.

 

미노가 놓이는 순간을 설명하고 있다.
영상자료

미노가 놓인다고 바로 멈추는 것이 아니라, 적절한 시간이 지나는 동안에도 움직임이 없으면 (보통은 0.5초) 멈춥니다.

이 동작을 만들기 위해서는

  1. 타이머를 동작시킬 조건 (더 이상 밑으로 내려가지 못함)
  2. 타이머가 필요 (0.5초를 재야 함)
  3. 타이머가 0.5초동안 움직인 후 다음 블럭 생성

위 3동작이 있어야 할 것입니다. 바로 작성해 봅시다.

 

 


1. 더 이상 밑으로 내려가지 못하는 조건 생성

먼저, 현재의 블럭이 더 이상 밑으로 내려가지 못하는지 상태인지 확인해 봅시다.

    def execute(self):
        self._init()

        while self._running:
            self._event()
            self._system()
            self._update()
        self._quit()

 

반복문에 system 함수를 추가하도록 합시다. system 에서 _enable_down()이란 함수를 추가하여 현재 미노가 밑으로 못 내려가는지 check하고 결과를 print 합니다.

밑으로 못내려 가는지에 대한 기준을 우선 y축 position을 기준으로 잡았습니다.

    def _system(self):
        if not self._enable_down():
            print("not enable")
    
    def _enable_down(self):
        is_enable = True
        for block in self._current_mino.blocks:
            if block[1] + 1 >= self._field_h:
                is_enable = False
                break;
        return is_enable

 

이렇게 코딩하면 미노가 바닥에 도달해 내려갈 수 없을 때 "not enable"이라는 print가 동작할 것입니다.

미노가 바닥에 도착했을 때
not enable이라는 글자를 작성한다.
한번만 알려줘도 충분한데...

print를 통해 self._enable_down()이라는 함수가 잘 동작하는 것을 확인했습니다.

이제는 self._enable_down()함수가 True가 되면, 타이머가 동작하게 하면 됩니다.

 

 


2. Timer를 사용해 보자.

pygame 공식문서에서 timer로 검색해봅시다. pygame.time이라는 method가 눈에 띕니다.

 

pygame.time에 대한 간단한 설명. 1000분의 1초까지도 표현이 가능하다고 한다.

 

이 중에서 눈에 띄는 함수가 있으니, 바로 pygame.time.set_timer입니다. 이 함수를 이용해서 5초 후에 trigger를 일으키는 동작을 짜달라고 ChatGPT에게 물어보았습니다.

사실 이 질문만으로는 set_timer를 사용하지 않아서, set_timer를 사용한 코드를 작성해달라고 재 요청 했다.

 

ChatGPT는 좋은 예제 코드를 작성해 주었습니다.

import pygame
pygame.init()

# Set up the window
win = pygame.display.set_mode((500, 500))
pygame.display.set_caption("Timer Example")

# Set up the flags
flag1 = True
flag2 = False

# Define an event to trigger every 5 seconds
TIMER_EVENT = pygame.USEREVENT + 1
pygame.time.set_timer(TIMER_EVENT, 5000)  # 5000 milliseconds = 5 seconds

# Main game loop
running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        
        # Check if the timer event has triggered
        if event.type == TIMER_EVENT:
            flag1 = False
            flag2 = True
    
    # Draw the flags
    if flag1:
        pygame.draw.rect(win, (255, 0, 0), (50, 50, 50, 50))
    elif flag2:
        pygame.draw.rect(win, (0, 255, 0), (50, 50, 50, 50))
    
    pygame.display.update()

# Clean up
pygame.quit()

* 출처 : ChatGPT

 

 

ChatGPT가 작성한 타이머 예제를 보면 다음과 같이 동작합니다.

  1. pygame.USEREVENT를 통해서 고유한 TIMER_EVENT를 만들고
  2. pygame.timer.set_timer로 타이머를 발동합니다.
  3. 시간이 지나면 pygame.event.get()에서 event.type == TIMER_EVENT인 event를 받을 수 있습니다.

이런 동작을 활용하여, 필자도 타이머를 활용한 코드를 작성해 보았습니다.

먼저 DROP_EVENT를 작성합니다.

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):
        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._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

 

그리고 event에 DROP_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:	## 작성된 부분
                print("dropped")                ## 작성된 부분

 

그리고 system에 set_timer를 set, reset 하는 함수를 작성합니다. reset은 millisecond를 0으로 두면 reset 된다고 pygame에서 설명하고 있습니다.

    def _system(self):
        if self._enable_down():
            pg.time.set_timer(self.DROP_EVENT, 0)
        else:
            pg.time.set_timer(self.DROP_EVENT, 500)

 

이러면 동작을 할까요 안할까요?

 

 


이번에도 실패!

블럭을 내려 보았는데...
감감무소식...

 

무엇이 문제일까요? pg.time.set_timer(self.DROP_EVENT, 500)이 문제가 있나 해서 debug stop을 걸어보았습니다.

구문은 잘 동작한다.

이상합니다. set 함수까지는 잘 동작하는데 말이죠. 그리고 debugging을 풀자마자 터미널에서는 dropped가 엄청 적혔습니다.

dropped가 많이 적혔다. 동작은 하는 것 같은데 왜 stop을 걸어야만 동작을 했을까?

 

이유는 바로 set_timer를 동작시키고, 다음 루프에서 또 set_timer를 동작시켰기 때문입니다. 그리고 몰려있던 set_timer가 debugger를 통해 코드 동작이 멈춰있는 사이, event queue에는 self.DROP_EVENT가 잔뜩 쌓여있게 되고, debugger를 풀면 print("dropped")가 동작하게 되는 것입니다.

 

이런 동작을 막기 위해서는 set_timer가 동작했을 때, 다시 동작하지 않도록 막아줘야 합니다.

 


Flag를 통해 set_timer 중복 호출을 방지

먼저 중복을 방지할 flag역할을 할 drop_delay_status를 작성합니다.

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):
        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._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 ## 작성된 부분

 

그리고 _system에서 flag를 적극 활용합니다.

    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)

 

이렇게 하면 제대로 동작할까요?

0.5초마다 동작하는 것을 확인할 수 있다.

 

드디어 정상적인 timer가 동작하는 것을 확인할 수 있었습니다. 이제는 drop이 되면 새로운 mino를 소환할 차례입니다.

 

 


Drop 후 다음 블럭 생성 확인

drop event가 일어나면 print("dropped") 대신 실제로 새로운 미노를 나타내도록 함수를 작성합니다. 이전에 사용되었던 _set_mino_init_position을 작성해서 새로운 미노가 등장하도록 합니다.

    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._current_mino = self._set_mino_init_position(1) ## 작성된 부분

 

그러면 미노가 drop 되었을 때, 다른 미노가 등장하게 됩니다. 하지만 원래 있던 기다란 미노가 사라졌군요.

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

 

Drop된 미노가 사라졌습니다. 왜냐하면 미노가 drop 된 후 field 남아있는 기능을 작성한 적이 없기 때문입니다.

 

사실, 지금 코드에는 field가 없습니다. field의 길이만큼 rect만 반복해서 그릴뿐이죠.

    def _draw_field_rect_list(self):
        """ field 크기만 정의되었을 뿐, field 내 블럭 단위로 정의된 부분이 없다 """
        for i in range(self._field_w):
            for j in range(self._field_h):
                _new_rect = pg.Rect(i*self._t_size, j*self._t_size, self._t_size, self._t_size)
                pg.draw.rect(self._display_surf, (255, 255, 255), _new_rect, 1)

 

그래서 다음 글에서는 field를 제대로 제작해 보도록 하겠습니다. 이왕이면 tetris_wiki에 있던 field의 크기인 10 * 40으로 제작하고, 실제 보이는 것은 10 * 20으로 해보는 것도 좋겠죠??

 

지금까지의 코드는 아래와 같습니다. 점점 Magic number들이 넘처나죠? 슬슬 정리할 때가 되었습니다.

import pygame as pg
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


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):
        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._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

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

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

    def _draw_field_rect_list(self):
        for i in range(self._field_w):
            for j in range(self._field_h):
                _new_rect = pg.Rect(i*self._t_size, j*self._t_size, self._t_size, self._t_size)
                pg.draw.rect(self._display_surf, (255, 255, 255), _new_rect, 1)

    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._current_mino = self._set_mino_init_position(1)

    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 block in self._current_mino.blocks:
            if block[1] + 1 >= self._field_h:
                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://tetris.fandom.com/wiki/Lock_delay

https://www.pygame.org/docs/ref/time.html#pygame.time.set_timer