Dandy Now!
  • 웹 기술(HTML, CSS, JavaScript)로 재탄생한 고전 게임, 테트리스 만들어 보기
    2025년 07월 05일 22시 38분 17초에 업로드 된 글입니다.
    작성자: DandyNow
    728x90
    반응형

    웹 기술로 재탄생한 고전 게임, 테트리스 만들어 보기

    유년 시절의 향수를 자극하며 수많은 이들의 손에서 즐겨지던 테트리스는 단순한 규칙 속에서 깊이 있는 몰입감을 선사하는 퍼즐 게임의 대명사라 할 수 있다. 본고에서는 이러한 테트리스 게임을 웹 기술만을 활용하여 구현한 과정을 상세히 기술하며, 웹 기술이 가진 무한한 잠재력에 대하여 논한다.


    1. 게임의 근간: HTML5 (index.html)

    모든 웹 페이지의 출발점이 그러하듯, 본 테트리스 게임 또한 HTML5를 기반으로 하여 그 구조와 콘텐츠를 정의하였다. index.html 파일은 게임의 전체적인 틀을 제시하는 역할을 담당한다.

    <!DOCTYPE html>
    <html lang="ko">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>테트리스</title>
        <link rel="stylesheet" href="style.css">
    </head>
    <body>
        <h1>테트리스</h1>
        <div class="game-container">
            <canvas id="tetris"></canvas>
            <div class="info">
                <h2>점수: <span id="score">0</span></h2>
                <h2>레벨: <span id="level">1</span></h2>
                <div class="next-piece">
                    <h2>다음 블록</h2>
                    <canvas id="next"></canvas>
                </div>
                <button id="start-button">게임 시작</button>
            </div>
        </div>
        <script src="script.js"></script>
    </body>
    </html>
    • <canvas> 태그: 게임의 핵심적인 시각적 요소를 구현하기 위해 사용된 HTML5의 중요 기능이다. id="tetris"는 주 게임 보드를, id="next"는 다음 출현할 블록을 미리 보여주는 영역을 각각 담당하며, JavaScript를 통해 이 캔버스 위에 블록들이 동적으로 그려진다.
    • 게임 정보 및 제어 요소: <h2><span> 태그는 현재 점수(score)와 레벨(level)을 사용자에게 명확히 전달하는 역할을 수행한다. <button id="start-button">은 게임 시작 기능을 담당하는 버튼으로서, 사용자 상호작용의 첫 지점을 제공한다.
    • 외부 파일 연동: <link rel="stylesheet" href="style.css">는 게임의 시각적 스타일을 정의하는 CSS 파일을, <script src="script.js"></script>는 게임의 전반적인 논리를 포함하는 JavaScript 파일을 각각 연동한다.

    2. 시각적 표현의 구현: CSS3 (style.css)

    style.css 파일은 본 게임의 레이아웃 및 시각적 심미성을 책임진다. 이 파일은 게임 화면의 배치, 글꼴, 색상 등 전반적인 디자인 요소들을 제어한다. (여기서는 style.css 파일의 구체적인 내용은 생략하나, 그 역할에 대한 설명은 포함한다.)

    • 캔버스 스타일링: tetrisnext 캔버스의 배경색, 테두리, 크기 등을 설정함으로써 게임 영역을 시각적으로 명확히 구분한다. 이는 게임의 '무대'를 확립하는 데 기여한다.
    • 레이아웃 구성: .game-container.info 클래스 등을 활용하여 게임 보드와 점수, 레벨, 다음 블록 표시 영역 등 정보 패널이 효과적으로 배치될 수 있도록 CSS Flexbox 혹은 Grid와 같은 레이아웃 기법을 적용하였다. 이를 통해 사용자 인터페이스는 정돈된 형태로 나타난다.
    body {
        font-family: Arial, sans-serif;
        background-color: #f0f0f0;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        height: 100vh;
        margin: 0;
    }
    
    h1 {
        color: #333;
    }
    
    .game-container {
        display: flex;
        flex-direction: row;
        align-items: flex-start;
        border: 3px solid #333;
        padding: 10px;
        background-color: #ddd;
        gap: 20px;
    }
    
    canvas#tetris {
        border: 2px solid #333;
        background-color: #000;
    }
    
    .info {
        text-align: left;
    }
    
    .info h2 {
        margin: 0 0 10px 0;
    }
    
    .next-piece {
        text-align: center;
    }
    
    canvas#next {
        border: 2px solid #333;
        background-color: #000;
    }
    
    #start-button {
        margin-top: 20px;
        padding: 10px 20px;
        font-size: 16px;
        cursor: pointer;
        background-color: #4CAF50;
        color: white;
        border: none;
        border-radius: 5px;
    }
    
    #start-button:hover {
        background-color: #45a049;
    }

    3. 게임의 지성: JavaScript (script.js)

    script.js 파일은 테트리스 게임의 모든 논리를 구현한다. 블록의 생성, 이동, 회전, 충돌 감지, 줄 제거, 점수 계산, 게임 오버 판정 등 게임 플레이에 필요한 핵심 기능들이 이곳에 정의되어 있다. 특히 Canvas API를 활용하여 게임의 모든 그래픽 요소를 동적으로 렌더링한다.

    3.1. 초기 환경 설정 및 변수 선언

    const COLS = 10;
    const ROWS = 20;
    const BLOCK_SIZE = 20;
    
    // 초기 설정
    const canvas = document.getElementById('tetris');
    const context = canvas.getContext('2d');
    canvas.width = COLS * BLOCK_SIZE;
    canvas.height = ROWS * BLOCK_SIZE;
    
    const nextCanvas = document.getElementById('next');
    const nextContext = nextCanvas.getContext('2d');
    nextCanvas.width = 4 * BLOCK_SIZE;
    nextCanvas.height = 4 * BLOCK_SIZE;
    
    const scoreElement = document.getElementById('score');
    const levelElement = document.getElementById('level');
    const startButton = document.getElementById('start-button');
    
    // 게임 변수
    let board = [];
    let score = 0;
    let level = 1;
    let lines = 0;
    let gameOver = false;
    let currentPiece;
    let nextPiece;
    let dropCounter = 0;
    let dropInterval = 1000; // 1초마다 한 칸씩 떨어집니다.
    let lastTime = 0;
    • COLS, ROWS, BLOCK_SIZE: 게임 보드의 가로, 세로 칸 수 및 개별 블록의 크기를 정의하는 상수로서, 게임의 기본적인 스케일을 결정한다.
    • canvas, context, nextCanvas, nextContext: HTML에 정의된 <canvas> 요소와 2D 그래픽 렌더링을 위한 컨텍스트를 획득한다. 이 객체들을 통하여 화면에 블록을 그릴 수 있다.
    • scoreElement, levelElement, startButton: HTML에서 점수와 레벨을 표시할 요소 및 게임 시작 버튼 요소를 가져온다.
    • 게임 변수: board는 게임 보드의 현재 상태를 나타내는 2차원 배열이다. score, level, lines는 각각 게임 점수, 레벨, 제거된 줄의 수를 추적한다. gameOver는 게임 종료 상태를 나타내는 불리언 변수이다. currentPiecenextPiece는 현재 조작 중인 블록과 다음에 출현할 블록을 저장하는 객체이다. dropCounter, dropInterval, lastTime은 블록이 자동으로 낙하하는 시간 간격을 제어하는 데 활용된다.

    3.2. 테트리스 블록의 정의 (PIECES)

    const PIECES = [
        {
            shape: [[1, 1, 1, 1]], // I
            color: 'cyan'
        },
        {
            shape: [[1, 1, 0], [0, 1, 1]], // Z
            color: 'red'
        },
        {
            shape: [[0, 1, 1], [1, 1, 0]], // S
            color: 'green'
        },
        {
            shape: [[1, 1, 1], [0, 0, 1]], // L
            color: 'orange'
        },
        {
            shape: [[1, 1, 1], [1, 0, 0]], // J
            color: 'blue'
        },
        {
            shape: [[1, 1, 1], [0, 1, 0]], // T
            color: 'purple'
        },
        {
            shape: [[1, 1], [1, 1]], // O
            color: 'yellow'
        }
    ];
    • PIECES: 테트리스 게임에 등장하는 7가지 유형의 블록 각각의 형태(shape)와 색상(color)을 정의한 배열이다. shape는 2차원 배열로 구성되어 블록의 형태를 이진 값(1은 블록의 구성 요소, 0은 빈 공간)으로 표현한다.

    3.3. 게임 보드 생성 (createBoard)

    function createBoard() {
        return Array.from({ length: ROWS }, () => Array(COLS).fill(0));
    }
    • createBoard(): 게임 보드를 초기 상태로 설정하는 함수이다. ROWS x COLS 크기의 2차원 배열을 생성하고 모든 칸을 0으로 채워 빈 공간임을 나타낸다.

    3.4. 게임 화면 렌더링 (draw)

    function draw() {
        // Draw board
        context.clearRect(0, 0, canvas.width, canvas.height);
        for (let row = 0; row < ROWS; row++) {
            for (let col = 0; col < COLS; col++) {
                if (board[row][col]) {
                    context.fillStyle = board[row][col];
                    context.fillRect(col * BLOCK_SIZE, row * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                    context.strokeRect(col * BLOCK_SIZE, row * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                }
            }
        }
    
        // Draw current piece
        if (currentPiece) {
            context.fillStyle = currentPiece.color;
            currentPiece.shape.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value) {
                        context.fillRect((currentPiece.x + x) * BLOCK_SIZE, (currentPiece.y + y) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                        context.strokeRect((currentPiece.x + x) * BLOCK_SIZE, (currentPiece.y + y) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                    }
                });
            });
        }
    
        // Draw next piece
        nextContext.clearRect(0, 0, nextCanvas.width, nextCanvas.height);
        if (nextPiece) {
            nextContext.fillStyle = nextPiece.color;
            const shape = nextPiece.shape;
            const shapeWidth = shape[0].length * BLOCK_SIZE;
            const shapeHeight = shape.length * BLOCK_SIZE;
            const startX = (nextCanvas.width - shapeWidth) / 2;
            const startY = (nextCanvas.height - shapeHeight) / 2;
    
            shape.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value) {
                        nextContext.fillRect(startX + x * BLOCK_SIZE, startY + y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                        nextContext.strokeRect(startX + x * BLOCK_SIZE, startY + y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                    }
                });
            });
        }
    }
    • draw(): 게임의 모든 시각적 요소들을 캔버스에 렌더링하는 함수이다.
      • context.clearRect(): 매 프레임마다 이전 그림을 제거하여 잔상을 방지한다.
      • board 배열을 순회하며 이미 고정된 블록들을 게임 보드에 그린다. 각 블록의 색상(context.fillStyle)과 위치/크기(context.fillRect, context.strokeRect)를 설정하여 사각형 형태로 표현한다.
      • currentPiece (현재 조작 중인 블록)와 nextPiece (다음에 출현할 블록)를 각각의 지정된 캔버스에 그린다. nextCanvas의 경우, 블록을 중앙에 배치하는 로직이 포함되어 있어 분리된 UI 요소를 효율적으로 관리한다.

    3.5. 게임 루프 (gameLoop)

    function gameLoop(time = 0) {
        if (gameOver) {
            return;
        }
    
        const deltaTime = time - lastTime;
        lastTime = time;
        dropCounter += deltaTime;
    
        if (dropCounter > dropInterval) {
            if (currentPiece) {
                if (!movePiece(0, 1)) {
                    lockPiece();
                    removeLines();
                    if (isGameOver()) {
                        gameOver = true;
                        alert('Game Over');
                        return;
                    }
                    currentPiece = nextPiece;
                    nextPiece = newPiece();
                }
            }
            dropCounter = 0;
        }
    
        draw();
        requestAnimationFrame(gameLoop);
    }
    • gameLoop(time = 0): 게임의 핵심적인 흐름을 제어하는 함수로서, requestAnimationFrame을 통해 브라우저의 화면 재생 빈도에 맞추어 호출되어 부드러운 애니메이션 효과를 구현한다.
      • deltaTime을 계산하여 마지막 프레임 이후의 경과 시간을 파악하고, 이를 dropCounter에 누적한다.
      • dropCounterdropInterval을 초과할 경우, 블록을 한 칸 아래로 이동(movePiece(0, 1))시킨다.
      • movePiece 호출이 실패하면 (즉, 블록이 더 이상 하강할 수 없으면) lockPiece()를 호출하여 블록을 보드에 고정하고, removeLines()를 호출하여 완성된 줄을 제거한다.
      • isGameOver()를 통해 게임 종료 여부를 확인하고, 게임이 종료되면 경고 메시지를 표시하고 루프를 중단한다.
      • 새로운 currentPiecenextPiece를 준비한다.
      • draw()를 호출하여 화면을 업데이트한다.

    3.6. 새 블록 생성 (newPiece)

    function newPiece() {
        const piece = PIECES[Math.floor(Math.random() * PIECES.length)];
        return {
            ...piece,
            x: Math.floor(COLS / 2) - Math.floor(piece.shape[0].length / 2),
            y: 0
        };
    }
    • newPiece(): PIECES 배열에서 무작위로 하나의 블록을 선택한 후, 게임 보드의 상단 중앙에 위치하도록 초기 x, y 좌표를 설정하여 해당 블록 객체를 반환한다.

    3.7. 블록 이동 제어 (movePiece)

    function movePiece(dx, dy) {
        if (isValidMove(currentPiece.shape, currentPiece.x + dx, currentPiece.y + dy)) {
            currentPiece.x += dx;
            currentPiece.y += dy;
            return true;
        }
        return false;
    }
    • movePiece(dx, dy): 현재 블록을 dx만큼 수평으로, dy만큼 수직으로 이동시킨다. 이동하기에 앞서 isValidMove 함수를 호출하여 이동할 위치가 유효한지 검사한다. 이동이 유효하면 블록의 위치를 갱신하고 true를 반환하며, 그렇지 않을 경우 false를 반환한다.

    3.8. 블록 회전 제어 (rotatePiece)

    function rotatePiece() {
        const shape = currentPiece.shape;
        const newShape = shape[0].map((_, colIndex) => shape.map(row => row[colIndex]).reverse());
        if (isValidMove(newShape, currentPiece.x, currentPiece.y)) {
            currentPiece.shape = newShape;
        }
    }
    • rotatePiece(): 현재 블록을 시계 방향으로 90도 회전시킨다. 회전된 새로운 형태(newShape)가 유효한 위치에 놓이는지 isValidMove로 확인한 후, 유효할 경우 currentPiece.shape를 갱신한다.

    3.9. 유효한 이동 판단 (isValidMove)

    function isValidMove(shape, x, y) {
        for (let row = 0; row < shape.length; row++) {
            for (let col = 0; col < shape[row].length; col++) {
                if (shape[row][col]) {
                    const newX = x + col;
                    const newY = y + row;
                    if (newX < 0 || newX >= COLS || newY >= ROWS || (newY >= 0 && board[newY] && board[newY][newX])) {
                        return false;
                    }
                }
            }
        }
        return true;
    }
    • isValidMove(shape, x, y): 주어진 shape (블록의 형태)가 특정 x, y 좌표에 배치되었을 때 게임 보드의 경계를 벗어나거나 이미 채워진 다른 블록과 겹치는지 여부를 판단하는 함수이다. 충돌이 발생하면 false를 반환하며, 그렇지 않으면 true를 반환한다.

    3.10. 블록 고정 (lockPiece)

    function lockPiece() {
        currentPiece.shape.forEach((row, y) => {
            row.forEach((value, x) => {
                if (value) {
                    board[currentPiece.y + y][currentPiece.x + x] = currentPiece.color;
                }
            });
        });
    }
    • lockPiece(): 현재 블록이 더 이상 하강할 수 없을 때, 해당 블록을 게임 보드(board)에 영구적으로 고정시키는 함수이다. 블록의 각 구성 칸에 해당하는 보드 셀에 블록의 색상을 기록한다.

    3.11. 줄 제거 및 점수 산정 (removeLines)

    function removeLines() {
        let linesRemoved = 0;
        outer: for (let row = ROWS - 1; row >= 0; row--) {
            for (let col = 0; col < COLS; col++) {
                if (board[row][col] === 0) {
                    continue outer;
                }
            }
    
            linesRemoved++;
            const newRow = Array(COLS).fill(0);
            board.splice(row, 1);
            board.unshift(newRow);
            row++;
        }
        if (linesRemoved > 0) {
            score += linesRemoved * 10;
            lines += linesRemoved;
            if (lines >= 10) {
                level++;
                lines -= 10;
                dropInterval *= 0.9;
            }
            scoreElement.innerText = score;
            levelElement.innerText = level;
        }
    }
    • removeLines(): 게임 보드를 하단에서 상단으로 스캔하며 완성된 줄의 존재 여부를 확인하고 이를 제거하는 함수이다.
      • 완성된 줄을 발견할 경우, 해당 줄을 splice 메서드를 통해 제거하고, unshift를 사용하여 보드 최상단에 새로운 빈 줄을 추가한다.
      • 제거된 줄의 수(linesRemoved)에 비례하여 score가 증가하며, lines (총 제거된 줄의 수)가 10 이상이 되면 level이 상승하고 dropInterval을 감소시켜 블록 낙하 속도를 가속화함으로써 게임의 난이도를 높인다.
      • 갱신된 점수와 레벨은 HTML 요소에 반영된다.

    3.12. 게임 종료 판정 (isGameOver)

    function isGameOver() {
        return board[0].some(cell => cell);
    }
    • isGameOver(): 게임 오버 조건을 판정하는 함수이다. 게임 보드의 가장 최상단 줄(board[0])에 블록이 하나라도 존재한다면 게임 오버로 간주하고 true를 반환한다.

    3.13. 하드 드롭 기능 (hardDrop)

    function hardDrop() {
        while (movePiece(0, 1));
        lockPiece();
        removeLines();
        if (isGameOver()) {
            gameOver = true;
            alert('Game Over');
            return;
        }
        currentPiece = nextPiece;
        nextPiece = newPiece();
    }
    • hardDrop(): 스페이스바가 눌렸을 때 현재 블록을 즉시 바닥으로 하강시키는 기능이다.
      • while (movePiece(0, 1));: movePiece(0, 1) (블록을 한 칸 아래로 이동시키는 명령)이 false를 반환할 때까지 (즉, 블록이 더 이상 하강할 수 없을 때까지) 반복적으로 movePiece 함수를 호출하여 블록을 빠르게 내린다.
      • 이후 lockPiece(), removeLines(), isGameOver()를 순차적으로 호출하여 블록을 고정하고 줄을 제거하며 게임 종료 여부를 확인한다.
      • 다음 블록을 준비한다.

    3.14. 이벤트 리스너 (키보드 및 버튼 제어)

    document.addEventListener('keydown', event => {
        if (gameOver) return;
    
        switch (event.code) {
            case 'ArrowLeft':
                movePiece(-1, 0);
                break;
            case 'ArrowRight':
                movePiece(1, 0);
                break;
            case 'ArrowDown':
                movePiece(0, 1);
                dropCounter = 0;
                break;
            case 'ArrowUp':
                rotatePiece();
                break;
            case 'Space':
                event.preventDefault(); // 스페이스바의 기본 동작 방지
                hardDrop();
                break;
        }
        draw();
    });
    
    function startGame() {
        board = createBoard();
        score = 0;
        level = 1;
        lines = 0;
        gameOver = false;
        scoreElement.innerText = score;
        levelElement.innerText = level;
        currentPiece = newPiece();
        nextPiece = newPiece();
    
        lastTime = 0;
        dropCounter = 0;
        dropInterval = 1000;
    
        gameLoop();
    }
    
    startButton.addEventListener('click', startGame);
    • document.addEventListener('keydown', event => { ... }): 사용자가 키보드를 누를 때 발생하는 이벤트를 감지하는 리스너이다.
      • event.code를 활용하여 어떤 키가 눌렸는지 확인하며, 방향키(ArrowLeft, ArrowRight, ArrowDown, ArrowUp)에 따라 movePiece 또는 rotatePiece 함수를 호출한다.
      • Space 키가 눌렸을 경우, event.preventDefault()를 호출하여 스페이스바의 기본 동작(예: 포커스된 버튼의 활성화)을 제어하고, hardDrop() 함수를 실행하여 블록을 즉시 하강시킨다.
      • 모든 키 입력 처리 후 draw()를 호출하여 화면을 즉시 갱신한다.
    • startGame(): 게임을 초기화하고 시작하는 함수이다. 보드, 점수, 레벨, 게임 오버 상태 등을 초기화하며, 새로운 블록을 생성한 후 gameLoop()를 호출하여 게임을 개시한다.
    • startButton.addEventListener('click', startGame): "게임 시작" 버튼이 클릭될 때 startGame 함수를 호출하도록 이벤트 리스너를 설정한다.

    결론

    이처럼 웹의 근간을 이루는 표준 기술인 HTML5, CSS3, 그리고 JavaScript만을 활용하여도 충분히 동적이고 흥미로운 게임을 구현할 수 있음을 본 사례는 명확히 보여준다. 특히 JavaScript와 Canvas API의 유기적인 조합을 통한 그래픽 처리와 requestAnimationFrame을 이용한 부드러운 애니메이션 구현, 그리고 섬세한 이벤트 핸들링은 웹 기반 테트리스 게임 개발의 핵심적인 성공 요인으로 작용한다.


    전체 코드: script.js

    const COLS = 10;
    const ROWS = 20;
    const BLOCK_SIZE = 20;
    
    // 초기 설정
    const canvas = document.getElementById('tetris');
    const context = canvas.getContext('2d');
    canvas.width = COLS * BLOCK_SIZE;
    canvas.height = ROWS * BLOCK_SIZE;
    
    const nextCanvas = document.getElementById('next');
    const nextContext = nextCanvas.getContext('2d');
    nextCanvas.width = 4 * BLOCK_SIZE;
    nextCanvas.height = 4 * BLOCK_SIZE;
    
    const scoreElement = document.getElementById('score');
    const levelElement = document.getElementById('level');
    const startButton = document.getElementById('start-button');
    
    // 게임 변수
    let board = [];
    let score = 0;
    let level = 1;
    let lines = 0;
    let gameOver = false;
    let currentPiece;
    let nextPiece;
    let dropCounter = 0;
    let dropInterval = 1000; // 1초마다 한 칸씩 떨어집니다.
    let lastTime = 0;
    
    const PIECES = [
        {
            shape: [[1, 1, 1, 1]], // I
            color: 'cyan'
        },
        {
            shape: [[1, 1, 0], [0, 1, 1]], // Z
            color: 'red'
        },
        {
            shape: [[0, 1, 1], [1, 1, 0]], // S
            color: 'green'
        },
        {
            shape: [[1, 1, 1], [0, 0, 1]], // L
            color: 'orange'
        },
        {
            shape: [[1, 1, 1], [1, 0, 0]], // J
            color: 'blue'
        },
        {
            shape: [[1, 1, 1], [0, 1, 0]], // T
            color: 'purple'
        },
        {
            shape: [[1, 1], [1, 1]], // O
            color: 'yellow'
        }
    ];
    
    
    function createBoard() {
        return Array.from({ length: ROWS }, () => Array(COLS).fill(0));
    }
    
    function draw() {
        // Draw board
        context.clearRect(0, 0, canvas.width, canvas.height);
        for (let row = 0; row < ROWS; row++) {
            for (let col = 0; col < COLS; col++) {
                if (board[row][col]) {
                    context.fillStyle = board[row][col];
                    context.fillRect(col * BLOCK_SIZE, row * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                    context.strokeRect(col * BLOCK_SIZE, row * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                }
            }
        }
    
        // Draw current piece
        if (currentPiece) {
            context.fillStyle = currentPiece.color;
            currentPiece.shape.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value) {
                        context.fillRect((currentPiece.x + x) * BLOCK_SIZE, (currentPiece.y + y) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                        context.strokeRect((currentPiece.x + x) * BLOCK_SIZE, (currentPiece.y + y) * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                    }
                });
            });
        }
    
        // Draw next piece
        nextContext.clearRect(0, 0, nextCanvas.width, nextCanvas.height);
        if (nextPiece) {
            nextContext.fillStyle = nextPiece.color;
            const shape = nextPiece.shape;
            const shapeWidth = shape[0].length * BLOCK_SIZE;
            const shapeHeight = shape.length * BLOCK_SIZE;
            const startX = (nextCanvas.width - shapeWidth) / 2;
            const startY = (nextCanvas.height - shapeHeight) / 2;
    
            shape.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value) {
                        nextContext.fillRect(startX + x * BLOCK_SIZE, startY + y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                        nextContext.strokeRect(startX + x * BLOCK_SIZE, startY + y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                    }
                });
            });
        }
    }
    
    function gameLoop(time = 0) {
        if (gameOver) {
            return;
        }
    
        const deltaTime = time - lastTime;
        lastTime = time;
        dropCounter += deltaTime;
    
        if (dropCounter > dropInterval) {
            if (currentPiece) {
                if (!movePiece(0, 1)) {
                    lockPiece();
                    removeLines();
                    if (isGameOver()) {
                        gameOver = true;
                        alert('Game Over');
                        return;
                    }
                    currentPiece = nextPiece;
                    nextPiece = newPiece();
                }
            }
            dropCounter = 0;
        }
    
        draw();
        requestAnimationFrame(gameLoop);
    }
    
    function newPiece() {
        const piece = PIECES[Math.floor(Math.random() * PIECES.length)];
        return {
            ...piece,
            x: Math.floor(COLS / 2) - Math.floor(piece.shape[0].length / 2),
            y: 0
        };
    }
    
    function movePiece(dx, dy) {
        if (isValidMove(currentPiece.shape, currentPiece.x + dx, currentPiece.y + dy)) {
            currentPiece.x += dx;
            currentPiece.y += dy;
            return true;
        }
        return false;
    }
    
    function rotatePiece() {
        const shape = currentPiece.shape;
        const newShape = shape[0].map((_, colIndex) => shape.map(row => row[colIndex]).reverse());
        if (isValidMove(newShape, currentPiece.x, currentPiece.y)) {
            currentPiece.shape = newShape;
        }
    }
    
    function isValidMove(shape, x, y) {
        for (let row = 0; row < shape.length; row++) {
            for (let col = 0; col < shape[row].length; col++) {
                if (shape[row][col]) {
                    const newX = x + col;
                    const newY = y + row;
                    if (newX < 0 || newX >= COLS || newY >= ROWS || (newY >= 0 && board[newY] && board[newY][newX])) {
                        return false;
                    }
                }
            }
        }
        return true;
    }
    
    function lockPiece() {
        currentPiece.shape.forEach((row, y) => {
            row.forEach((value, x) => {
                if (value) {
                    board[currentPiece.y + y][currentPiece.x + x] = currentPiece.color;
                }
            });
        });
    }
    
    function removeLines() {
        let linesRemoved = 0;
        outer: for (let row = ROWS - 1; row >= 0; row--) {
            for (let col = 0; col < COLS; col++) {
                if (board[row][col] === 0) {
                    continue outer;
                }
            }
    
            linesRemoved++;
            const newRow = Array(COLS).fill(0);
            board.splice(row, 1);
            board.unshift(newRow);
            row++;
        }
        if (linesRemoved > 0) {
            score += linesRemoved * 10;
            lines += linesRemoved;
            if (lines >= 10) {
                level++;
                lines -= 10;
                dropInterval *= 0.9;
            }
            scoreElement.innerText = score;
            levelElement.innerText = level;
        }
    }
    
    function isGameOver() {
        return board[0].some(cell => cell);
    }
    
    function hardDrop() {
        while (movePiece(0, 1));
        lockPiece();
        removeLines();
        if (isGameOver()) {
            gameOver = true;
            alert('Game Over');
            return;
        }
        currentPiece = nextPiece;
        nextPiece = newPiece();
    }
    
    document.addEventListener('keydown', event => {
        if (gameOver) return;
    
        switch (event.code) {
            case 'ArrowLeft':
                movePiece(-1, 0);
                break;
            case 'ArrowRight':
                movePiece(1, 0);
                break;
            case 'ArrowDown':
                movePiece(0, 1);
                dropCounter = 0;
                break;
            case 'ArrowUp':
                rotatePiece();
                break;
            case 'Space':
                event.preventDefault(); // 스페이스바의 기본 동작 방지
                hardDrop();
                break;
        }
        draw();
    });
    
    function startGame() {
        board = createBoard();
        score = 0;
        level = 1;
        lines = 0;
        gameOver = false;
        scoreElement.innerText = score;
        levelElement.innerText = level;
        currentPiece = newPiece();
        nextPiece = newPiece();
    
        lastTime = 0;
        dropCounter = 0;
        dropInterval = 1000;
    
        gameLoop();
    }
    
    startButton.addEventListener('click', startGame);
    728x90
    반응형
    댓글