windsurf 에디터를 이용해 만든 테트리스가 추가 되었습니다. 정보
windsurf 에디터를 이용해 만든 테트리스가 추가 되었습니다.
본문
https://sir.kr/javascript/tetris/
총 이틀에 걸쳐서 만들었고
집중한 시간은 한 2~3시간 되는것 같습니다.
생산속도가 엄청 빨라 지겠네요.
g 키를 누르면 고스트 블록을 켜고, 끄고 할수 있습니다.
(현재 떨어지고 있는 블록이 땅에 닿았을 때 어디에 위치하게 될지 미리 보여주는 기능)
src/app/tetris/page.js
'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>
);
}
추천
3
3
댓글 2개
약간의 버그가 있는거같지만 2~3시간에 만드셨다면 진짜 엄청나긴하네요.
@멸천도 아 이틀정도 걸리셨다고 하셨었군요.
잘못보고 집중시간 2~3시간을 총 작업시간으로 인식했네요.
어쨌든 AI의 발전이 대단한거같습니다.