React로 타일드롭 게임 만들기: 초보자를 위한 단계별 가이드

React로 타일드롭 게임 만들기: 초보자를 위한 단계별 가이드 | 지식에 대한 탐구

1. 개요

안녕하세요! 오늘은 React를 사용하여 클래식 타일드롭 게임을 만드는 방법을 단계별로 알아보겠습니다. 이 튜토리얼을 통해 React 기초, 상태 관리, 그리고 게임 로직 구현에 대해 배울 수 있습니다.

 

2. 개발 환경 설정

2.1 Node.js 설치

먼저, Node.js를 설치해야 합니다. Node.js 공식 웹사이트에서 LTS 버전을 다운로드하고 설치하세요.

 

2.2 새 React 프로젝트 생성

터미널을 열고 다음 명령어를 실행하여 새 React 프로젝트를 생성합니다:

# 새 React 프로젝트 생성
npx create-react-app tetris-game
# 프로젝트 폴더로 이동
cd tetris-game

 

2.3 Tailwind CSS 설치 및 설정

Tailwind CSS를 설치하고 설정합니다:

# Tailwind CSS 및 관련 패키지 설치
npm install -D tailwindcss postcss autoprefixer
# Tailwind 설정 파일 생성
npx tailwindcss init -p

tailwind.config.js 파일을 열고 다음과 같이 수정합니다:

module.exports = {
  content: [
    "./src/**/*.{js,jsx,ts,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

 

src/index.css 파일을 열고 다음 내용을 추가합니다:

@tailwind base;
@tailwind components;
@tailwind utilities;

 

3. 타일드롭 게임 컴포넌트 생성

src 폴더 안에 TetrisGame.js 파일을 생성하고 다음 코드를 작성합니다:

import React, { useState, useEffect, useCallback } from 'react';

// 게임 보드의 크기 설정
const BOARD_WIDTH = 10;
const BOARD_HEIGHT = 20;

// 테트로미노 모양과 색상 정의
const TETROMINOS = [
  { shape: [[1, 1, 1, 1]], color: 'cyan' },
  { shape: [[1, 1], [1, 1]], color: 'yellow' },
  { shape: [[1, 1, 1], [0, 1, 0]], color: 'purple' },
  { shape: [[1, 1, 1], [1, 0, 0]], color: 'orange' },
  { shape: [[1, 1, 1], [0, 0, 1]], color: 'blue' },
  { shape: [[1, 1, 0], [0, 1, 1]], color: 'red' },
  { shape: [[0, 1, 1], [1, 1, 0]], color: 'green' },
];

// 빈 게임 보드 생성 함수
const createEmptyBoard = () => 
  Array.from({ length: BOARD_HEIGHT }, () => Array(BOARD_WIDTH).fill(0));

// 타일드롭 게임 컴포넌트
const TetrisGame = () => {
  // 게임 상태 관리를 위한 useState 훅 사용
  const [board, setBoard] = useState(createEmptyBoard());
  const [currentPiece, setCurrentPiece] = useState(null);
  const [gameOver, setGameOver] = useState(false);
  const [score, setScore] = useState(0);
  const [isPlaying, setIsPlaying] = useState(false);

  // 새로운 테트로미노 생성 함수
  const spawnNewPiece = useCallback(() => {
    const randomPiece = TETROMINOS[Math.floor(Math.random() * TETROMINOS.length)];
    const newPiece = {
      shape: randomPiece.shape,
      color: randomPiece.color,
      x: Math.floor(BOARD_WIDTH / 2) - Math.floor(randomPiece.shape[0].length / 2),
      y: 0,
    };
    
    if (checkCollision(newPiece.shape, newPiece.x, newPiece.y)) {
      setGameOver(true);
      setIsPlaying(false);
    } else {
      setCurrentPiece(newPiece);
    }
  }, []);

  // 테트로미노 아래로 이동 함수
  const moveDown = useCallback(() => {
    if (!currentPiece || gameOver) return;
    if (checkCollision(currentPiece.shape, currentPiece.x, currentPiece.y + 1)) {
      placePiece();
      return;
    }
    setCurrentPiece(prev => ({ ...prev, y: prev.y + 1 }));
  }, [currentPiece, gameOver]);

  // 테트로미노 왼쪽으로 이동 함수
  const moveLeft = () => {
    if (!currentPiece || gameOver) return;
    if (!checkCollision(currentPiece.shape, currentPiece.x - 1, currentPiece.y)) {
      setCurrentPiece(prev => ({ ...prev, x: prev.x - 1 }));
    }
  };

  // 테트로미노 오른쪽으로 이동 함수
  const moveRight = () => {
    if (!currentPiece || gameOver) return;
    if (!checkCollision(currentPiece.shape, currentPiece.x + 1, currentPiece.y)) {
      setCurrentPiece(prev => ({ ...prev, x: prev.x + 1 }));
    }
  };

  // 테트로미노 회전 함수
  const rotate = () => {
    if (!currentPiece || gameOver) return;
    const rotated = currentPiece.shape[0].map((_, index) =>
      currentPiece.shape.map(row => row[index]).reverse()
    );
    if (!checkCollision(rotated, currentPiece.x, currentPiece.y)) {
      setCurrentPiece(prev => ({ ...prev, shape: rotated }));
    }
  };

  // 충돌 검사 함수
  const checkCollision = (shape, x, y) => {
    for (let row = 0; row < shape.length; row++) {
      for (let col = 0; col < shape[row].length; col++) {
        if (shape[row][col] && 
            (y + row >= BOARD_HEIGHT ||
             x + col < 0 ||
             x + col >= BOARD_WIDTH ||
             board[y + row][x + col] !== 0)) {
          return true;
        }
      }
    }
    return false;
  };

  // 테트로미노를 보드에 배치하는 함수
  const placePiece = () => {
    const newBoard = board.map(row => [...row]);
    currentPiece.shape.forEach((row, y) => {
      row.forEach((value, x) => {
        if (value !== 0) {
          newBoard[y + currentPiece.y][x + currentPiece.x] = currentPiece.color;
        }
      });
    });

    setBoard(newBoard);
    clearLines(newBoard);
    spawnNewPiece();
  };

  // 완성된 줄 제거 및 점수 계산 함수
  const clearLines = (newBoard) => {
    let linesCleared = 0;
    for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
      if (newBoard[y].every(cell => cell !== 0)) {
        newBoard.splice(y, 1);
        newBoard.unshift(Array(BOARD_WIDTH).fill(0));
        linesCleared++;
      }
    }
    if (linesCleared > 0) {
      setScore(prev => prev + linesCleared * 100);
    }
  };

  // 게임 루프 useEffect
  useEffect(() => {
    if (isPlaying && !gameOver) {
      const gameLoop = setInterval(() => {
        moveDown();
      }, 1000);
      return () => clearInterval(gameLoop);
    }
  }, [isPlaying, gameOver, moveDown]);

  // 새 테트로미노 생성 useEffect
  useEffect(() => {
    if (isPlaying && !currentPiece && !gameOver) {
      spawnNewPiece();
    }
  }, [currentPiece, gameOver, isPlaying, spawnNewPiece]);

  // 키보드 이벤트 처리 useEffect
  useEffect(() => {
    const handleKeyPress = (e) => {
      if (!isPlaying || gameOver) return;
      switch (e.key) {
        case 'ArrowLeft':
          moveLeft();
          break;
        case 'ArrowRight':
          moveRight();
          break;
        case 'ArrowDown':
          moveDown();
          break;
        case 'ArrowUp':
          rotate();
          break;
        default:
          break;
      }
    };

    window.addEventListener('keydown', handleKeyPress);
    return () => window.removeEventListener('keydown', handleKeyPress);
  }, [isPlaying, gameOver, moveDown]);

  // 게임 시작 함수
  const startGame = () => {
    setBoard(createEmptyBoard());
    setCurrentPiece(null);
    setGameOver(false);
    setScore(0);
    setIsPlaying(true);
    spawnNewPiece();
  };

  // 게임 일시정지 함수
  const pauseGame = () => {
    setIsPlaying(false);
  };

  // 게임 재개 함수
  const resumeGame = () => {
    if (!gameOver) {
      setIsPlaying(true);
    }
  };

  // 게임 종료 함수
  const endGame = () => {
    setGameOver(true);
    setIsPlaying(false);
  };

  // 렌더링
  return (
    <div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
      <h1 className="text-4xl font-bold mb-4">타일드롭</h1>
      <div className="border-4 border-gray-800 bg-white">
        {board.map((row, y) => (
          <div key={y} className="flex">
            {row.map((cell, x) => (
              <div
                key={`${y}-${x}`}
                className="w-6 h-6 border border-gray-300"
                style={{
                  backgroundColor: 
                    currentPiece && 
                    y >= currentPiece.y && 
                    y < currentPiece.y + currentPiece.shape.length &&
                    x >= currentPiece.x && 
                    x < currentPiece.x + currentPiece.shape[0].length &&
                    currentPiece.shape[y - currentPiece.y][x - currentPiece.x] 
                      ? currentPiece.color
                      : cell || 'white'
                }}
              />
            ))}
          </div>
        ))}
      </div>
      <div className="mt-4 text-xl">점수: {score}</div>
      <div className="mt-4 flex space-x-2">
        {!isPlaying && !gameOver && (
          <button 
            className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
            onClick={startGame}
          >
            게임 시작
          </button>
        )}
        {isPlaying && !gameOver && (
          <button 
            className="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
            onClick={pauseGame}
          >
            일시정지
          </button>
        )}
        {!isPlaying && !gameOver && (
          <button 
            className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
            onClick={resumeGame}
          >
            계속하기
          </button>
        )}
        <button 
          className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
          onClick={endGame}
        >
          게임 종료
        </button>
      </div>
      {gameOver && (
        <div className="mt-4 text-2xl font-bold text-red-600">게임 오버!</div>
      )}
    </div>
  );
};

export default TetrisGame;

 

4. App.js 수정

이제 만든 타일드롭 게임 컴포넌트를 앱에 추가해봅시다. src/App.js 파일을 열고 다음과 같이 수정합니다:

import React from 'react';
import TetrisGame from './TetrisGame';

function App() {
  return (
    <div className="App">
      <TetrisGame />
    </div>
  );
}

export default App;

 

5. 게임 실행

모든 준비가 완료되었습니다! 이제 게임을 실행해봅시다. 터미널에서 다음 명령어를 실행합니다:

# 개발 서버 실행
npm start

브라우저에서 http://localhost:3000로 접속하면 타일드롭 게임을 플레이할 수 있습니다.

 

6. 코드 설명

이제 타일드롭 게임의 주요 부분들을 살펴보겠습니다:

  • 상태 관리: useState 훅을 사용하여 게임의 여러 상태를 관리합니다. (board, currentPiece, gameOver, score, isPlaying)
  • 테트로미노 생성: spawnNewPiece 함수는 랜덤한 테트로미노를 생성하고, 게임 오버 조건을 확인합니다.
  • 테트로미노 이동: moveDown, moveLeft, moveRight, rotate 함수들이 테트로미노의 이동과 회전을 담당합니다.
  • 충돌 감지: checkCollision 함수는 테트로미노가 벽이나 다른 블록과 충돌하는지 확인합니다.
  • 라인 제거: clearLines 함수는 완성된 라인을 제거하고 점수를 업데이트합니다.
  • 게임 루프: useEffect 훅을 사용하여 주기적으로 테트로미노를 아래로 이동시킵니다.
  • 키보드 입력 처리: 또 다른 useEffect를 사용하여 키보드 이벤트를 처리합니다.
  • 게임 컨트롤: startGame, pauseGame, resumeGame, endGame 함수들이 게임의 상태를 제어합니다.

 







 

이 튜토리얼을 통해 React를 사용하여 클래식 타일드롭 게임을 만드는 방법을 배웠습니다. 이 과정에서 React의 주요 개념들인 컴포넌트, 상태 관리, 효과 처리 등을 실제로 적용해 보았습니다.

이 기본 버전의 타일드롭 게임을 바탕으로, 다음과 같은 추가 기능들을 구현해 볼 수 있습니다:

  • 다음 테트로미노 미리보기
  • 테트로미노 홀드 기능
  • 레벨 시스템 (속도 증가)
  • 최고 점수 기록
  • 게임 디자인 개선

React와 게임 개발에 대해 더 깊이 이해하고 싶다면, 이러한 추가 기능들을 직접 구현해 보는 것을 추천합니다. 즐거운 코딩 되세요!

© 2024 지식에 대한 탐구. All rights reserved.