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

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

· 11개월 전 · 426 · 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의 발전이 대단한거같습니다.

 

게시글 목록

번호 제목
106
102
98
97
90
87
86
81
78
77
76
68
67
66
61
57
56
52
51
50
49
47
46
45
37
29
27
19
18
13