테트리스 게임을 두 가지 방법으로 개발하면서

하나의 제품이나 기능을 다양하게 개발하는 방법을

다음과 같이 단계별로 정리하였다.

1. 개요

2. 배열 방식의 도형 회전

3. Bitmask 방식의 도형 회전

4. 배열기반 회전 방식의 테트리스

5. Bitmask기반 회전 방식의 테트리스

6. requestAnimationFrame


앞서 정리했던 두 가지 회전 방법 중

Bitmask를 이용한 회전 방법을 기초로

테트리스를 구현하는 방법 (5 번)에 대하여 정리한다.


개발 단계는 앞서 정리한 배열법을 이용한 테트리스와 동일하게

개발하는 과정을 다음과 같이 세분화하여 구현하면서 정리한다.


1. 하나의 도형 움직이기 (down)

2. 도형들 움직이기와 게임 종료

3. 이동 중에 도형 회전

4. 채워진 행 제거

5. 키보드 조작과 도형별 색깔 지정



구현에 대한 개념은 앞서 앞서 정리한 배열법과 동일하다.

구현된 코드도 거의 유사하여,

차이나는 부분에 대해서만 간단하게 정리한다.


개발의 첫 단계로

테트리스 도형이 위에서 아래로 내려오는 기능을 구현한다.

구현 방법은

시간의 흐름(setInterval)에 따라 도형의 좌표(x, y)를 바꾸어 주고,

해당 좌표를 기준으로 도형을 구성하는 4 개의 사각형을 그려준다.


이상의 코드를 배열법으로 구현한 코드와 비교해 보면

회전에서 정리한 것과 같이

7 개의 도형을 저장하는 방식만 차이가 있고

거의 동일한 것을 알 수 있다.

즉, 배열법은 0 과 1 의 값을 가지는 3 차원 배열로 저장하고(shapes)

Bitmask는 16진수로 된 2 차원 배열로 저장하는 차이가 있다 [라인 8].


테트리스 도형 정보를 저장하는 방식외에도

복원한 정보에 맞추어 (0x8000 >> (y * 4 + x))

도형을 구성하는 사각형을 그리는 방식도 차이가 있다 [라인 40].


앞서 작성한 Bitmask 회전 예제 코드에,

시간의 흐름 (setInterval)에 따른 [라인 21]

위치 변경(sPos.y++)을 추가하여 작성하였다 [라인 45].

도형을 그리는 위치도 현재 위치(sPos.y)에

도형을 구성하는 사각형들의 위치를 계산하여 그려준다 [라인 41].


두번째로,

하나의 도형이 떨어져 바닥에 쌓이고

또 다른 도형이 떨어져 그 위에 쌓이는 기능을 구현한다.

하나의 도형이 이동할 때 마다,

다음 행에 다른 도형이 있는지 확인하는 기능을 구현하는 것이 핵심으로

한 칸씩 이동할 때 마다 다른 도형이 있는지 확인해서

다른 도형이 없으면 이동하고 (y++),

다른 도형이 있으면 멈추게 된다.


다음 행(정확하게는 칸)에 도형이 있는지 확인하기 위해,

게임판 (10 * 20) 전체에 대한 정보를 gamePanel에 저장해서 사용한다.


이러한 처리는 배열법이든 Bitmask든 동일하지만

도형 정보를 처리하는 데서 발생하는 차이를 구현해야 한다.

var gamePanel = [];
~~ 생략 ~~
function playingTetris() {
    if ( intersects(sPos.y + 1, sPos.x)) {
        for (var i = 0; i < 4; i++)
            for (var j = 0; j < 4; j++)
                if ((curShape & (0x8000 >> (i * 4 + j))) && gamePanel[sPos.y+i]) {
                    gamePanel[sPos.y+i][sPos.x+j] = 1;
                }
       
        curShape = getNextShape();       
        sPos = {x:0, y:0};
        if ( intersects(sPos.y, sPos.x)) {
            clearInterval(intervalHandler);
            alert("Game Over");
        }
    } else {
        sPos.y++;
    }
    draw();
}   

function intersects(y, x) {
    for (var i = 0; i < 4; i++)
        for (var j = 0; j < 4; j++)
            if (curShape & (0x8000 >> (i * 4 + j)))
                if (y+i >= 20 || x+j < 0 || x+j >= 10 || gamePanel[y+i][x+j])
                    return true;
    return false;
}

function draw() {
    ~~ 생략 ~~
    for (var y = 0; y < gamePanel.length; y++) {
        for (var x = 0; x < gamePanel[y].length; x++) {
            if (gamePanel[y][x]) {
                ctx.fillRect(x * 20, y * 20, 19, 19);
            }
        }
    }
   
    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            if (curShape & (0x8000 >> (y * 4 + x))) {
                ctx.fillRect((sPos.x+x) * 20, (sPos.y+y) * 20, 19, 19);
            }
        }
    }
}

전체 코드

이상의 코드는 배열법에서 작성한 것과 거의 동일하고

빨간색으로 표시한 코드만 차이가 있다 (0x8000 >> (i * 4 + j).

Bitmask법은 데이터를 2 진수(bit)로 계산해서

16 진수로 줄여서 저장하고,

비트 연산(>>, &)을 통해서 복원해서 사용한다.

복원하는 방법은 Bitmask회전에 정리하였다.


playingTetris() 함수에서

도형을 gamePanel에 도형 정보를 저장하는 부분은 참고 예제와 차이가 있다.

여기에서는 gamePanel에 1을 저장했지만

gamePanel[sPos.y+i][sPos.x+j] = 1;

참고 예제에서는 도형 정보를 하나의 클래스(Json)로 보고,

다음과 같이 해당 클래스를 넣어준다.

gamePanel[sPos.y+i][sPos.x+j] = curShape;

하나의 도형은 4 개의 사각형을 가지니

gamePanel에 4 개의 동일한 도형 정보를 중복하여 저장한다.


배열법과 Bitmask법으로 구현할 때의 차이를 강조하고

소스를 단순화하여 쉽게 설명하기 위해

이상과 같이 가급적 같은 코드로 작성했지만

참고 예제처럼 작성하는 것도 좋은 방법이니 소스를 확인해 보길 바란다.

참고 예제에서는 도형 정보들을

다음과 같이 도형의 모습에 대한 정보(blocks)와 색상(color)을

하나의 클래스(Json)으로 저장해서 사용하였다.

var i = { blocks: [0x0F00, 0x2222, 0x00F0, 0x4444], color: 'cyan'   };
var j = { blocks: [0x44C0, 0x8E00, 0x6440, 0x0E20], color: 'blue'   };
var l = { blocks: [0x4460, 0x0E80, 0xC440, 0x2E00], color: 'orange' };
var o = { blocks: [0xCC00, 0xCC00, 0xCC00, 0xCC00], color: 'yellow' };
var s = { blocks: [0x06C0, 0x8C40, 0x6C00, 0x4620], color: 'green'  };
var t = { blocks: [0x0E40, 0x4C40, 0x4E00, 0x4640], color: 'purple' };
var z = { blocks: [0x0C60, 0x4C80, 0xC600, 0x2640], color: 'red'    };

또 다른 참고 예제와의 차이는

도형 정보를 저장하는 shapes(참고 예제는 blocks)에 있다.

본 예제는 행을 Y축, 열을 X축으로 사용하여 shapes[y][x]로 사용하나

참고 예제는 행을 X축, 열을 Y축으로 사용하여 shapes[x][y]로 사용한다.

즉, 참고 예제는 다음 그림의 오른쪽과 같은 방식으로 사용되었다.

위의 그림에서

첫 그림이 테트리스 도형이 떨어지는 화면이다.

이 화면을 본 예제에서는 왼쪽 그림처럼 게임판(gamePanel)을 저장하고

참고한 예제에서는 오른쪽처럼 게임판(blicks)을 저장한다.


일반적으로 X축과 Y축을 표현할 때, X축이 먼저 나오도록 작성한다.

즉, X * Y로 표현하는데, 이 표현법을 그대로 배열로 작성한 것 같다.

본 예제에서는 Y * X로 사용하였다.


세번째로, 내려오는 도형을 버튼을 이용하여 조작하는 기능을 구현한다.

도형을 오른쪽으로 회전시키거나

왼쪽이나 오른쪽으로 이동시키거나, 그냥 떨어지도록 구현한다.

도형을 왼쪽 / 오른쪽 (X)으로 이동시키는 것은

왼쪽은 X축 변수(sPos.x) 값을 -1 하고,

오른쪽은 X축 변수(sPos.x) 값을 +1 한 후,

게임판을 새로 그려서 구현한다.


그냥 떨어지는 기능은

타이머를 사용하지 않고,

현재 위치 (sPos.y)에서 바닥(20)에 닿을 때 까지

1씩 증가시키면서 게임판을 계속 새로 그려서 구현한다.


회전은 앞서서 정리한 내용 중

오른쪽으로 회전하는 개념을 적용하면 된다.

function moveShape(value) {
    if ( !intersects(sPos.y, sPos.x+value)) {
        sPos.x += value;
        draw();
    }
}
function dropShape() {
    for (var y=sPos.y; y<ROW_CNT; y++) {
        if ( !intersects(sPos.y+1, sPos.x)) {
            sPos.y++;
            draw();
        } else {
            break;
        }
    }
}
function rotateShape() {
    curRotation = (curRotation + 1) % 4;
    curShape = shapes[curShapeType][curRotation];
    draw();
}

전체코드

이동 함수는 직접 도형 데이터를 조절하는 것이 아니라서

배열법과 Bitmask 법에서 동일하게 구현된다.

rotateShape()함수에서 구현된 도형의 회전만 차이가 있다.


Bitmask에서는 회전된 도형에 대한 정보를 가지고 있기 때문에

회전 각도를 4 개의 숫자를 가지는

변수(curRotation)의 값을 1 씩(90 도씩) 증가 시켜서

도형의 정보를 가르키도록 구현한다.


네 번째는 다음 표의 19 행처럼

게임판의 한 행이 도형의 사각형으로 다 찼을 경우,

해당 행을 제거하는 기능을 구현한다.


구현하는 방법은 게임판의 정보를 가지고 있는 2차원 배열,

gamePanel의 모든 행을 검사해서

각 행의 모든 열의 값이 1 이면 해당 행을 제거한다.

function removeRow() {
    var newRows = [];
    var k = ROW_CNT;
    for (var y = ROW_CNT-1; y>=0; y--) {
        for (var x = 0; x < COL_CNT; x++) {
            if (!gamePanel[y][x]) {
                newRows[--k] = gamePanel[y].slice();
                break;
            }
        }
    }
    for (var y = 0; y < k; y++) {
        newRows[y] = [];
        for (var x = 0; x < COL_CNT; x++)
            newRows[y][x] = 0;
    }
    return newRows;
}

전체코드

행 제거도 도형 정보를 건드리지 않기 때문에

배열법과 차이가 없다.


다만, 배열의 한 행을 제거하는 방법이

참고 예제는 다음과 같이 단순한 방법으로 구현되었다.

    function removeLines() {
        var x, y, complete, n = 0;
        for(y = ny ; y > 0 ; --y) {
            complete = true;
            for(x = 0 ; x < nx ; ++x) {
                if (!getBlock(x, y))
                    complete = false;
            }
            if (complete) {
                removeLine(y);
                y = y + 1; // recheck same line
                n++;
            }
        }
        if (n > 0) {
        addRows(n);
        addScore(100*Math.pow(2,n-1)); // 1: 100, 2: 200, 3: 400, 4: 800
      }
    }
    function removeLine(n) {
        var x, y;
        for(y = n ; y >= 0 ; --y) {
            for(x = 0 ; x < nx ; ++x)
                setBlock(x, y, (y == 0) ? null : getBlock(x, y-1));
        }
    }

removeLines() 함수에서 한 행이 모두 채워졌으면 (complete==true),

removeLine() 함수를 호출해서 해당 행을 삭제한다.

행을 삭제하는 방법은

해당 행(n) 부터 0 번째 행까지 반복하면서

현재 행(y)에 이전 행(y-1)의 값(getBlock)을 채워 넣는 것(setBlock)이다.

즉, 한 행씩 뒤로 당겨서 삭제하는 방식을 사용하였다.

(본 예제와 행열 구조가 반대라서, 참고 예제에서는 한 열을 뒤로 당기도록 구현)


그리고, 행을 삭제한 개수(n)만큼

게임판 정보 배열(blocks)의 앞에 행을 추가한다 (addRows).


이러한 방식은 앞서 작성한 방식보다 비효율적이다.

즉, 배열을 이용하여 구현한 예제에서 발췌한 방식이 조금 더 낫다.

배열법 방식은 Y축에 대해서 for문을 한번만 사용하지만

Bitmask 참고 예제에서 사용한 방식은 Y축에 대해서 for 문을 여러 번 사용한다.

알고리즘의 속도 표기법으로 설명 가능하니 생각해 보길 바란다.

배열법 방식은 새로운 배열을 추가로 사용하기 때문에 메모리를 더 사용한다.


마지막으로 떨어지는 현재 도형을

버튼이 아닌 키보드로 제어하는 것과

각 도형별로 다른 색상을 지정하는 기능을 구현한다.


키보드 제어는 HTML 버튼의 클릭이벤트 대신에

웹 문서의 키보드 이벤트를 이용하여 각 함수를 호출하면 된다.


도형별 색상은 게임판의 정보를 저장할 때, 도형의 종류를 저장하고

각 도형의 종류에 따라서 다른 색으로 도형을 그리면 된다.

다만, 도형에 대한 정보를 저장한다고 말하지만

도형의 정보가 아닌 도형을 구성하는 사각형의 정보가 저장된다.

 ~~ 생략 ~~
 
var KEY = { ESC: 27, SPACE: 32, LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40 };
var shapeColor = ["red", "orange", "yellow", "green", "blue", "indigo", "violet"];

 ~~ 생략 ~~

function playingTetris() {
    if ( intersects(sPos.y + 1, sPos.x)) {
        for (var i = 0; i < 4; i++)
            for (var j = 0; j < 4; j++)
                if ((curShape & (0x8000 >> (i * 4 + j))) && gamePanel[sPos.y+i]) {
                    gamePanel[sPos.y+i][sPos.x+j] = curShapeType+1;
                }
       
        curShape = getNextShape();       
        sPos = {x: (COL_CNT-4) / 2, y:0};
        if (intersects(sPos.y, sPos.x)) {
            clearInterval(intervalHandler);
            alert("Game Over");
        }
        gamePanel = removeRow();
    } else {
        sPos.y++;
    }
    draw();
}   

function draw() {
    ~~ 생략 ~~
 
    ctx.fillStyle = 'black';
    for (var y = 0; y < gamePanel.length; y++) {
        for (var x = 0; x < gamePanel[y].length; x++) {
            if (gamePanel[y][x]) {
                ctx.fillStyle = shapeColor[gamePanel[y][x]-1];
                ctx.fillRect(x * 20, y * 20, 19, 19);
            }
        }
    }
   
    ctx.fillStyle = shapeColor[curShapeType];
    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            if (curShape & (0x8000 >> (y * 4 + x))) {
                ctx.fillRect((sPos.x+x) * 20, (sPos.y+y) * 20, 19, 19);
            }
        }
    }
}

function keydown(ev) {
    var handled = false;
    switch(ev.keyCode) {
        case KEY.LEFT:   moveShape(-1); handled = true; break;
        case KEY.RIGHT:  moveShape( 1); handled = true; break;
        case KEY.UP:     rotateShape(); handled = true; break;
        case KEY.DOWN:   dropShape();      handled = true; break;
        case KEY.ESC:    clearInterval(intervalHandler); handled = true; break;
    }

    if (handled) {
        ev.preventDefault();
    }
}
   
document.addEventListener('keydown', keydown, false);

</script>

전체코드

키보드 제어와 도형별 색상도

배열법과 Bitmask를 이용한 코드를 모두 동일하게 구현하였다.

앞 서와 마찬가지로 빨간색으로 표시한 코드만 차이가 있다 (0x8000 >> (i * 4 + j).


이상의 코드를 정리하면

배열법과 Bitmask법 둘다 개발하는 코드는 동일한 것을 알 수 있다.

차이는 데이터를 저장한 방법(shapes)과

데이터를 복원하기 위한 코드(0x8000 >> (i * 4 + j)만 차이가 있다.


테트리스 개발 과정을 정리한 이유는

다양하게 개발하는 방법을 이상과 같이 정리하기 위한 것도 있지만

어느 한 소스만 좋을 것이라는 편견을 갖지 않기 위한 의미도 있다.

개념이나 방식들은 Bitmask법의 예제가 월등해 보이나 지나치게 어렵게 작성하였고,

배열법은 다소 이상한 방식(div 사용)으로 구현하였지만

쉽고 편리하게 사용할 수 있도록 작성되었다.

이 둘의 모든 코드를 정리하지 못하였으니,

잘 살펴보면 실력향상에 많은 도움이 될 것이다.




+ Recent posts