많은 사람들이 좋아하는 테트리스 게임을

두 가지 방법으로 개발하면서

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

덤으로 간단한 게임 개발법과

자바스크립트 Canvas 사용법도 익힐 수 있다.


자바 스크립트를 이용하여 구현하고 정리하였지만

여기서 정리하는 주요 개념에 대한 내용은

개발 언어에 관계없이 동일하게 사용할 수 있다.

시각적인 표현에 조금 차이가 있을 수 있지만

대부분의 개발언어에서 동일하게 구현할 수 있다.


인터넷을 검색해 보면

여러가지 방법으로 개발된 소스를 찾을 수 있다.

하나의 많이 참고되는 방식이 있고,

다소 비효율적이긴 하지만

재미난 방식으로 개발한 소스들도 있다.

비효율적으로 작성된 코드가 쉽게 작성되어 개념을 익히기 좋은 장점이 있고,

모든 코드가 비효율적이 것은 아니기 때문에,

남의 소스를 많이 보고 다양하게 개발해 보는 것이

중요하다고 생각하여 두가지 방식으로 정리하였다.


테트리스를 제작하는 방법에 대한 설명과 예제는

다음 사이트에 잘 정리되어 있다 (영어).



이 내용에서 첫 사이트의 예제와

나머지 사이트의 예제로 나눌수 있고,

이 게임들은 두가지 방식으로 개발되었다.

근본적인 차이는 테트리스 도형의 회전 방식의 차이이다.

테트리스 도형에 대한 용어가 사람에 따라 조금씩 차이가 있는데

Shape, Piece, Block, Tetromino등으로 사용한다.

여기서는 도형, Shape, Block을 같은 의미로 사용한다.


테트리스는 O, I, T, S, X, J, L의 7개 도형을

특정 버튼이나 키보드를 이용하여 오른쪽으로 90도씩 회전시키고

한 라인을 모두 채우면 사라지는 게임이다.

출처: Code in Complete


이 도형을 회전 시키는

첫번째 방법은 각 7 개의 도형에 대한 정보를 배열에 넣고

회전 명령에 따라

그때 그때 90 도씩 계산하여 회전시키는 것이다.


또다른 방법은

7 개 도형의 회전 후 모습을

모두 배열에 담아서 저장하고

상황에 맞추어 보여주는 방식이다.

다음 그림과 같이 90 도로 회전하는 4 개의 도형 정보를 저장한다.

이 방식은 제법 많은 데이터를 저장 하기 때문에

데이터를 16 진수로 변환해서 사용한다.

출처: Code in Complete

편의상,

앞서 7개의 도형을 배열에 저장하고

동적으로 회전을 계산하여 구현하는 것을 배열 개발법 (또는 배열법),

두번째의 모든 도형 모습을 저장하여 사용하는

개발 방법을 Bitmask개발법(또는 bitmask법)으로 호칭한다.

Bitmask로 개발하는 방법은 Code in Complete에 잘 설명되어있다.


이해를 쉽게 하기 위해

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

1. 배열 방식의 도형 회전

2. Bitmask 방식의 도형 회전

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

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

5. requestAnimationFrame




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

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

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

1. 개요

2. 배열 방식의 도형 회전

3. Bitmask 방식의 도형 회전

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

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

6. requestAnimationFrame


다소 개념이 쉬운 배열을 이용한 방식을 먼저 정리한다.

배열 방식은 이 사이트에서 제공하는 방식으로

해당 소스가 제법 복잡해서

테트리스 도형의 회전과 관련된 부분만

추출해서 다음과 같이 정리하였다.

배열을 사용한 것 외에

테트리스 도형들을 HTML DIV로 보여준다는 특징이있다.

보통 게임(개발 언어에 관계없이)은 Canvas에 그려서 제작하는데

DIV을 동적으로 생성하고 삭제하는 식으로 구현하였다.

추천하지 않는 방식이지만

시각적인 부분을 제외하면 쉽고 일반적인 개발 방법을 사용하였다.

이 부분은 이 개발자만의 특정이 아니라

많은 개발자가 사용하는 일반적인 방법이다.

(짧은 시간 찾다 보니 다른 소스를 못 찾았음).


먼저 테트리스 도형을 출력할 canvas [라인 2]와

회전 방향키[라인 3, 4],

도형의 종류를 순서 데로 보여줄 HTML 버튼을 생성한다 [라인 5].

자바 스크립트에서

shapes라는 3차원 배열 변수로 도형을 저장한다 [라인 13].

3차원은 도형 리스트,

2차원은 도형 하나에 대한 정보

1차원은 도형을 구성하는 셀 정보이다.

1차원에 있는 각 셀의 정보는

해당 위치에 도형을 구성할 정보가 있으면 1,

그냥 빈 공간으로 정보가 없으면 0을 가진다.

각 도형은 이어진 4 개의 셀로 모양이 구성되고

이 배열에서 1의 값 위치를 이어서 보면

주석으로 지정된 문자(O, I, T, S, X, J, L)와 일치하는 것을 알 수 있다 (표참조).

0 은 빈(Null)값을 의미한다.


추가적으로

3차원 배열은 7개의 2차원 배열을

2차원 배열은 4개의 1차원 배열을

1차원 배열은 4개의 원소를 가진다.

3차원 배열은 도형의 개수만큼 가지는 것이고,

1, 2차원은 도형의 모양을 x, y축으로 가지는 것이다.

각각 4개씩 4 * 4로 가지고 있다.

그리고, 각 도형은 4개의 셀(도형-사각형)로 구성되기 때문에

각 도형은 4개의 1을 가진다.


도형들(shapes) 중에서 화면에 출력할 도형을 curShape에 보관하고,

현재 도형의 종류를 curShapeType 변수에 저장한다 [라인 44].

도형의 종류는 배열 변수 shapes의 차원 위치이다.

shapes는 3차원 배열로 7개의 2차원을 가지고 있다.

이 7개의 순서(배열이라 0~6)이 도형의 종류(curShapeType )가 된다.


가장 기본적인 함수는 그리기(draw)함수 이다.

저장된 데이터를 상황에 맞추어 그리는 것이 주요 내용이기 때문이다.

draw()함수에서

canvas의 색을 흰색(white)으로 지정하고 [라인 49],

canvas의 크기(100*100)만큼 사각형(fillrect)을 그린다 [라인 50].

canvas 크기의 사각형을 그린다는 것은

canvas의 모든 내용을 지운다는 의미이다.

회전 버튼을 누를 때 마다,

매번 전체를 지우고 회전된 도형의 모양을 새로 그린다.

다음으로 색상을 검은색(black)으로 지정하고,

테트리스 도형(shape)을 그린다.

배열의 값에서 1 이 있으면 (if (curShape[y][x]) – 라인 54)

작은 도형(20*20)을 그린다 [라인 55].

자바 스크립트의 특성으로

변수의 값이 0 이거나 null, undefined 이면

IF문에서 false가 반환되어

if (curShape[y][x]===0) 과 같이 작성하지 않아도 된다.

따라서, curShape[y][x]의 값이 1 일때만

테트리스 도형을 그리기 위한 하나의 사각형을 그린다.


현재 출력하려는 도형의 정보를 가진 변수 curShape은

앞서 curShape=shapes[0]으로 [라인 44]

3차원 shapes에서 2차원을 배정 받았다.

즉, 하나의 도형 4 * 4 크기의

2 차원 배열에 대한 값을 가지고 있기 때문에

두 개의 for문을 이용하여 값을 확인한다 [라인 52, 53].


실행 화면에서 Next Shape 버튼을 클릭하면,

새로운 도형 종류를 curShapeType 변수에 저장하고

도형들(shapes) 중에서 curShapeType에 맞는 도형을 curShape에 저장한다 [라인 61].

도형 종류는 도형 정보를 가지고 있는 shapes의

색인 값(순서)을 의미하기 때문에

새로운 도형 종류는 curShapeType변수의 값에 1 더한 값으로 지정한다.

다만, 도형이 7 (배열이라 0~6)개이기 때문에

curShapeType의 값이 7 이되면 다시 0 이 되게 한다.

if문을 사용할 수 있겠지만 ( if (curShapeType===7) curShapeType=0)

나머지(%)를 구하는 방식으로 구현하였다 [라인 62].


좌우 버튼을 클릭하면

현재 도형을 회전시켜서 출력한다.

rotateLeft(), rotateRight() 함수에서 회전한 값을 반환 받아

curShape 변수에 저장하고 [라인 68, 73]

그리고, 이것을 화면에 출력한다 (draw).

코드로는 회전 방법에 대해서 이해하기 어려울 수 있다 [라인 77, 86].

먼저, 왼쪽으로 회전하는 코드를

다음 표와 같이 정리 할 수 있다.

[참고] 다음 표의 좌표는 (Y, X)로 지정되었다.

원본
왼쪽으로 회전

0123

0123
00, 00, 10, 20, 3
00, 31, 32, 33, 3
11, 01, 11, 21, 3
10, 21, 22, 23, 2
22, 02, 12, 22, 3
20, 11, 12, 13, 1
33, 03, 13, 23, 3
30, 01, 02, 03, 0

표에서 왼쪽에 있는 것이

각 도형에 대한 4 *4의 크기를 가진 2차원 배열 정보이다.

이 배열을 왼쪽으로 이동하는 것은

각 행을 열로(90도 회전) 변환하는 것을 의미한다 [오른쪽 표].

표에서 노란색으로 표시한 셀처럼,

왼쪽의 표에서 하나의 행이 그대로 90도 회전해서

오른쪽 표가 만들어 진 것을 볼 수 있다.

즉, 우측에 있는 표처럼

0, 0 의 위치에 기존의 0, 3 에 있는 데이터를

0, 1 의 위치에 기존의 0, 2 에 있는 데이터를

.....

배치하는 식으로 코드를 구성하여

새로운 배열을 생성한다 [라인 79, 88].

이상의 코드에서는

이 표의 내용을 그대로 코드로 작성하였다 [라인 79, 88].

오른쪽으로 회전하는 것은 다음 표와 같이 반대로 처리한다.

원본
오른쪽으로 회전

0123

0123
00, 00, 10, 20, 3
03, 02, 01, 00, 0
11, 01, 11, 21, 3
13, 12, 11, 10, 1
22, 02, 12, 22, 3
23, 22, 21, 20, 2
33, 03, 13, 23, 3
33, 32, 31, 30, 3

이상으로 테트리스 도형을 관리하고 회전하는 방법을 정리하였다.

테트리스에서 사용하는 7가지 도형을

    1. 3차원 배열에 저장하고

    2. 배열 값의 위치를 조작하여 회전시킨다.

마지막으로 이 배열의 값을(값이 1 이면) 이용하여

3. Canvas에 사각형을 그려서 테트리스 도형을 그려준다.

코드 양이 많아 보이지만

이 세가지의 간단한 구조로 되어 있으니 잘 기억하고

충분히 이해한 뒤에 다음 내용들을 확인하는 것이 좋다.


Bitmask 방식을 정리하기 전에

배열 방식의 회전에 대한 몇 가지 보강을 정리한다.

먼저, 이상의 코드에서 사용한 것과 같이 [라인 78, 87]

배열의 위치를 직접 지정해서 구현하는 것은 가변성이 떨어진다.

즉, 4 * 4로 고정된 경우에는 문제가 없지만

5 * 5나 3 * 3으로 바꾸어 처리할 경우 쉽게 수정할 수 없다.

이러한 직접적인 코드보다는 for문을 이용하는 것이 더 좋을 수 있다.


앞서서 정리했던 회전 관련 표와 동일한 다음 표에서

빨간색으로 표시된 값을 자세히 보면 공통점을 찾을 수 있다.

원본
왼쪽으로 회전

0123

0123
00, 00, 10, 20, 3
00, 31, 32, 33, 3
11, 01, 11, 21, 3
10, 21, 22, 23, 2
22, 02, 12, 22, 3
20, 11, 12, 13, 1
33, 03, 13, 23, 3
30, 01, 02, 03, 0

위 표에서 

빨간색과 파란색으로 표시된 값의 변화를 보면

회전 후의 Y값이 0 으로 고정일 때 원본의 Y값은 0~3 까지의 값

회전 후의 X값이 0~3 까지의 값일 때 원본의 X값은 3 으로 고정되어 있다.

이것을 X축과 Y축 각각에 대한 for 문을 이용하여

다음과 같이 작성할 수 있다.

(코드를 보기전 위 개념을 직접 구현해 보는 것이 좋다)

function rotateLeft(piece) {
    var ret = [];
    for (var y = 0; y < 4; y++) {
        ret[y] = piece[y].slice();
    }
   
    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            ret[y][x] = piece[x][3 - y];
        }
    }
    return ret;
}

예제 1-2

앞서의 문장을 다음과 같이 공식화 할 수 있다.

회전 후의 Y값이 0으로 고정일 때 원본의 Y값은 0~3까지의 값

회전 [0] = 원본 [0~3]

회전 후의 X값이 0~3까지의 값일 때 원본의 X값은 3으로 고정되어

회전 [0][0~3] = 원본 [0~3][3]

으로 정리할 수 있다.

이 코드는 가독성이 떨어지는데, 0~3을 x로 치환하면

회전 [0][x] = 원본 [x][3]

이 된다.

이 식을 Y값이 0 이상일 경우의 값을

표에서 확인하면 다음과 같다.

회전 [0][x] = 원본 [x][3]
회전 [1][x] = 원본 [x][2]
회전 [2][x] = 원본 [x][1]
회전 [3][x] = 원본 [x][0]

숫자 0부터 3까지의 값을 y라고 하면

회전 [y][x] = 원본 [x][3-y]

가 되고, 이것을 2개의 For문을 이용하여

이상의 코드와 같이 작성하였다.

실행시켜 보면,

기존의 코드와 동일하게 작동하는 것을 알 수 있다.


이상의 코드에서 회색으로 처리한 For문은

2차원 배열을 초기화 하는 자바스크립트 방법이다.

회전할 데이터를 저장할 ret 변수를 1차원으로 선언하고

For문을 이용하여 4개의 요인을 추가한다.

추가할 때 4개의 요인을 가진 새로운 1차원 배열을 지정하면

2차원 배열이 되는데,

여기에서는 Slice 함수를 이용하여

기존 배열에서 복사하는 방법을 사용하였다.

앞서 참고한 사이트에서 사용한 방식으로

2차원 배열을 초기화하여 사용할 때 많이 사용한다.

다음 코드가 좀더 일반적인 방식이다.

    for (var y = 0; y < 4; y++) {
        ret[y] = [];
        for (var x = 0; x < 4; x++) {
            ret[y][x] = 0;
        }
    }


오른쪽으로 회전하는 것도 왼쪽과 동일한 방법으로 구현한다.

다음 표를 자세히 보면,

회전 후의 Y값이 0 으로 고정일 때 원본의 Y값은 3~0 까지의 값

회전 후의 X값이 0~3 까지의 값일 때 원본의 X값은 0 으로 고정되어 있다.

왼쪽과 동일한 방법으로 구현해 보길 바란다.

원본
오른쪽으로 회전

0123

0123
00, 00, 10, 20, 3
03, 02, 01, 00, 0
11, 01, 11, 21, 3
13, 12, 11, 10, 1
22, 02, 12, 22, 3
23, 22, 21, 20, 2
33, 03, 13, 23, 3
33, 32, 31, 30, 3


function rotateRight(piece) {
    var ret = [];
    for (var y = 0; y < 4; y++) {
        ret[y] = piece[y].slice();
    }
   
    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            ret[y][x] = piece[3 - x][y];
        }
    }
    return ret;
}

예제 1-2


기존 소스에 다음과 같이 코드를 추가한 뒤 실행해 본다.

function draw() {
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, 100, 100);
    ctx.fillStyle = 'black';
    for (var x = 0; x <= 100; x+=20) {
        ctx.beginPath();
        ctx.moveTo(x,0);
        ctx.lineTo(x,100);
        ctx.stroke();
    }
    for (var y = 0; y <= 100; y+=20) {
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(100, y);
        ctx.stroke();
    }

    ~~ 생략 ~~

이 코드는 테트리스 도형을 그리는

Canvas에 배경선을 그리는 코드로

다음과 같은 화면을 볼 수 있다.

처음에 있는 I 자는 문제가 없어 보이지만

두 번째에 있는 T 자의 경우

세 번째와 네 번째의 도형이

한쪽으로 치우친 것을 볼 수 있다.

첫 번째와 두 번째의 T 자는 3 *3의 범위에 있지만

세 번째와 네 번째는 4 * 4의 범위에 있다.

I 자는 전체 도형이 네 칸을 사용하기 때문에 문제가 없지만

T 자는 실제 크기는 세 칸인데,

정보를 저장한 배열의 크기는 4칸(4*4)이라

회전할 때 위치가 틀어지는 문제가 발생한다.

문제 해결과 관련된 토론은

인터넷에서 제법 많은 토론이 이루어 지니 찾아보길 바란다.

여기에서는 2가지 방법으로 해결해 본다.


첫 번째 방법은 bitmask에서 언급했던

모든 회전 모양을 저장하는 방법이다.

두 번째 방법은 앞서의 방식에서

각 도형의 크기만큼만 회전에 참여시키는 것이다.

새로운 방법인 bitmask는 뒤에서 따로 정리하고

먼저, 각 도형의 크기만큼만 회전시키는 방법을 정리한다.


O 자는 2 * 2, I 자는 4 * 4의 크기를 가지고

나머지 도형은 모두 3 * 3의 크기를 가진다.

따라서, 이 정보를 저장하도록 하고

회전할 때 이 크기만큼만 회전하게 수정하면 된다.

var shapes = [
   [[1, 1, 0, 0],        // 'O'
    [1, 1, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]],
   [[0, 0, 1, 0],        // 'I'
    [0, 0, 1, 0],
    [0, 0, 1, 0],
    [0, 0, 1, 0]],
   [[1, 1, 1, 0],        // 'T'
    [0, 1, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]],
   [[0, 1, 1, 0],        // 'S'
    [1, 1, 0, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]],
   [[1, 1, 0, 0],        // 'Z'
    [0, 1, 1, 0],
    [0, 0, 0, 0],
    [0, 0, 0, 0]],
   [[0, 1, 0, 0],        // 'J'
    [0, 1, 0, 0],
    [1, 1, 0, 0],
    [0, 0, 0, 0]],
   [[1, 0, 0, 0],        // 'L'
    [1, 0, 0, 0],
    [1, 1, 0, 0],
    [0, 0, 0, 0]]
];

var shapeSize = [2,4,3,3,3,3,3];

function rotateLeft(piece) {
    ~~ 생략 ~~
   
    var size = shapeSize[curShapeType];
    for (var y = 0; y < size; y++) {
        for (var x = 0; x < size; x++) {
            ret[y][x] = piece[x][(size-1) - y];
        }
    }
    return ret;
}

function rotateRight(piece) {
    ~~ 생략 ~~
   
    var size = shapeSize[curShapeType];
    for (var y = 0; y < size; y++) {
        for (var x = 0; x < size; x++) {
            ret[y][x] = piece[(size-1) - x][y];
        }
    }
    return ret;
}

예제 1-3

먼저, 도형 I를 제외하고

shapes에 저장된 도형의 위치를 모두 수정한다.

도형 정보가 배열 중앙에 있는데,

다음 표 처럼 0, 0 에서 시작하도록 이동 시켜준다.


도형의 크기를 shapeSize에 배열로 저장하고,

현재 도형의 종류(curShapeType)에 따라

적절한 크기를 사용한다.

도형의 크기 만큼만 회전에 참여 시킨다의 의미는

For문의 최종값을 도형의 크기로 지정한다는 것이다.


실행후, 다음 그림과 같이 출력되는 것을 확인할 수 있다.

위의 T 자가 수정전, 아래 T자가 수정 후 그림으로

제대로 회전되는 것을 볼 수 있다.

마지막으로 다음 그림을 보면

3 * 3 의 도형들은 빨간색으로 표시한

중심이 존재하게 된다.

S 자처럼 위를 중심으로 잡아도 되고

Z 자처럼 아래를 중심으로 잡아도 된다.

가급적 둘다 같은 방식으로 중심으로 잡아주는 것이 좋다.

다만, L 자는

왼쪽으로 치우쳐있다.

공백 부분을 중심으로 회전하는 데,

오른쪽으로 한칸 이동시켜 J자와 일치 시켜주는 것이 좋을 수 있다.


코드 양이 많아서 복잡해 보이지만

실제로는 간단하게 작성된

테트리스 도형 회전에 대한 배열법을 정리하였다.

다음으로 조금 어려운 개념을 사용한

Bitmask 방식에 대하여 정리하였다.


문제

정리하지 않았지만,

도형의 회전시 새로운 배열(ret)을 생성하여 사용하였다.

자바 스크립트 배열은

기본적으로 참조(Refence)의 개념을 가지고 있기 때문이다.

대부분의 게임에서 배열이 매우 중요한 개념이니 찾아 보길 바란다.



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

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

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

1. 개요

2. 배열 방식의 도형 회전

3. Bitmask 방식의 도형 회전

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

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

6. requestAnimationFrame


이번에는 Bitmask를 이용한 테트리스 도형 회전에 대하여 정리한다.

Bitmask를 이용한 테트리스 도형 회전은

7개의 테트리스 도형에 대한 모든 회전(4가지) 정보를 가지고 있는 것으로

데이터 양을 줄이고 관리를 편리하게 하기 위해

도형 정보를 bit로 변환해서 저장한다.


그림과 같이 하나의 행이 하나의 워드(word - 4bit)가 되고,

색이 있는 부분을 1 로 표기하고

색이 없는 부분은 0 으로 지정하면,

2 진수로 처리되어 오른쪽 표와 같이 된다.

즉, L 자 도형은

2 진수로 0100 0100 0110 0000 이 되고

16 진수로 4460 이 된다 (진수 변환은 본문 마지막의 해설 참조).


이렇게 모든 도형과 회전후 도형 모습을

다음과 같이 2 진수로 처리하여 16진수 값으로 저장한다.

출처: Code in Complete

데이터를 저장하는 방법을 이해하면

다음 코드와 같이 간단하게 구현할 수 있다.

위 그림의 계산 값과 다음 코드의 계산값(shapes)에 차이가 있다.

둘다 방식은 같은데 도형의 배열내 위치가 조금씩 다르기 때문이다.

이미지와 기타 코드는 Code in Complete에서 가져왔고

다음 샘플은 stackoverflow에서 가져와서 수정했기 때문에 차이가 있다.


이상의 코드에서 7 개의 도형(행)과 4 개의 각도(열)를

2 차원 배열로 구성해서 shapes 변수에 저장한다.

배열법보다 상당히 줄어든 코드를 볼 수 있다 [라인 13].

배열법에서는 현재 도형의 모양을 별도의 변수에서 저장했지만

Bitmask에서는 도형의 모양 (curShapeType – 행 위치)과

도형의 현재 각도(curRotation)에 대한 정보 [라인 23]만 있으면 된다.

curRotation 변수에 현재 각도에 대한

배열의 위치 (열 위치)를 다음과 같이 저장한다.

0 이면 기본 각도 (0 or 360)

1 이면 90 도

2 이면 180 도

3 이면 270 도를 의미하는 숫자가 된다.

즉 왼쪽으로 회전하는 것은

앞으로 가야 하기 때문에 curRotation 값에 -1을 계산하고 [라인 46]

오른쪽으로 회전하는 것은

뒤로 가야 하기 때문에 curRotation 값에 +1 계산한다 [라인 52].

다만, 왼쪽으로 회전해서 0 보다 작으면

다시 3 부터 시작하게 되고 [라인 47],

오른쪽으로 회전해서 3 보다 크면 0 부터 시작하도록 처리한다 [라인 52].


마지막으로 가장 중요한 도형을 그리는 방법을 정리한다 [라인 33].

이 부분이 가장 어렵고 중요한데,

데이터 저장을 2 진수로 저장하는 일종의 암호화를 하고,

이것을 다시 쉽게 풀어 쓰는 복호화를 하기 때문이다.

비트 연산을 잘하는 사람이 아닌 이상

33 라인을 이해하기 어렵다.

어렵다기 보다는 개발 중에 비트 연산을 보는 경우가 드물어

익숙하지 않기 때문에 어렵게 느껴질 수 있다.

기본 개념은 앞서 2 진수로 계산한 값을

기본 값과 이진 연산을 통해서 값이 있으면 (1 이면)

테트리스 도형을 그리기 위한 사각형을 그린다 [라인 34].

예로, L 자 도형은 16 진수 0x4460 으로 저장되어 있다.

이 값을 다시 2 진수 0100 0100 0110 0000 로 변환해서

하나의 문자(bit)가 1 이면 사각형을 출력하도록 하면 된다.

다만, 16 진수 값을 2 진수의 문자열로 바꾸는 것이 까다롭고 복잡해서

비트 연산(bitmask)을 통해서 간단하게 구현한다.


먼저 두 개의 for문을 사용하면

0 부터 15 (4 * 4 = 16)까지의 값을 구할 수 있다 [라인 31, 32].

즉, 16 번의 비트 이동(>>)을 하게 된다.


16 진수 0x8000은

2 진수로 1000 0000 0000 0000 이다.

이것을 16 번 비트 이동(>>)하면

0x8000 >> 0  : 1000 0000 0000 0000
0x8000 >> 1  : 0100 0000 0000 0000
0x8000 >> 2  : 0010 0000 0000 0000
0x8000 >> 3  : 0001 0000 0000 0000
….
0x8000 >> 15 : 0000 0000 0000 0001 이 된다.

이 각각의 값을

L 자 도형의 2진수인

0100 0100 0110 0000 과 & (and) 비트 연산을 하면

두 개의 값이 1일때만 1 이 반환되기 때문에

이때만 사각형을 그리도록 한다 [라인 33].


예로 처음에는 다음과 같이 0 이 반환된다.

0x8000 >> 0  : 1000 0000 0000 0000
0x4460       : 0100 0100 0110 0000
-------------------------------
& 결과         0000 0000 0000 0000  => 0x0000

두 번째에는 1 이 반환된다.

0x8000 >> 1  : 0100 0000 0000 0000
0x4460       : 0100 0100 0110 0000
-------------------------------
& 결과         0100 0000 0000 0000  => 0x4000

이렇게 16번을 비교하면

테트리스 도형을 그리기 위한 4개의 사각형을 얻을 수 있다.


16 진수로 비트 연산(>>, &)을 하면

자바 스크립트 내부에서

위와 같은 이진 연산을 알아서 처리해준다.


지금까지 테트리스의 도형을

회전시키는 2가지 방법에 대해서 정리하였다.

배열법은 코드 양이 많은 것 같지만 단순한 원리가 적용되었고,

Bitmask 방법은 코드 양이 적지만

익숙하지 않은 bit에 대한 개념과 사용법을 알아야 한다.

둘 다 장단점이 있고,

인터넷으로 검색해 보면 Bitmask로 개발한 예제가 더 많고

더 나은 방법이라는 글을 많이 볼 수 있다.


개인적으로도 Bitmask가 더 좋은 방식이라고 생각하지만

개발을 시작하는 사람들에게는

배열법도 게임에 대한 이해를 돕기에 좋은 방식이라고 생각한다.

더우기 배열법 예제에서 나타난 문제를 해결하면서

실력향상을 도모할 수 있다.

그리고, 실제 게임을 제작하면

Bitmask는 복잡한 방식으로 구현되기 때문에

초보자가 이해하기 어렵다 (Code in Complete참조).

배열법이 코드양이 많아 보여서 복잡하게 느껴질 수 있지만

단순한 구조라 실제 계발에서도 직관적으로 구조를 파악할 수 있다.

(여기서 참고한 예제는 DIV를 사용해서 조금 더 어려울 수 있다.)


이렇게 테트리스의 도형을 회전시키는 방법을 통해

다양한 방식으로 개발할 수 있다는 것을 맛보고,

이것을 좀더 심화해서 간단하지만 즐길 수 있는

실제 게임을 제작하면서 어떤 차이가 있는지 정리한다.


2진수를 16진수로 변환하는 방법

출처: Code in Complete

이 그림에서 보듯이 가장 오른쪽 부터

2 ^ 0 으로 1

2 ^ 1 으로 2

2 ^ 2 으로 4

2 ^ 3 으로 8 의 값이 된다.

해당 자리에 값이 지정되면 (2진수 이므로 1 이면)

1, 2, 4, 8의 숫자가 해당 위치의 값이 되어 사용된다.


예로,

2진수 1000 은 오른쪽에서 4번째에 값이 있기 때문에 2 ^ 3 으로 16 진수 8 이 된다.

2진수 1100 은 2 ^ 3 과 2 ^ 2 에 값이 있으니 8 + 4 로 16진수 C (십진수 12) 가 된다.



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

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

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

1. 개요

2. 배열 방식의 도형 회전

3. Bitmask 방식의 도형 회전

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

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

6. requestAnimationFrame


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

배열을 이용한 회전 방법을 기초로

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

테트리스 구현을 위해 가장 중요한 개념은 회전으로,

이 개념에 몇 가지 게임 관련 개념을 더 하여

실제 사용 가능한 테트리스를 구현한다.


이해를 쉽게 하기 위해

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


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

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

3. 이동 중에 도형 회전

4. 채워진 행 제거

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



개발의 첫 단계로

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

단순한 도형 이동이지만

구현을 위해 알아야 할 게임의 기본 개념이 있다.


게임도 동영상처럼

시간의 흐름에 따라 그림을 그려서 움직이게 보인다.

동영상에서 초당 24 프레임으로 제작되었다는 등의 말을 하듯이

게임에서도 초당 몇 번 화면을 그릴 것인지를(프레임) 결정해야 한다.

그린다(draw)는 말은

앞서 회전에서 도형을 그리는 것처럼

Canvas에 도형(rectangle)을 그리는 것을 의미한다.


테트리스 도형이 위에서 아래로 내려오는 것은

시간의 흐름(초당 몇 번)에 따라

도형을 이동 시키고 새로 그려준다는 말이 된다.

도형을 이동 시킨다는 말은 현재 그림을 그린 좌표(x,y) 값을

시간의 흐름에 따라 증가시킨다는 의미이다.

시간의 흐름을 따른다는 것은

쓰레드(Thread)나 타이머(Timer)로

지정된 시간마다 특정 함수나 작업을 호출한다는 의미이다.


정리하면,

지정된 시간마다 도형의 좌표를 증가시켜서

지정된 위치에 도형을 그리면 이동하는 것처럼 보이게 된다.


단순한 개념이지만

처음 개발하는 사람에게는 쉽지 않은 개념일 수 있다.

그래도, 배열법에서 정리한 마지막 코드

이 개념에 대한 구현을 시도해 보길 바란다.


이 코드가 제법 복잡해 보이지만

배열법에서 정리한 마지막 코드

다음의 코드를 추가하거나 수정한 것이다.

<canvas id="tetrisCanvas" width="200" height="400"></canvas>

<script>
~~ 생략 ~~
var sPos = {x:0, y:0};

var intervalHandler = setInterval(
    function () {
          draw();
    }, 400
);   

function draw() {
    ctx.fillStyle = 'white';
    ctx.fillRect(0, 0, 200, 400);
    ctx.rect(0, 0, 200, 400);
    ctx.strokeStyle="blue";
    ctx.stroke();
   
    ctx.fillStyle = 'black';

    for (var y = 0; y < 4; y++) {
        for (var x = 0; x < 4; x++) {
            if (curShape[y][x]) {
                ctx.fillRect((sPos.x+x) * 20, (sPos.y+y) * 20, 19, 19);
            }
        }
    }
    if (sPos.y++ === 18){
        clearInterval(intervalHandler);
    }
}
</script>

테트리스 도형을 그릴 Canvas의 크기를

100 * 100 에서 200 * 400 으로 수정하였다.

위에서 아래로 내려올(도형이 움직일) 공간을 주기 위해

높이(height)를 400 으로 지정하였다 [라인 5].


이 크기 변경에 따라 Canvas를 새로 그리기 위해 지우는

도형의 크기도 200 * 400 으로 수정하였다 [라인 50].

추가적으로, 도형이 움직이는 공간을 조금 명확하게 하기 위해

(게임판을 구별하기 위해)

Canvas 외곽선을 파란(blue)색으로 표시하였다 [라인 51~53].


자바 스크립트에는 시간이 되면 지정된 함수를 호출하는 타이머로

setInterval() 함수가 제공된다.

setInterval() 함수는 42 라인에서 작성한 것처럼

400 ms (0.4초) 마다 콜백(callback) 함수를 호출하고

콜백 함수에서는 테트리스 도형을 그리는 draw()함수를 호출한다.

즉, 1 초에 2.5번 도형을 그린다.


도형의 위치(x, y)를 지정하기 위해

X와 Y 좌표를 Json으로 가지는 sPos 변수를 사용했다 [라인 40].

도형이 위에서 아래로 이동할 것이기 때문에 Y 좌표만 있어도 되지만

뒤이어 보강할 것이라 미리 선언해 두었다.


이 sPos.y 변수는 두 군데에서 사용된다.

시간이 흐를 때 마다 (draw()함수가 호출될 때 마다)

sPos.y 변수의 값을 1 씩 증가시킨다 [라인 64].

1 씩 증가하다 18 이 되면 타이머를 중지 시킨다 (clearInterval).

18 이 되면 중지하는 것은 도형이 무한이 떨어지는 것을 막기 위한 것으로

다음 예제에서 삭제할 것이니 그냥 넘어간다.


Canvas의 크기는 200 * 400 이다.

테트리스 도형을 구성하는 사각형의 크기는 20 * 20 의 크기를 가진다.

즉, sPos.y 변수의 값 1 은

Canvas에서 사각형 하나의 크기인 20 을 가지게 되고,

Canvas의 높이가 400 이니

sPos.y 변수의 값은 최대 20 (=400 / 20)을 가지게 된다.

즉, 테트리스 게임판은 높이가 20 칸(너비는 10칸)으로

구성된 바둑판 (10 * 20) 같은 형태가 된다.

그리고, 테트리스 도형은 이 바둑판 위를 한칸씩 아래로 이동(sPos.y++)하게 된다.


다음의 코드를 draw() 함수에 추가하여 실행한다.

    for (var x = 0; x <= 200; x+=20) {
        ctx.beginPath();
        ctx.moveTo(x,0);
        ctx.lineTo(x,400);
        ctx.stroke();
    }
    for (var y = 0; y <= 400; y+=20) {
        ctx.beginPath();
        ctx.moveTo(0, y);
        ctx.lineTo(200, y);
        ctx.stroke();
    }

Canvas에 20 * 20 크기의 격자를 그리는 코드로

10 * 20 개의 격자가 생긴 것을 알 수 있다.


이상의 코드는 타이머 사용방법과 [라인 42, 65]

위치 변수(sPos)의 값 1 이

Canvas에서 20의 크기를 가진다는 것이 핵심 개념으로 [라인 60],

앞서의 개념에 더하여 정리하면,

지정된 시간마다(setInterval = 400) 도형의 좌표(sPos.y)를 증가시켜서(+1)

지정된 위치(sPos.y)에 도형을 구성하는 사각형을 그리면(fillRect)

이동하는 것처럼 보이게 된다.


두번째로,

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

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


이 기능을 구현하기 위해서 필요한 개념은

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

다음 행에 먼저 떨어진 다른 도형이 있는지 확인하는 기능이다.

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

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

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

다음 행(정확하게는 칸)에 도형이 있는지 확인하려면

앞서 내려간 도형들에 대한 정보를 알아야 한다.

즉, 게임판 (10 * 20) 전체에 대한 정보를 가지고 있어야 한다.

게임판의 크기는 10 * 20 의 2 차원 배열로 Canvas에 그려진 격자의 갯수이다.


그림과 같이 첫 번째 도형이 내려와 바닥에 닿으면,

이 위치 정보가 어딘가(변수)에 저장되어 있어야 한다.

그리고, 하나의 도형이 이동할 때 마다 게임판을 새로 그리기 때문에

저장한 정보를 이용하여 쌓여진 도형들도 같이 그려줘야 한다.


도형의 정보를 저장하는 shapes 변수의 내용은 생략하였고 [라인 7],

앞서의 코드보다 양이 제법 많아 보이지만

핵심 코드는

게임판의 정보를 저장하는 gamePanel 배열 변수이다 [라인 12].

대부분의 코드가 이 변수를 처리하기 위해 추가 / 변경되었다.

gamePanel 배열 변수는 다음 그림과 같이

왼쪽의 게임판을

우측과 같은 2차원 배열 변수에

도형(실제로는 도형을 구성하는 사각형)이 있으면 1을 [라인 31]

없으면 0 으로 채워서 구성한다.


12 라인부터 18 라인은

gamePanel를 2차원 배열로 선언하고 초기화하는 코드이다.

앞서 2차원 배열을 slice() 함수를 이용하여 초기화 했는데

이와 같이 다소 불편하게 생성 및 초기화하는 것이

자바스크립트의 기본 특징이다.


다음으로 신경 써야 하는 것은 intersects(y, x) 함수로

다음 행(칸)에 값이 있는지 없는지를 확인한다.

파라미터로 다음 행(sPos.y+1)에 대한 정보를 받아서 확인한다.

아직 좌우 이동은 하지 않기 때문에

x좌표는 고정할 수도 있지만 일단 변수로 지정한다.

(개념상 같이 처리되어 자연스럽게 구현된다.)


intersects() 함수에서 for문이 두 번 사용되어

하나의 도형을 구성하는 세부 도형(rectangle)이 있는지 확인하고 [라인 49],

있으면 게임판 변수(gamePanel)에서

먼저 떨어진 다른 도형의 세부 도형의 정보가 있는지 확인한다 [라인 50].

다른 정보가 있으면 이동하지 말라는 의미로 true를 반환한다.

intersects() 함수는 앞서의 예제에서 사용했던,

이동 위치가 18 이면 중지하도록 작성한 코드를 제대로 구현한 코드이다 (sPos.y++ === 18).


이 문장에 3개의 조건이 더 있는 것은 [라인 50]

이동하려는 위치가 바닥을 벗어나거나 (y+i >= 20)

좌우 이동시 왼쪽으로 벗어나거나(x+j < 0)

오른쪽으로 벗어나도(x+j >= 10)

이동하지 말라는 의미로 true를 반환한다.


이전과 다르게 타이머에서 draw() 함수가 아닌

playingTetris() 함수를 호출한다 [라인 51].

playingTetris() 함수는 게임을 컨트롤 하는 함수로

적절한 작업을 처리하고,

Canvas에 도형들을 그리는 draw() 함수를 호출한다 [라인 55].


playingTetris() 함수에서는

다음 위치(sPos.y + 1)에 다른 도형이 있는지

intersects() 함수를 호출하여 확인하여 없으면 [라인 27]

현재 위치를 다음 위치로 바꾼 뒤(sPos.y++) 게임판을 다시 그린다 [라인 41, 43].


반대로 다음 위치에 도형이 있으면 [라인 28],

게임판(gamePanel) 변수에

현재 도형을 채운다는 의미로 현재 도형의 정보(1)를 채운다 [라인 31].

현재 도형(curShape)의 정보를 찾기 위해

2 개의 for문을 이용하여

4 * 4 의 2 차원 배열 정보를 모두 찾아 본 후,

배열(curShape) 값이 1 이면 [라인 30]

도형의 위치 정보(sPos)와 for 문의 인자로

gamePanel 변수에서 채워야 할 위치를 ( [sPos.y+i][sPos.x+j] ) 계산하여

도형이 있다는 의미로 1 을 지정한다 [라인 31].

하나의 도형은 4개의 1 (=사각형)로 구성되기 때문이다.

(이해가 되지 않을 경우 회전 참조)


getNextShape() 함수로 다음 도형을 구하고 [라인 34],

위치 정보(sPos)를 초기화(0, 0) 한다 [라인 35].

그리고 다시 새로운 도형과 좌표에 대하여

intersects() 함수를 호출하여 게임 종료를 확인한다 [라인 36].

다음 위치(sPos)가 0, 0 인데 true가 반환되면

게임판이 모두 찼다는 의미로 게임을 종료하게 된다 [라인 37].


draw() 함수에서는 게임판(gamePanel)의 정보가 채워져 있으면 [라인 55]

해당 위치(x * 20, y * 20)에

사각형을 그려서 도형이 있다는 것을 나타낸다[라인 66].

이것은 현재 도형을 그리는 것과 변수만 다를 뿐 같은 방식이다 [라인 71~78].


회전에서 사용했던

getNextShape() 함수를 이용하여 다음 도형을 지정한다 [라인 80].


마지막으로 위치 정보를 저장하는 변수의 초기값을 지정할 때,

x 값을 0 으로 지정하였다 [라인 11, 35].

이 값 때문에 그림과 같이 도형들이 왼쪽에 붙어서 떨어진다.

x 값을 3 (= (10 - 4) / 2) 으로 수정하여

다음 그림과 같이 현재 도형이 게임판 중앙에 오도록 수정한다.


정리하면, 게임판(canvas)에 대한 정보를 

10 * 20의 2 차원 변수 gamePanel에 저장하고,

현재 도형(curShape)을 이동할 때 마다 (sPos.y++)

gamePanel에서 해당 위치에 먼저 도착한 도형이 있는지 확인한다.

도형이 있으면, 현재 도형의 정보를 gamePanel에 넣어 주고

새로운 도형을 무작위로 지정한 뒤 위치 정보를 초기화 한다.

첫 위치(0, 0)에서 이동할 수 없으면

해당 열에 도형이 꽉 찬 것으로 게임을 종료한다.


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

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

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

먼저, 도형을 왼쪽 / 오른쪽 (X)으로 이동시키는 것은 간단하다.

좌우 이동은 도형을 아래로(Y)로 이동시키는 코드에서

이미 같이 구현하였기 때문에,

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

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

게임판을 새로 그려주면 된다.


그냥 떨어지는 기능은

타이머를 사용하지 않고,

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

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


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

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

<canvas id="tetrisCanvas" width="200" height="400"></canvas>
<button onclick="moveShape(-1)">Left</button>
<button onclick="rotateShape()">Rotate</button>
<button onclick="moveShape(1)">Right</button>
<button onclick="dropShape()">Drop</button>

<script>
~~ 생략 ~~
function getNextShape() {
    curShapeType = Math.floor((Math.random() * 7));
    return shapes[curShapeType]
}
~~ 생략 ~~
function moveShape(value) {
    if ( !intersects(sPos.y, sPos.x+value)) {
        sPos.x += value;
        draw();
    }
}

function dropShape() {
    for (var y=sPos.y; y<20; y++) {
        if ( !intersects(sPos.y+1, sPos.x)) {
            sPos.y++;
            draw();
        }
    }
}

function rotateShape() {
    var ret = [];
    for (var y = 0; y < 4; y++) {
        ret[y] = curShape[y].slice();
    }
   
    var size = shapeSize[curShapeType];
    for (var y = 0; y < size; y++) {
        for (var x = 0; x < size; x++) {
            ret[y][x] = curShape[(size-1) - x][y];
        }
    }
    curShape = ret;
    draw();
}

</script>

전체코드

먼저 그림과 같이 도형을 조작하는데 사용할

HTML 버튼(button) 태그를 기능에 맞추어 4 개 생성한다.


왼쪽(Left)과 오른쪽은(Right) 버튼은

-1 또는 +1 을 파라미터로 moveShape() 함수를 호출한다.

moveShape() 함수에서는 파라미터(value)로 받은 값을

도형의 위치에 더해준 뒤 (sPos.x += value),

게임판을 새로 그린다 (draw).


떨어지는(Drop) 버튼은 dropShape() 함수를 호출한다.

dropShape() 함수에서는

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

1씩 증가시키면서 게임판을 계속 새로 그린다 (draw).


moveShape()와 dropShape() 함수에서 공통으로

이동 전에 이동이 가능한지 확인하는

intersects() 함수를 사용하였다.

도형을 이동시킬때 마다 이동이 가능한지 확인해야 한다.

예로, 도형이 이미 왼쪽 끝(0)에 있는데,

계속 왼쪽 이동 버튼을 누르면 아무 것도 하지 않도록 한다.


회전(rotate) 버튼은 rotateShape() 함수를 호출하고,

rotateShape() 함수에서는 앞서 작성한 회전(오른쪽) 코드를 그대로 사용한다.

다만, 회전한 결과(ret)를 다시 현재 도형 변수 curShape에 넣어주고

게임판을 새로 그린다 (draw).


구현 기능이 많고 어려운 것 같지만

앞서 정리한 개념과 코드가 거의 비슷하게 작성되어

쉽게 구현할 수 있다.


이상의 코드에 버그는 아니지만 조금 부족한 코드가 있다.

해답을 설명하지 않으니 찾아서 수정해 보길 바라고,

다음 예제에 수정되어 있다. (힌트 dropShape)


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

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

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


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

즉, gamePanel의 모든 행을 검사해서

하나의 행에 대한 모든 열의 값이 1 이면 해당 행을 제거한다.

해당 행을 제거하는 방법,

즉 2차원 배열에서 1차원을 제거하는 방법이 핵심이고

구현 방법도 다양하다.

다음 코드는 배열을 이용하여 구현한 예제에서 발췌한 코드이다.

var ROW_CNT = 20;
var COL_CNT = 10;

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;
}

전체코드

먼저, 행의 개수와 열의 개수를

숫자에서 상수로 바꾸어 작성하였다.

가급적 고정된 값은 상수로 처리하는 것이 일반적이고,

여기에서는 20 이란 숫자가 행의 개수와

도형을 구성하는 사각형의 크기를 의미하여,

두 가지로 사용되기 때문에 상수로 작성해야 한다.

지금까지는 개념을 먼저 알아야 해서 무시하고 작성했다.


현재 도형을 이동시킬 때,

다음 위치에 다른 도형이 있을 경우(intersects===true)

게임판에 현재 도형 정보를 넘긴 뒤에

게임판의 모든 행(Y축)을 검사한다 (removeRow).

즉, 여기서는 생략했지만 playingTetris() 함수에서

removeRow() 함수를 호출해서 사용하니 전체 코드에서 확인하길 바란다.


게임판(gamePanel)의 모든 행을 검사하기 위해,

행의 개수만큼 반복하면서 (for)

각 행의 열의 개수만큼 반복해서 값이 있는지 확인한다 (if).

여기에 조금의 스킬과 개념이 필요하다.


배열을 이용하여 구현한 예제에서 사용한 코드를 참고한 것으로

새로운 게임판 배열 변수(newRows)를 생성하고,

기존의 게임판 배열 변수의 각 행을 하나씩 복제(slice)해서 넣는다.

즉, 각 행의 열을 검사하다

열에서 값이 없는 열이 없으면 (if !) 해당 열을 복제한다.

값이 있을 때는 복제하지 않기 때문에

해당 행이 빠져서 삭제하는 것처럼 구현된다.


이때 새로운 게임판 배열 변수(newRows)의 행 변화를 나타내는 변수 k와

기존의 게임판 배열 변수(gamePanel)의 행 변화를 나타내는 변수 y의

변화를 잘 알아야 한다.

기존의 게임판 배열 변수(gamePanel)의 모든 행을 검사하기 때문에

y는 0 부터 개수(20)만큼 반복한다.

새로운 배열 변수 newRows는 각 행의 열 값 중

빈 값이 있을 때만 변화(감소)시키기 때문에

gamePanel의 크기(y)보다 작다.

즉 두 값의 (y-k) 차이가 삭제된 행의 개수가 된다.


다만, 검사 순서를 앞 (0)이 아닌 뒤 (19)에서 시작한다.

행이 삭제될 때,

뒤의 행이 앞으로 당겨지는 것이 아니고

앞의 행이 뒤로 당겨지는 것이기 때문이다.

다음 그림과 같이 19 행이 다 찬 상태에서

18 행이 19 행을 차지하게 된다.

이런 상태에서는 뒤에서부터 처리하는 것이 편리하지만

개인의 공부를 위해 어려운 길(?)로 가보는 것도 좋다.

앞에서부터 이동하는 것을 구현해 보길 바란다.


따라서 이렇게 복제를 한 뒤에

삭제된 행의 개수만큼 newRows의 앞부분에 추가하여

전체 개수를 맞추어 새로운 게임판 변수를 만든다.


여기서 마지막으로 알아야 할 개념은 if문의 사용이다.

하나의 행에 대하여 모든 열을 검사하는 것이 아니고,

중간에 하나라도 빈 값이 있으면 더 이상 검사하지 않고 중지한다 (break).

10 개의 모든 열을 검사하는 것보다

앞의 몇 개로 검사를 끝나는 경우가 많기 때문에

사소하지만 최적화를 위한 첫 걸음과 같은 코드 작성법이다.


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

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

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


키보드 제어는 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 < curShapeSize; i++)
            for (var j = 0; j < curShapeSize; j++)
                if (curShape[i][j]) {
                    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() {
    ~~ 생략 ~~
                           
    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 < curShapeSize; y++) {
        for (var x = 0; x < curShapeSize; x++) {
            if (curShape[y][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>

전체코드

기능을 구현하기 위해 기존의 코드에 추가된 코드의 양이

위 코드에 표시한 것과 같이 얼마 되지 않는다.


먼저, 키보드 제어부터 정리하면

addEventListener를 이용하여

웹 페이지 문서(document)에서 발생하는 모든 키보드 입력을 가져오도록 한다.

Keydown 이벤트에 keydown 함수(이벤트 핸들러)를 지정하였다.


이때, 눌러진 키보드 키 값(keyCode)으로

어떤 키가 눌러졌는지 확인해서

버튼에서 호출한 것과 같이 적절한 함수를 호출한다.

지정된 키가 눌러진 경우(handled = true),

preventDefault()를 호출해서 이벤트 진행을 중지 시킨다.

이미 게임에서 처리했기 때문에

해당 키를 눌렀을 때 발생할 일을 하지 않도록 하는 것이다.

예로, 웹 페이지가 디자인 된 상태에서 테트리스가 운영될 경우

상하 스크롤이 생길 수 있다.

이 경우 down 버튼을 누르면 도형이 그냥 떨어져야 하는데

페이지가 아래로 스크롤 될 수 있다.

이것을 방지하기 위해 preventDefault()를 호출한다.


제어용으로 사용할 키들은 값으로 사용하지 않고,

Json 변수 KEY를 상수처럼 사용하였다.

상수를 여러 개 선언하여 사용하는 것보다 JSon으로 사용하는 것이

쉽게 사용할 수 있고, 객체지향적인 개발 느낌도 나서 좋다.


다음으로 위 코드에서 빨간색으로 표시한 코드가

도형별로 다른 색상을 출력한다.


도형의 종류는 모든 도형 정보를 가지는 shapes 에서

보관하는 도형의 순서(O, I, T, S, X, J, L)를 의미하는 것으로,

현재 도형의 종류를 curShapeType에 0 부터 6까지의 숫자로 보관한다.

다음 그림과 같이 도형 L 은 6, S는 3 , T는 2 의 값을 가진다.

이것을 이용해서 7 가지의 HTML 색상(red, orange 등) 정보를 저장하는

배열 변수(shapeColor)에 curShapeType을 지정해서

해당 도형의 색상을 찾을 수 있다.

즉, 색상("red", "orange", "yellow", "green", "blue", "indigo", "violet")의 순서와

도형의 순서(O, I, T, S, X, J, L)를 연결하여 색상을 지정한다.

그리고, 도형을 그리기 전에 이 색을 지정하고 (fillStyle)

도형을 그리면 된다 (fillRect).


다만, 떨어지는 도형은 이렇게 그릴 수 있지만

이미 떨어진 도형, 즉 게임판에 저장된 도형은 다른 처리를 해야 한다.

기존에는 게임판에 도형에 대한 정보를 저장할 때

값이 있다는 의미로 1 을 지정했다.

이번에는 현재 도형의 종류(curShapeType)를 지정한다.

다만, curShapeType은 배열이라 0 부터 시작하는데

0 은 게임판에서 도형이 없다는 의미라

1 부터 시작하도록 curShapeType에 +1 을 해주고

그릴 때는 -1 을 해서 처리하였다.

즉, 도형 L 은 7 (6), S는 4 (3) , T는 3 (2) 의 값을 가진다.

그림으로 정리하면,

L 자 도형의 경우 떨어질 때는 6 으로,

게임판에 저장할 때는 7 로 저장하여 구현한다.

테트리스 도형을 이동할 경우와

gamePanel에 저장할 때로 나누어서 정리하였는데

유사한 기능을 2 가지로 구현해서 혼동할 수 있어 다시 정리한다.


도형이 이동할 경우에는 도형의 종류(curShapeType)가 0 부터 시작되고,

ctx.fillStyle = shapeColor[curShapeType];


도형의 정보를 저장할 때는 +1, 그릴 때는 -1로 계산해서 구현한다.

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

ctx.fillStyle = shapeColor[gamePanel[y][x]-1];



지금까지 배열을 이용한 회전에 몇 가지 기능을 구현하면서

간단한 테트리스를 제작하는 방법을 정리하였다.


이 과정에서 중요한 개념은 배열의 사용이다.

화면에 보여주기 위한 게임판(canvas)에 대한 정보를

어떻게 저장하고 관리하는 지가 핵심 기술이랄 수 있다.

즉, 2 차원 배열 사용법이 핵심이다.


구현한지가 오래되어서 정확하지 않지만

제법 많은 게임들(특히 보드게임)을 배열을 이용하여 개발하였던 것으로 기억하니

잘 익혀 두길 바란다.







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

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

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

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 사용)으로 구현하였지만

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

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

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




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

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

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

1. 개요

2. 배열 방식의 도형 회전

3. Bitmask 방식의 도형 회전

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

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

6. requestAnimationFrame


Bitmask를 사용한 예제에서는

시간의 흐름을 setTimeout() 함수나 setInterval() 함수가 아닌

requestAnimationFrame() 함수를 사용하였다.


setTimeout() 함수나 setInterval() 함수는 다음과 같은 문제가 있다.


setInterval() 함수는 언제나 콜백함수를 호출하기 때문에 브라우저의 다른 탭이 선택된 경우와 같이 실제 화면을 다시 그릴 필요가 없는 경우에도 화면을 다시 그린다. 그래서 시스템의 리소스 낭비를 초래하여, 불필요한 전력을 소모하게 만든다. 또한, 디스플레이 갱신 전에 캔버스를 여러 번 고치더라도 디스플레이 갱신 바로 직전의 캔버스 상태만 적용이 되기 때문에, 프레임 손실이 발생할 수도 있다.

출처: Beautiful Code


게임과 같은 처리에는 requestAnimationFrame() 가 더 적합하다는 의미로

IE 10 이후나 Firefox, Chrome등에서 사용할 수 있다.


다음 예제는 MSDN에서 제공하는 예제

개인적으로 가장 쉬운 예제인 것 같아서 이 코드를 기본으로 정리하였다.

예제를 실행하고, "Start" 버튼을 누르면

빨간색의 도형이 왼쪽에서 오른쪽으로 천천이 이동한다.


먼저, 39 라인에서 requestAnimationFrame()을 새로 정의하였다.

재정의한 것은 IE, FireFox, Chrome등의 웹브라우저에 맞추어

적절한 requestAnimationFrame()을 사용하기 위한 것으로 [라인 40~43]

requestAnimationFrame()을 지원하지 않는 브라우저일 경우에는

기존과 같이 setTimeout()을 사용하도록 하였다 [라인 44~46].

setTimeout()의 실행 간격은 1000 / 60 (=16.667)로

이 값은 밀리 세컨드이니 0.0167초에 한번씩 실행된다.

이렇게 지정한 이유는 requestAnimationFrame() 함수가

0.0167초에 한번씩 실행되도록 실행 시간이 고정되어 있다.


실행을 중지하는 cancelAnimationFrame()도

requestAnimationFrame()와 동일한 방식으로 작성되었다 [라인 49~57].


실행 화면에서 “Start” 버튼을 누르면

Start()함수가 호출되고 [라인 30]

다시 requestAnimationFrame(requestAFrame)가 호출되면서

render()가 콜백 함수로 실행된다 [라인 31].


render() 함수에서는

화면에 있는 빨간 DIV의 왼쪽(left) 값을 3px씩 증가시켜서

왼쪽에서 오른쪽으로 움직이게 한다 [라인 26].


setTimeout()과 requestAnimationFrame()의 차이를 보기 위해서는

 “Start” 버튼을 눌러서 실행을 시킨 뒤에

웹 브라우저를 작게(Minimize) 한 뒤에 복원을 시켜보면 된다.

도형의 이동이 웹 브라우저를 작게 했을 때의 위치에서 멈추었다가

복원할 때 다시 해당 위치에서 시작되는 것을 볼 수 있다.


이 코드를 테트리스에 적용하는 것은 문제가 있다.

기존에 작성한 코드는 0.4 (400 / 1000) 초에 한번씩 실행되는데

requestAnimationFrame()는 무조건 0.0167 초마다 실행된다.

지나치게 빨리 실행되어,

도형들이 빨리 떨어지게 된다.


requestAnimationFrame()는 실행 시간을 지정할 수 없기 때문에

약간의 트릭이 필요하고,

이 방법이 Bitmask 참고 예제에 잘 구현되어 있다.

다음 코드는 이 예제에서 추출한 코드로,

requestAnimationFrame()을 지정된 시간 간격으로 실행되게 구현한다.

function render() {
    elm.style.left = ((lpos += 3) % 600) + "px";
                                             
}
var dt = 0, step = 0.4;
var last = now = timestamp();
function timestamp() { return new Date().getTime(); }
   
function frame() {
    now = timestamp();
    update(Math.min(1, (now - last) / 1000.0));
    last = now;
    requestId = window.requestAFrame(frame);
}
function update(idt) {
    dt = dt + idt;
    if (dt > step) {
        dt = dt - step;
        render();
    }
}
   
function speedUp() {
    step -= 0.05
    if (step < 0.1) step = 0.1;
}
   
function start() {
    frame()
}

전체코드

수정/추가된 코드가 많아 보이지만 원리는 간단하다.

현재 시간과 과거 시간의 차이를 계산해서

차이가 지정된 시간 (0.4초)보다 크면

도형의 위치를 이동시켜 준다.


코드와 연결해서 정리하면

현재 시간(now)과 과거 시간(last)의 차이(now - last)를 계산해서(dt)

차이가 지정된 시간 (0.4초)보다 크면 (dt > step)

도형의 위치를 이동시켜 준다 (render()).


조금 더 자세하게 정리하면,

현재 시간은 현재 호출된 시간(now)을 의미하며

과거 시간은 마지막으로 호출된 시간(이전 now)을 의미한다.

현재 시간과 과거 시간의 차이(idt)는 거의 항상 0.0167 초 이다.

(밀리 세컨드이기 때문에 1000 으로 나누어 준다. (now - last) / 1000)

requestAnimationFrame() 함수가 실행되는 시간이기 때문이다.


이 값을 계속 누적해서 (dt = dt + idt) 경과 시간을 계산하게 되고

경과 시간(dt)이 지정된 시간 (0.4 : step)보다 크면 (dt > step)

도형의 위치를 이동시켜 준다 (render()).

즉, 경과시간(dt)은 지정된 시간(0.4 초)이 될때까지 0.0167 초씩 증가한다.

지정된 시간이 되면 도형을 이동 시키고, 경과 시간을 초기화 한다 (dt - step).

초기화 방법이 0 이 아닌 dt – step 인 것은

미세한 시간 차이라도 처리해 주기 위한 것이다.


기존 코드에 추가한 이번 예제는 속도 조절 기능도 구현했다.

HTML 버튼을 추가하고,

이 버튼을 클릭하면 speedUp() 함수를 호출하도록 작성했다.

speedUp() 함수에서는 지정된 시간(step)을 0.05초씩 작게(-) 해서

0.4초 마다 호출되던 것을 0.1 초까지 빨라지도록 했다.

이것은 테트리스의 난이도 조절에 사용할 수 있다.


이상의 코드를 앞서 정리한

Btmask 기반 회전 방식을 토대로 제작한 마지막 예제에 적용한다.


requestAnimationFrame() 예제에 추가한(두번째 예제) 코드를

Btmask 마지막 예제에 그대로 추가해 주면

requestAnimationFrame()를 사용하여

게임 난이도를 조절하는 예제를 구현할 수 있다.

function update(idt) {
    dt = dt + idt;
    if (dt > step) {
        dt = dt - step;
        playingTetris();
    }
}
window.onload = function(){
    requestId = window.requestAFrame(frame);
}

전체코드

추가 후 바꾸어 줄 코드는 이상의 코드(빨간색)과 같이

setInterval() 대신 requestAnimationFrame(),

도형을 이동시키는 rander() 대신

테트리스를 실행하는 playingTetris()을 호출하면 된다.


게임을 실행한 후,

한 행을 채우면 갑자기 도형들이 빨라지는 것을 볼 수 있다.



지금까지 테트리스를 제작하는 두 가지 방법을 정리하였다.

두가지 제작 방법으로 작성된 참고 예제에서

쉽고 좋다고 여겨지는 코드를 추출하여 정리하였다.

두 예제 모두 비효율적인(?) 코드와 재미난 코드로 구성되어 있어

배울점이 많으니 더 살펴 보길 바란다.

작성한 예제 코드에 적절한 난이도(시간) 조절과

다음 도형을 볼 수 있는 미리보기,

게임 진행에 따른 점수 기능을 구현하면

제법 그럴 듯한 테트리스가 될 것이다.

(참고 예제에 구현되어 있다)

어렵지 않으니 각자 구현해 보길 바라고 댓글로 공유하면 아무 멋진 일이 될 것 같다.




+ Recent posts