원래 이런거 퍼오려면 출처를 써야하는데 귀찮아서 안쓴다. 테트리스는 프로그래밍카페 좀만 뒤져봐도 한줄마다 주석 잘 달려서 이해하기 쉬운것도 많은듯. 


근데 테트리스 처음보고 이해하기 쉽지 않다. 말이 쉽지. 이상하게 남의 소스는 주석이 안 달리면 외계어 같아. 이거 언제까지 이러냐? 슈퍼개발자들도 남의 소스코드분석 오래걸림?


책을 읽듯이 한번 훑어보면 이해하는게 정상이야? 아니면 원래 남의 소스코드는 분석이 오래걸려?



14-3.Tetris1


14-3-가.테트리스

테트리스는 1985년 러시아의 프로그래머인 알렉세이 파지노프가 처음 개발한 퍼즐 게임이다. 게임룰이 단순하고 될 듯 하면서도 잘 안되기 때문에 사람을 빠져들게 만드는 중독성을 가지고 있으며 문명의 혜택을 받는 사람 중에 이 게임을 해 보지 않은 사람이 없을 정도다. 파지노프는 순식간에 유명해지기는 했지만 당시는 불법 복제가 난무하던 시절이라 금전적인 이득을 보지는 못했다고 한다. 사람들이 돈을 주고 게임을 한다는 개념에 익숙하지 않았기 때문이다. 대신 마이크로소프트사에 스카웃되어 지금은 마이크로소프트에서 게임 개발을 하고 있다.

너무 너무 유명한데다 안 해본 사람이 거의 없으므로 굳이 게임 방법을 설명할 필요는 없을 것 같고 바로 제작에 들어가 보자. 게임 규칙이 복잡해서 굉장히 많은 코드를 필요로 할 것 같지만 막상 작성해 보면 그다지 길지는 않다. 물론 예제 수준 치고는 짧은 편도 아니지만 지금까지 C언어를 부지런히 학습해 왔다면 이 예제는 능히 분석해 볼 수 있을 것이다. 전체 소스는 다음과 같다.

 

예 제 : Tetris1

#include <Turboc.h>

 

#define LEFT 75

#define RIGHT 77

#define UP 72

#define DOWN 80

#define ESC 27

#define BX 5

#define BY 1

#define BW 10

#define BH 20

 

void DrawScreen();

void DrawBoard();

BOOL ProcessKey();

void PrintBrick(BOOL Show);

int GetAround(int x,int y,int b,int r);

BOOL MoveDown();

void TestFull();

 

struct Point {

     int x,y;

};

Point Shape[][4][4]={

     { {0,0,1,0,2,0,-1,0}, {0,0,0,1,0,-1,0,-2}, {0,0,1,0,2,0,-1,0}, {0,0,0,1,0,-1,0,-2} },

     { {0,0,1,0,0,1,1,1}, {0,0,1,0,0,1,1,1}, {0,0,1,0,0,1,1,1}, {0,0,1,0,0,1,1,1} },

     { {0,0,-1,0,0,-1,1,-1}, {0,0,0,1,-1,0,-1,-1}, {0,0,-1,0,0,-1,1,-1}, {0,0,0,1,-1,0,-1,-1} },

     { {0,0,-1,-1,0,-1,1,0}, {0,0,-1,0,-1,1,0,-1}, {0,0,-1,-1,0,-1,1,0}, {0,0,-1,0,-1,1,0,-1} },

     { {0,0,-1,0,1,0,-1,-1}, {0,0,0,-1,0,1,-1,1}, {0,0,-1,0,1,0,1,1}, {0,0,0,-1,0,1,1,-1} },

     { {0,0,1,0,-1,0,1,-1}, {0,0,0,1,0,-1,-1,-1}, {0,0,1,0,-1,0,-1,1}, {0,0,0,-1,0,1,1,1} },

     { {0,0,-1,0,1,0,0,1}, {0,0,0,-1,0,1,1,0}, {0,0,-1,0,1,0,0,-1}, {0,0,-1,0,0,-1,0,1} },

};

 

enum { EMPTY, BRICK, WALL };

char *arTile[]={". ","■","□"};

int board[BW+2][BH+2];

int nx,ny;

int brick,rot;

 

void main()

{

     int nFrame, nStay;

     int x,y;

 

     setcursortype(NOCURSOR);

     randomize();

     clrscr();

     for (x=0;x<BW+2;x++) {

          for (y=0;y<BH+2;y++) {

              board[x][y] = (y==0 || y==BH+1 || x==0 || x==BW+1) ? WALL:EMPTY;

          }

     }

     DrawScreen();

     nFrame=20;

 

     for (;1;) {

          brick=random(sizeof(Shape)/sizeof(Shape[0]));

          nx=BW/2;

          ny=3;

          rot=0;

          PrintBrick(TRUE);

 

          if (GetAround(nx,ny,brick,rot) != EMPTY) break;

          nStay=nFrame;

          for (;2;) {

              if (--nStay == 0) {

                   nStay=nFrame;

                   if (MoveDown()) break;

              }

              if (ProcessKey()) break;

              delay(1000/20);

          }

     }

     clrscr();

     gotoxy(30,12);puts("G A M E  O V E R");

     setcursortype(NORMALCURSOR);

}

 

void DrawScreen()

{

     int x,y;

 

     for (x=0;x<BW+2;x++) {

          for (y=0;y<BH+2;y++) {

              gotoxy(BX+x*2,BY+y);

              puts(arTile[board[x][y]]);

          }

     }

 

     gotoxy(50,3);puts("Tetris Ver 1.0");

     gotoxy(50,5);puts("좌우:이동, 위:회전, 아래:내림");

     gotoxy(50,6);puts("공백:전부 내림");

}

 

void DrawBoard()

{

     int x,y;

 

     for (x=1;x<BW+1;x++) {

          for (y=1;y<BH+1;y++) {

              gotoxy(BX+x*2,BY+y);

              puts(arTile[board[x][y]]);

          }

     }

}

 

BOOL ProcessKey()

{

     int ch,trot;

 

     if (kbhit()) {

          ch=getch();

          if (ch == 0xE0 || ch == 0) {

              ch=getch();

              switch (ch) {

              case LEFT:

                   if (GetAround(nx-1,ny,brick,rot) == EMPTY) {

                        PrintBrick(FALSE);

                        nx--;

                        PrintBrick(TRUE);

                   }

                   break;

              case RIGHT:

                   if (GetAround(nx+1,ny,brick,rot) == EMPTY) {

                        PrintBrick(FALSE);

                        nx++;

                        PrintBrick(TRUE);

                   }

                   break;

              case UP:

                   trot=(rot == 3 ? 0:rot+1);

                   if (GetAround(nx,ny,brick,trot) == EMPTY) {

                        PrintBrick(FALSE);

                        rot=trot;

                        PrintBrick(TRUE);

                   }

                   break;

              case DOWN:

                   if (MoveDown()) {

                        return TRUE;

                   }

                   break;

              }

          } else {

              switch (ch) {

              case ' ':

                   while(MoveDown()==FALSE) {;}

                   return TRUE;

              }

          }

     }

     return FALSE;

}

 

void PrintBrick(BOOL Show)

{

     int i;

 

     for (i=0;i<4;i++) {

          gotoxy(BX+(Shape[brick][rot][i].x+nx)*2,BY+Shape[brick][rot][i].y+ny);

          puts(arTile[Show ? BRICK:EMPTY]);

     }

}

 

int GetAround(int x,int y,int b,int r)

{

     int i,k=EMPTY;

 

     for (i=0;i<4;i++) {

          k=max(k,board[x+Shape[b][r][i].x][y+Shape[b][r][i].y]);

     }

     return k;

}

 

BOOL MoveDown()

{

     if (GetAround(nx,ny+1,brick,rot) != EMPTY) {

          TestFull();

          return TRUE;

     }

     PrintBrick(FALSE);

     ny++;

     PrintBrick(TRUE);

     return FALSE;

}

 

void TestFull()

{

     int i,x,y,ty;

 

     for (i=0;i<4;i++) {

          board[nx+Shape[brick][rot][i].x][ny+Shape[brick][rot][i].y]=BRICK;

     }

 

     for (y=1;y<BH+1;y++) {

          for (x=1;x<BW+1;x++) {

              if (board[x][y] != BRICK) break;

          }

          if (x == BW+1) {

              for (ty=y;ty>1;ty--) {

                   for (x=1;x<BW+1;x++) {

                        board[x][ty]=board[x][ty-1];

                   }

              }

              DrawBoard();

              delay(200);

          }

     }

}

 

콘솔에서 실행되기 때문에 별다른 장식은 없고 부가 기능이 없어 소스도 200줄 조금 넘는 정도밖에 안된다. 흑백의 콘솔 화면에서 실행되기 때문에 별로 볼 품은 없지만 그래도 게임은 잘 진행되며 오리지널 테트리스의 기능은 다 가지고 있다.



테트리스가 히트를 치면서 화려한 그래픽과 사운드, 그리고 재미있는 규칙을 추가한 수많은 아류 버전이 발표되었다. 현재까지도 웹에서 끈질긴 생명력을 자랑하고 있으며 핸드폰에서도 이 게임을 할 수 있다. 그만큼 이 게임은 개작의 여지가 많고 좋은 아이디어만 있다면 얼마든지 흥미진진하게 만들어 볼 수 있는 좋은 습작거리이다. 이 게임의 소스를 분석해 보되 바로 설명을 보지 말고 가급적이면 혼자 힘으로 먼저 분석해 보기 바란다. 남의 소스를 읽고 구현 기법을 익히는 것도 일종의 기술이며 많은 연습이 필요하다.

우선 전체 구조를 보자. 어떤 프로그램을 분석할 때는 항상 프로그램 전체의 상태를 표현하는 전역변수의 역할을 파악하고 다음으로 함수의 구조를 분석해야 한다. 프로그램 선두에는 매크로들이 정의되어 있는데 LEFT, RIGHT 등의 키 매크로는 지금까지 줄곧 사용해 왔던 것들이다. BX, BY 매크로는 게임판의 좌상단 좌표이며 BW, BH는 게임판의 폭과 높이인데 (5,1)에서 시작하여 폭 10, 높이 20을 가지도록 정의되어 있다. BX, BY는 외부 벽의 좌상단 좌표이며 BW, BH에 외부 벽은 제외하고 순수한 게임판의 폭과 높이로 정의하였다.



소스 곳곳에서 이 매크로들을 참조하여 출력 위치를 결정하므로 만약 위치나 크기를 바꾸고 싶다면 이 매크로의 값을 변경하면 된다. 높이를 줄이면 게임의 난이도가 증가할 것이고 폭을 넓히면 좀 쉬워진다. 게임판의 위치와 크기를 지정하는 이 매크로들은 게임을 분석하고 유지하는데 굉장한 도움을 준다. 상수를 직접 사용하면 이 값이 무엇을 의미하는지 금방 알아보기 힘들지만 이 매크로 덕분에 BW+2가 게임판의 폭에 양쪽 벽의 폭을 더한 값이라는 것을 직관적으로 알 수 있게 된다.

Point, Shape 구조체 배열은 벽돌의 모양을 정의하는데 다소 복잡하므로 다음 항에서 따로 분석해 보자. EMPTY, BRICK, WALL 열거 멤버들은 게임판의 상태를 표현하며 게임판은 빈 공간, 벽돌, 벽 셋 중 하나의 상태를 가진다. 이 열거형은 태그명을 주지도 않았고 변수를 선언하지도 않았으므로 오로지 열거 멤버만 0, 1, 2로 정의하는 역할을 한다. 세 가지 상태값에 대해 이름을 붙임으로써 개발자의 부족한 기억력을 보조해준다. #define으로 매크로 상수를 각각 정의하는 것과 기능적으로 동일하지만 중복되지 않는 일련의 값이므로 열거 멤버로 선언하는 것이 더 간편하다.

arTile 배열은 공간, 벽돌, 벽의 모양을 정의하는 크기 3의 문자열 배열(문법적으로는 문자형 포인터 배열)인데 각 타일은 2개의 문자로 구성된다. 콘솔 화면은 80*25의 해상도를 가지며 문자들이 세로로 길쭉하기 때문에 정사각형 타일을 그리기 위해서는 두 개의 문자로 한 칸을 표현하는 것이 보기에 좋다. 만약 #이나 $같은 영문자로 한 칸을 그리면 벽돌들이 너무 날씬해져서 현실감이 없으며 회전할 때 왜곡이 심해져 게임의 재미도 반감된다. 타일 하나를 2문자로 표시하면 보기에는 좋지만 출력할 때마다 x좌표에 2를 곱해야 하는 번거로움이 있기는 하다.



공간 타일은 점 하나와 공백(". ")으로 정의되어 있는데 공중에 떠 있는 벽돌의 수직 위치를 쉽게 가늠할 수 있도록 공간에 점을 찍어 주었다. 벽돌과 벽의 타일인 "■", "□"문자는 한글과 같은 2바이트 문자이며 문자표(charmap)에서 복사해 붙여 넣은 것이다. 한글 윈도우즈가 아니거나 코드 페이지가 한글(949)이 아니면 이 문자들이 깨져 보이므로 이럴 때는 "##"이나 "WW"등의 영문 기호로 바꾸는 것이 좋다. arTile배열은 요소 순서를 빈 공간, 벽돌, 벽으로 정의하여 열거 멤버 EMPTY, BRICK, WALL을 이 배열의 첨자로 바로 사용할 수 있도록 하였다. 즉 벽돌 모양을 출력하고 싶으면 arTile[BRICK] 문자열을 출력하고 지울 때는 arTile[EMPTY]를 출력하면 된다.

board 배열은 전체 게임판의 상태, 즉 벽돌이 어디에 얼마만큼 쌓여 있는지를 기억한다. 게임판의 상하좌우에 벽이 있으므로 게임판의 폭과 높이인 BW, BH에 2씩 더해 준 크기로 선언하였다. 이 배열 요소들은 각 칸의 상태인 EMPTY, BRICK, WALL값 중 하나를 가진다. 첫 번째 첨자를 x축 값, 두 번째 첨자를 y축 값으로 정의했으므로 이 배열은 화면의 상태를 반시계 방향으로 90도 회전한 상태로 기억한다. 좌표를 지정할 때는 보통 x, y순을 사용하므로 이 배열의 첨자도 관행대로 정의했다.

board 배열에는 이미 바닥에 착륙한 벽돌의 정보만 기록되며 이동중인 벽돌은 아직 자리가 정해지지 않았으므로 기록되지 않는다. nx, ny는 이동중인 벽돌의 현재 좌표이되 이 값은 물론 배열상의 좌표값이다. 배열상의 좌표를 화면상의 좌표로 바꿀 때는 BX+nx*2, BY+ny식을 사용하면 된다. brick, rot는 이동중인 벽돌의 번호와 회전 모양을 기억한다. brick, rot, nx, ny 전역변수를 참조하면 어떤 벽돌이 어떤 모양으로 어느 위치에 있는지를 알게 된다.

다음은 이 예제의 함수들에 대해 분석해 보자. main외에 7개의 함수가 정의되어 있는데 본체의 코드는 차차 분석해 볼 것이고 역할만 간략하게 정리해 보았다.

 

함수

설명

DrawScreen

화면 전체를 그린다. 게임판과 게임 이름, 벽까지 한꺼번에 그린다.

DrawBoard

게임판만 그린다. 즉 외부벽과 문자열들은 빼고 쌓여 있는 벽돌만 그린다.

ProcessKey

키 입력을 처리하는데 main 함수의 부담을 덜어주기 위해 별도의 함수로 분리해 놓았다. 이동중인 벽돌이 바닥에 닿으면 TRUE를 리턴한다.

PrintBrick

벽돌을 출력하거나 삭제하는데 이동중인 벽돌을 대상으로 하므로 전역변수 brick, rot, nx, ny값을 참조한다.

GetAround

벽돌 주변에 무엇이 있는지 검사하여 벽돌의 이동 및 회전 가능성을 조사한다. 이동중인 벽돌의 주변을 조사하는 것이 아니므로 인수로 전달된 위치의 벽돌 모양을 참조한다.

MoveDown

벽돌을 한칸 아래로 이동시킨다. 만약 바닥에 닿았다면 TestFull 함수를 호출한 후 TRUE를 리턴한다.

TestFull

수평으로 다 채워진 줄을 찾아 삭제한다.

 

처음 분석할 때는 이 함수들의 동작을 완전히 이해할 수 없으므로 일단 이름으로 기능을 유추한 후에 본체 코드를 분석해 보는 것이 좋다.