테스트 사이트 - 개발 중인 베타 버전입니다

windsurf 에디터를 이용해 만든 테트리스가 추가 되었습니다.

· 11개월 전 · 430 · 2

https://sir.kr/javascript/tetris/

 

총 이틀에 걸쳐서 만들었고

 

집중한 시간은 한 2~3시간 되는것 같습니다.

 

생산속도가 엄청 빨라 지겠네요.

 

g 키를 누르면 고스트 블록을 켜고, 끄고 할수 있습니다.

 

(현재 떨어지고 있는 블록이 땅에 닿았을 때 어디에 위치하게 될지 미리 보여주는 기능)

 

 

src/app/tetris/page.js

 

[code]

'use client';

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

import styled from '@emotion/styled';

 

// Tetromino shapes

const TETROMINOES = {

  I: [[1], [1], [1], [1]],

  O: [[1, 1], [1, 1]],

  T: [[0, 1, 0], [1, 1, 1]],

  S: [[0, 1, 1], [1, 1, 0]],

  Z: [[1, 1, 0], [0, 1, 1]],

  J: [[1, 0], [1, 0], [1, 1]],

  L: [[0, 1], [0, 1], [1, 1]]

};

 

const COLORS = {

  I: '#00f0f0',

  O: '#f0f000',

  T: '#a000f0',

  S: '#00f000',

  Z: '#f00000',

  J: '#0000f0',

  L: '#f0a000'

};

 

// Board size

const BOARD_WIDTH = 10;

const BOARD_HEIGHT = 20;

 

// Styled components

const Container = styled.div`

  position: fixed;

  top: 120px;

  left: 0;

  right: 0;

  bottom: 0;

  display: flex;

  flex-direction: column;

  align-items: center;

  background: #111;

  color: white;

  font-family: Arial, sans-serif;

  overflow: hidden;

`;

 

const GameWrapper = styled.div`

  display: flex;

  gap: 20px;

  align-items: flex-start;

  justify-content: center;

  width: 100%;

  max-width: 800px;

  margin-top: 40px;

 

  @media (max-width: 768px) {

    flex-direction: column;

    align-items: center;

    gap: 10px;

    padding: 0 10px;

  }

`;

 

const GameInfo = styled.div`

  display: flex;

  flex-direction: column;

  gap: 10px;

 

  h1 {

    font-size: 24px;

    margin: 0;

  }

 

  div {

    min-height: 20px;

    font-size: 16px;

  }

 

  .status-info {

    min-width: 150px;

    display: flex;

    justify-content: space-between;

  }

 

  .next-piece {

    margin-top: 20px;

 

    h2 {

      font-size: 18px;

      margin: 0 0 10px 0;

    }

 

    .preview {

      background: #000;

      padding: 5px;

      border-radius: 4px;

      display: grid;

      grid-template-columns: repeat(4, 20px);

      grid-template-rows: repeat(4, 20px);

      gap: 1px;

    }

  }

 

  @media (max-width: 768px) {

    width: min(90vw, 300px);

    padding: 10px;

    flex-direction: row;

    justify-content: space-between;

    align-items: flex-start;

  }

`;

 

const GameBoard = styled.div`

  display: grid;

  grid-template-rows: repeat(${BOARD_HEIGHT}, 1fr);

  grid-template-columns: repeat(${BOARD_WIDTH}, 1fr);

  gap: 1px;

  background: #222;

  padding: 10px;

  border-radius: 5px;

  width: min(85vw, 240px);

  height: min(60vh, 480px);

  box-sizing: border-box;

`;

 

const Cell = styled.div`

  background: ${props => props.color || '#000'};

  border: 1px solid #333;

  aspect-ratio: 1;

  transition: all 0.2s ease;

  ${props => props.isClearing && `

    animation: clearAnimation 0.5s;

  `}

  ${props => props.ghost && `

    background: ${props.color}22;

    border: 2px solid ${props.color}44;

  `}

 

  @keyframes clearAnimation {

    0% {

      transform: scale(1);

      opacity: 1;

    }

    50% {

      transform: scale(1.1);

      opacity: 0.5;

    }

    100% {

      transform: scale(0);

      opacity: 0;

    }

  }

`;

 

const GhostBlock = styled.div`

  position: absolute;

  width: 20px;

  height: 20px;

  background: ${props => props.color}22;

  border: 2px solid ${props => props.color}44;

`;

 

const Controls = styled.div`

  display: grid;

  grid-template-columns: repeat(3, 1fr);

  gap: 10px;

  margin-top: 20px;

  width: min(90vw, 250px);

 

  @media (min-width: 768px) {

    display: none;

  }

`;

 

const Button = styled.button`

  padding: 10px;

  font-size: 16px;

  border: none;

  border-radius: 5px;

  background: #444;

  color: white;

  cursor: pointer;

  &:hover {

    background: #555;

  }

`;

 

export default function TetrisGame() {

  const [board, setBoard] = useState(createEmptyBoard());

  const [piece, setPiece] = useState(null);

  const [nextPiece, setNextPiece] = useState(null);

  const [score, setScore] = useState(0);

  const [gameOver, setGameOver] = useState(false);

  const [gameStarted, setGameStarted] = useState(false);

  const [clearingRows, setClearingRows] = useState([]);

  const [speed, setSpeed] = useState(120); // 120% is base speed

  const baseInterval = 1000; // 1초를 기본 속도로 설정

  const [showGhost, setShowGhost] = useState(true); // 고스트 블록 표시 여부

 

  // Create empty board

  function createEmptyBoard() {

    return Array(BOARD_HEIGHT).fill().map(() => Array(BOARD_WIDTH).fill(null));

  }

 

  // Create new piece

  function createNewPiece() {

    const shapes = Object.keys(TETROMINOES);

    const shape = shapes[Math.floor(Math.random() * shapes.length)];

    return {

      shape: TETROMINOES[shape],

      color: COLORS[shape],

      x: Math.floor(BOARD_WIDTH / 2) - Math.floor(TETROMINOES[shape][0].length / 2),

      y: 0

    };

  }

 

  // Check collision

  function hasCollision(piece, board, dx = 0, dy = 0) {

    return piece.shape.some((row, y) =>

      row.some((cell, x) => {

        if (!cell) return false;

        const newX = piece.x + x + dx;

        const newY = piece.y + y + dy;

        return (

          newX < 0 ||

          newX >= BOARD_WIDTH ||

          newY >= BOARD_HEIGHT ||

          (newY >= 0 && board[newY][newX] !== null)

        );

      })

    );

  }

 

  // Merge piece with board

  function mergePiece(piece, boardState) {

    const newBoard = boardState.map(row => [...row]);

    piece.shape.forEach((row, y) => {

      row.forEach((cell, x) => {

        if (cell && piece.y + y >= 0) {

          newBoard[piece.y + y][piece.x + x] = piece.color;

        }

      });

    });

    return newBoard;

  }

 

  // Clear completed rows

  function clearRows(boardState) {

    let clearedRows = [];

    const newBoard = boardState.map((row, index) => {

      if (row.every(cell => cell !== null)) {

        clearedRows.push(index);

        return null;

      }

      return [...row];

    });

 

    if (clearedRows.length > 0) {

      setClearingRows(clearedRows);

      setSpeed(prevSpeed => Math.min(200, prevSpeed + (clearedRows.length * 2))); // 2%씩 속도 증가

 

      setTimeout(() => {

        setClearingRows([]);

        const finalBoard = newBoard

          .filter(row => row !== null)

          .map(row => [...row]);

 

        while (finalBoard.length < BOARD_HEIGHT) {

          finalBoard.unshift(Array(BOARD_WIDTH).fill(null));

        }

 

        setBoard(finalBoard);

        setScore(prev => prev + clearedRows.length * 100);

      }, 500);

 

      return boardState;

    }

 

    return newBoard.filter(row => row !== null);

  }

 

  // Hard drop

  const hardDrop = useCallback(() => {

    if (!piece || gameOver) return;

 

    let dropDistance = 0;

    while (!hasCollision(piece, board, 0, dropDistance + 1)) {

      dropDistance++;

    }

 

    if (dropDistance > 0) {

      setPiece(prev => ({

        ...prev,

        y: prev.y + dropDistance

      }));

    }

  }, [piece, board, gameOver, hasCollision]);

 

  // Move piece

  const movePiece = useCallback((dx, dy) => {

    if (!piece || gameOver) return;

 

    if (!hasCollision(piece, board, dx, dy)) {

      setPiece(prev => ({

        ...prev,

        x: prev.x + dx,

        y: prev.y + dy

      }));

    } else if (dy > 0) {

      // Piece has landed

      const newBoard = mergePiece(piece, board);

      const clearedBoard = clearRows(newBoard);

      setBoard(clearedBoard);

 

      // Use next piece and create new next piece

      setPiece(nextPiece);

      setNextPiece(createNewPiece());

     

      if (hasCollision(nextPiece, clearedBoard)) {

        setGameOver(true);

      }

    }

  }, [piece, nextPiece, board, gameOver, hasCollision, mergePiece, clearRows]);

 

  // Rotate piece

  const rotatePiece = useCallback(() => {

    if (!piece || gameOver) return;

 

    const rotated = piece.shape[0].map((_, i) =>

      piece.shape.map(row => row[i]).reverse()

    );

 

    const newPiece = {

      ...piece,

      shape: rotated

    };

 

    if (!hasCollision(newPiece, board)) {

      setPiece(newPiece);

    }

  }, [piece, board, gameOver, hasCollision]);

 

  // Get ghost piece position

  const getGhostPosition = useCallback((currentPiece) => {

    if (!currentPiece) return null;

   

    let ghostY = currentPiece.y;

    while (!hasCollision(

      { ...currentPiece, y: ghostY + 1 },

      board

    )) {

      ghostY++;

    }

   

    return {

      ...currentPiece,

      y: ghostY

    };

  }, [board]);

 

  // Handle keyboard controls

  useEffect(() => {

    const handleKeyPress = (e) => {

      if (!gameStarted || gameOver) return;

 

      switch (e.code) {

        case 'ArrowLeft':

          movePiece(-1, 0);

          break;

        case 'ArrowRight':

          movePiece(1, 0);

          break;

        case 'ArrowDown':

          movePiece(0, 1);

          break;

        case 'ArrowUp':

          rotatePiece();

          break;

        case 'Space':

          hardDrop();

          break;

        case 'KeyG': // G 키로 고스트 블록 토글

          setShowGhost(prev => !prev);

          break;

      }

    };

 

    window.addEventListener('keydown', handleKeyPress);

    return () => window.removeEventListener('keydown', handleKeyPress);

  }, [gameStarted, gameOver, movePiece, rotatePiece, hardDrop, setShowGhost]);

 

  // Initialize game

  useEffect(() => {

    if (!piece && !gameOver) {

      const newPiece = createNewPiece();

      const newNextPiece = createNewPiece();

      setPiece(newPiece);

      setNextPiece(newNextPiece);

    }

  }, [piece, gameOver]);

 

  // Game loop

  useEffect(() => {

    let dropTimer;

 

    if (gameStarted && !gameOver) {

      if (!piece) {

        const newPiece = createNewPiece();

        const newNextPiece = createNewPiece();

        setPiece(newPiece);

        setNextPiece(newNextPiece);

      }

 

      dropTimer = setInterval(() => {

        movePiece(0, 1);

      }, baseInterval * (100 / speed)); // 속도에 따라 인터벌 조정

    }

 

    return () => clearInterval(dropTimer);

  }, [gameStarted, gameOver, piece, movePiece, speed]);

 

  // Render next piece preview

  const renderNextPiece = () => {

    if (!nextPiece) return null;

 

    const grid = Array(4).fill().map(() => Array(4).fill(null));

   

    // Center the piece in the preview grid

    const offsetY = Math.floor((4 - nextPiece.shape.length) / 2);

    const offsetX = Math.floor((4 - nextPiece.shape[0].length) / 2);

   

    nextPiece.shape.forEach((row, y) => {

      row.forEach((cell, x) => {

        if (cell) {

          grid[y + offsetY][x + offsetX] = nextPiece.color;

        }

      });

    });

 

    return grid.map((row, y) =>

      row.map((cell, x) => (

        <Cell

          key={`next-${y}-${x}`}

          color={cell || '#333'}

        />

      ))

    );

  };

 

  // Render game board with current piece

  const renderBoard = () => {

    const displayBoard = board.map(row => [...row]);

 

    if (piece) {

      // Add ghost piece first

      const ghostPiece = getGhostPosition(piece);

      if (ghostPiece && showGhost) {

        ghostPiece.shape.forEach((row, dy) => {

          row.forEach((cell, dx) => {

            if (cell === 1) {

              const y = ghostPiece.y + dy;

              const x = ghostPiece.x + dx;

              if (y >= 0 && y < BOARD_HEIGHT && x >= 0 && x < BOARD_WIDTH) {

                displayBoard[y][x] = { color: piece.color, ghost: true };

              }

            }

          });

        });

      }

 

      // Add current piece on top

      piece.shape.forEach((row, dy) => {

        row.forEach((cell, dx) => {

          if (cell === 1) {

            const y = piece.y + dy;

            const x = piece.x + dx;

            if (y >= 0 && y < BOARD_HEIGHT && x >= 0 && x < BOARD_WIDTH) {

              displayBoard[y][x] = { color: piece.color, ghost: false };

            }

          }

        });

      });

    }

 

    return displayBoard.map((row, y) =>

      row.map((cell, x) => (

        <Cell

          key={`${y}-${x}`}

          color={typeof cell === 'string' ? cell : cell?.color}

          ghost={cell?.ghost}

          isClearing={clearingRows.includes(y)}

        />

      ))

    );

  };

 

  // Start new game

  const startGame = () => {

    setBoard(createEmptyBoard());

    setPiece(null);

    setNextPiece(null);

    setScore(0);

    setGameOver(false);

    setGameStarted(true);

    setSpeed(120); // 시작 속도를 120%로 설정

  };

 

  return (

    <Container>

      <GameWrapper>

        <GameInfo>

          <h1>Tetris</h1>

          <div className="status-info">

            <span>Score:</span>

            <span>{score}</span>

          </div>

          <div className="status-info">

            <span>Speed:</span>

            <span>{speed}%</span>

          </div>

          <div className="status-info">

            <span>Ghost:</span>

            <span>{showGhost ? 'ON' : 'OFF'}</span>

          </div>

          <div className="next-piece">

            <h2>Next Block</h2>

            <div className="preview">

              {renderNextPiece()}

            </div>

          </div>

        </GameInfo>

       

        <div>

          <GameBoard>{renderBoard()}</GameBoard>

         

          {!gameStarted ? (

            <Button onClick={startGame}>Start Game</Button>

          ) : gameOver ? (

            <div style={{ textAlign: 'center', marginTop: '10px' }}>

              <h2>Game Over!</h2>

              <Button onClick={startGame}>Play Again</Button>

            </div>

          ) : (

            <Controls>

              <Button onClick={() => movePiece(-1, 0)}>←</Button>

              <Button onClick={() => rotatePiece()}>↻</Button>

              <Button onClick={() => movePiece(1, 0)}>→</Button>

              <div></div>

              <Button onClick={() => movePiece(0, 1)}>↓</Button>

              <Button onClick={hardDrop}>⤓</Button>

            </Controls>

          )}

        </div>

      </GameWrapper>

    </Container>

  );

}

[/code]

댓글 작성

댓글을 작성하시려면 로그인이 필요합니다.

로그인하기

댓글 2개

11개월 전

약간의 버그가 있는거같지만 2~3시간에 만드셨다면 진짜 엄청나긴하네요.

11개월 전

@멸천도 아 이틀정도 걸리셨다고 하셨었군요.

잘못보고 집중시간 2~3시간을 총 작업시간으로 인식했네요.

어쨌든 AI의 발전이 대단한거같습니다.

 

게시글 목록

번호 제목
12
11
7
6
4
3
2
1