본문 바로가기
JavaScript

[JavaScript] 간단한 테트리스 게임 만들기

by teamnova 2024. 8. 24.
728x90

 

오늘은 자바 스크립트로 간단한 테트리스 게임을 만들어보도록 하겠습니다. 

먼저 HTML  코드입니다 

(자바 스크립트 전체 코드는 게시글 하단에서 확인하실 수 있습니다)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Tetris Game</title>
    <style>
        canvas {
            background-color: #000;
            display: block;
            margin: 0 auto;
            border: 2px solid #fff;
        }
    </style>
</head>
<body>
    <canvas id="tetris" width="300" height="600"></canvas>
    <script src="tetris.js"></script>
</body>
</html>

 

테트리스 도형이 쌓일 배경을 만들어줍니다. 

 

 

 

다음은 자바스크립트 코드입니다.

앞서 작성한 HTML 코드에서는 캔버스의 크기를 가로 300픽셀, 세로 600픽셀로 설정했습니다.

캔버스 위의 게임 보드는 작은 셀들로 구성되어 있습니다.


// 1) grid 그리드 크기 설정 
const canvas = document.getElementById('tetris');
const context = canvas.getContext('2d');

const grid = 20;  // 테트리스 그리드 크기
const cols = canvas.width / grid;
const rows = canvas.height / grid;

 

자바스크립트에서 각 셀의 크기를 나타내는 grid 변수에 20을 할당하여,
게임 속 가장 작은 도형(셀 하나)의 크기가 20x20 픽셀이 되도록 설정했습니다.

 

 

 

// 2) 배열 생성 및 위치별 색상 정의 

let board = Array.from({ length: rows }, () => Array(cols).fill(0));

const colors = [
    null,
    'cyan',
    'blue',
    'orange',
    'yellow',
    'green',
    'purple',
    'red'
];

 

테트리스 게임 보드를 나타내는 배열을 생성하고 각각의 위치에 맞는 색상을 정의해줍니다. 

 

 

 

// 3) 테트로미노 블록 정의 

const tetrominoes = [
    [
        [1, 1, 1, 1],  // I
    ],
    [
        [0, 2, 0],
        [2, 2, 2],     // T
    ],
    [
        [3, 3],
        [3, 3],        // O
    ],
    [
        [0, 4, 4],
        [4, 4, 0],     // S
    ],
    [
        [5, 5, 0],
        [0, 5, 5],     // Z
    ],
    [
        [6, 0, 0],
        [6, 6, 6],     // J
    ],
    [
        [0, 0, 7],
        [7, 7, 7],     // L
    ]
];

function createPiece(type) {
    return tetrominoes[type];
}

 

알파벳 모양의 셀 위치 에 색상을 정의해줍니다 

 

 

// 4) 테트로미노 조작 
function drawMatrix(matrix, offset) { //matrix : 블록의 형태
    // offset: 블록을 그릴때의 위치 오프셋 정의. 화면 어디에 위치할 지 결정한다. 
    matrix.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = colors[value];
                context.fillRect((x + offset.x) * grid, (y + offset.y) * grid, grid, grid);
                // fillRect: 실제 셀에 색칠한다. 
            }
        });
    });
}


// 5)  테트로미노 블록이 다른 블록이나 경계에 충돌했는지, 여부 판단. 
function collide(board, player) {
    const [m, o] = [player.matrix, player.pos];
    for (let y = 0; y < m.length; y++) {
        for (let x = 0; x < m[y].length; x++) {
            if (m[y][x] !== 0 &&
               (board[y + o.y] &&
                board[y + o.y][x + o.x]) !== 0) {
                return true;
            }
        }
    }
    return false;
}

//  6) 도형을 보드에 고정 시키기. 
function merge(board, player) {
    player.matrix.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                board[y + player.pos.y][x + player.pos.x] = value;
            }
        });
    });
}

 

drawMatrix 함수는 주어진 matrix 2차원 배열과 offset 값을 사용하여, 캔버스에 블록을 그리는 역할을 합니다. 또한 셀의 값에 따라 색상을 설정합니다. 

 

collide 함수는 현재 게임 보드의 상태와 플레이어의 상태를 비교하여서 이동 중인 블록이 유효한 위치, 즉 빈 곳에 놓일 수 있도록 검사하는 역할을 합니다. 테트로미노 블록이 이동하거나 회전할때 해당 함수를 호출합니다. 

 

merge 함수는 현재 플레이어가 조작 중인 테트로미노 블록을 게임 보드에 고정하는 역할을 합니다. 블록이 더이상 이동하거나 회전하지 못하도록 합니다. 

 

 

let dropCounter = 0;
let dropInterval = 1000;
let lastTime = 0;

7) 매 프레임마다 호출되며 dropInterval 에 따라 1초마다 playerDrop 을 호출함 
function update(time = 0) {
    const deltaTime = time - lastTime;
    lastTime = time;

    dropCounter += deltaTime;
    if (dropCounter > dropInterval) {
        playerDrop();
    }

    draw();
    requestAnimationFrame(update);
}

8) 블록을 한 칸 (픽셀) 아래로 이동 시킨다. 
function playerDrop() {
    player.pos.y++;
    if (collide(board, player)) {
        player.pos.y--;
        merge(board, player);
        playerReset();
    }
    dropCounter = 0;
}

// 9) 방향키 입력에 따라 player.pos.x 를 조정하여 블록을 좌우로 이동 시킨다. 
function playerMove(dir) {
    player.pos.x += dir;
    if (collide(board, player)) {
        player.pos.x -= dir;
    }
}

function playerReset() {
    player.matrix = createPiece(Math.floor(Math.random() * tetrominoes.length));
    player.pos.y = 0;
    player.pos.x = Math.floor((cols - player.matrix[0].length) / 2);

    if (collide(board, player)) {
        board.forEach(row => row.fill(0));
    }
}

function draw() {
    context.clearRect(0, 0, canvas.width, canvas.height);
    drawMatrix(board, { x: 0, y: 0 });
    drawMatrix(player.matrix, player.pos);
}

const player = {
    pos: { x: 0, y: 0 },
    matrix: createPiece(Math.floor(Math.random() * tetrominoes.length))
};

document.addEventListener('keydown', event => {
    if (event.keyCode === 37) {
        playerMove(-1);
    } else if (event.keyCode === 39) {
        playerMove(1);
    } else if (event.keyCode === 40) {
        playerDrop();
    } else if (event.keyCode === 81) {
        player.matrix = rotate(player.matrix, -1);
    } else if (event.keyCode === 87) {
        player.matrix = rotate(player.matrix, 1);
    }
});

update();

 

 

update 함수는 게임의 상태를 주기적으로 업데이트하고 화면을 다시 그리기 위해 사용됩니다. 

즉 게임의 핵심적인 함수라고 할 수 있습니다 

 

여기서 사용된 requestAnimationFrame 이란 웹 브라우저의 API 로 

애니메이션을 부드럽게 처리하기 위해 프레임 단위로 함수를 호출하는 api 입니다. 

 

위 코드에서는 update 가 주기적으로 호출되며 dropInterval 변수에 담긴 1000은 1초를 의미합니다. 

이 값은 테트리스 게임에서 블록이 화면에서 한 단계 아래로 떨어지는 주기를 나타냅니다 

 

 

// 10) 블록 회전 구현 
function rotate(matrix, dir) {

// 1. 행렬 전치 
    for (let y = 0; y < matrix.length; y++) {
        for (let x = 0; x < y; x++) {
            [matrix[x][y], matrix[y][x]] = [matrix[y][x], matrix[x][y]];
        }
    }
    
    // 2. 행렬 반전 
    if (dir > 0) {
        matrix.forEach(row => row.reverse()); // 오른쪽 회전
    } else {
        matrix.reverse(); // 왼쪽 회전 
    }
    return matrix;
}

 

플레이어의 키보드 조작에 따라 도형 변경되는 코드 입니다. 

행렬 전치란 행렬의 행과 열을 교환하는 것으로, 90도 시계방향으로 회전하듯 보입니다. 

 

 

행렬 반전이란, 전치 한 후, 각 행을 좌우 또는 상하로 반전시키는 것을 말합니다.  

 

아래는 js 전체 코드 및 시연 영상입니다. 

 

tetris.js 

const canvas = document.getElementById('tetris');
const context = canvas.getContext('2d');

const grid = 20;  // 테트리스 그리드 크기
const cols = canvas.width / grid;
const rows = canvas.height / grid;

let board = Array.from({ length: rows }, () => Array(cols).fill(0));

const colors = [
    null,
    'cyan',
    'blue',
    'orange',
    'yellow',
    'green',
    'purple',
    'red'
];


// 3 테트로미노 블록 정의 

const tetrominoes = [
    [
        [1, 1, 1, 1],  // I
    ],
    [
        [0, 2, 0],
        [2, 2, 2],     // T
    ],
    [
        [3, 3],
        [3, 3],        // O
    ],
    [
        [0, 4, 4],
        [4, 4, 0],     // S
    ],
    [
        [5, 5, 0],
        [0, 5, 5],     // Z
    ],
    [
        [6, 0, 0],
        [6, 6, 6],     // J
    ],
    [
        [0, 0, 7],
        [7, 7, 7],     // L
    ]
];

function createPiece(type) {
    return tetrominoes[type];
}


// 4 테트로미노 조작 
function drawMatrix(matrix, offset) { //matrix : 블록의 형태
    // offset: 블록을 그릴때의 위치 오프셋 정의. 화면 어디에 위치할 지 결정한다. 
    matrix.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                context.fillStyle = colors[value];
                context.fillRect((x + offset.x) * grid, (y + offset.y) * grid, grid, grid);
                // fillRect: 실제 셀에 색칠한다. 
            }
        });
    });
}


// 5.  테트로미노 블록이 다른 블록이나 경계에 충돌했는지, 여부 판단. 
function collide(board, player) {
    const [m, o] = [player.matrix, player.pos];
    for (let y = 0; y < m.length; y++) {
        for (let x = 0; x < m[y].length; x++) {
            if (m[y][x] !== 0 &&
               (board[y + o.y] &&
                board[y + o.y][x + o.x]) !== 0) {
                return true;
            }
        }
    }
    return false;
}

// 
function merge(board, player) {
    player.matrix.forEach((row, y) => {
        row.forEach((value, x) => {
            if (value !== 0) {
                board[y + player.pos.y][x + player.pos.x] = value;
            }
        });
    });
}



let dropCounter = 0;
let dropInterval = 1000;
let lastTime = 0;

function update(time = 0) {
    const deltaTime = time - lastTime;
    lastTime = time;

    dropCounter += deltaTime;
    if (dropCounter > dropInterval) {
        playerDrop();
    }

    draw();
    requestAnimationFrame(update);
}

function playerDrop() {
    player.pos.y++;
    if (collide(board, player)) {
        player.pos.y--;
        merge(board, player);
        playerReset();
    }
    dropCounter = 0;
}

function playerMove(dir) {
    player.pos.x += dir;
    if (collide(board, player)) {
        player.pos.x -= dir;
    }
}

function playerReset() {
    player.matrix = createPiece(Math.floor(Math.random() * tetrominoes.length));
    player.pos.y = 0;
    player.pos.x = Math.floor((cols - player.matrix[0].length) / 2);

    if (collide(board, player)) {
        board.forEach(row => row.fill(0));
    }
}

function draw() {
    context.clearRect(0, 0, canvas.width, canvas.height);
    drawMatrix(board, { x: 0, y: 0 });
    drawMatrix(player.matrix, player.pos);
}

const player = {
    pos: { x: 0, y: 0 },
    matrix: createPiece(Math.floor(Math.random() * tetrominoes.length))
};

document.addEventListener('keydown', event => {
    if (event.keyCode === 37) {
        playerMove(-1);
    } else if (event.keyCode === 39) {
        playerMove(1);
    } else if (event.keyCode === 40) {
        playerDrop();
    } else if (event.keyCode === 81) {
        player.matrix = rotate(player.matrix, -1);
    } else if (event.keyCode === 87) {
        player.matrix = rotate(player.matrix, 1);
    }
});

update();


function rotate(matrix, dir) {
    for (let y = 0; y < matrix.length; y++) {
        for (let x = 0; x < y; x++) {
            [matrix[x][y], matrix[y][x]] = [matrix[y][x], matrix[x][y]];
        }
    }
    if (dir > 0) {
        matrix.forEach(row => row.reverse());
    } else {
        matrix.reverse();
    }
    return matrix;
}