import { useState, useEffect, useCallback, useRef } from 'react'; import { motion } from 'framer-motion'; import { useSettings } from '@/contexts/SettingsContext'; import { useAchievements } from '@/contexts/AchievementsContext'; import { Link } from 'react-router-dom'; import GlitchCrash from '@/components/GlitchCrash'; import { Maximize2, Minimize2, Users, User } from 'lucide-react'; import { useBrowserFullscreen } from '@/hooks/useGameDimensions'; import GameTouchButton from '@/components/GameTouchButton'; import { MobileControlsDock, MobileControlsSpacer } from '@/components/MobileControlsDock'; const BOARD_WIDTH = 10; const BOARD_HEIGHT = 20; const TICK_SPEED = 500; const HIGHSCORE_KEY = 'tetris-highscore'; const MAX_SCORE = 4294967296; type Board = (string | null)[][]; type GameMode = '1p' | '2p'; const TETROMINOS = { I: { shape: [[1, 1, 1, 1]], color: 'hsl(var(--primary))' }, O: { shape: [[1, 1], [1, 1]], color: 'hsl(var(--primary))' }, T: { shape: [[0, 1, 0], [1, 1, 1]], color: 'hsl(var(--primary))' }, S: { shape: [[0, 1, 1], [1, 1, 0]], color: 'hsl(var(--primary))' }, Z: { shape: [[1, 1, 0], [0, 1, 1]], color: 'hsl(var(--primary))' }, J: { shape: [[1, 0, 0], [1, 1, 1]], color: 'hsl(var(--primary))' }, L: { shape: [[0, 0, 1], [1, 1, 1]], color: 'hsl(var(--primary))' }, }; const TETROMINOS_P2 = { I: { shape: [[1, 1, 1, 1]], color: 'hsl(280 70% 50%)' }, O: { shape: [[1, 1], [1, 1]], color: 'hsl(280 70% 50%)' }, T: { shape: [[0, 1, 0], [1, 1, 1]], color: 'hsl(280 70% 50%)' }, S: { shape: [[0, 1, 1], [1, 1, 0]], color: 'hsl(280 70% 50%)' }, Z: { shape: [[1, 1, 0], [0, 1, 1]], color: 'hsl(280 70% 50%)' }, J: { shape: [[1, 0, 0], [1, 1, 1]], color: 'hsl(280 70% 50%)' }, L: { shape: [[0, 0, 1], [1, 1, 1]], color: 'hsl(280 70% 50%)' }, }; type TetrominoKey = keyof typeof TETROMINOS; interface Piece { shape: number[][]; color: string; x: number; y: number; } interface PlayerState { board: Board; piece: Piece; nextPiece: Piece; score: number; lines: number; gameOver: boolean; } const createBoard = (): Board => Array.from({ length: BOARD_HEIGHT }, () => Array(BOARD_WIDTH).fill(null)); const randomTetromino = (isP2 = false, excludeShape?: number[][]): Piece => { const keys = Object.keys(TETROMINOS) as TetrominoKey[]; let key = keys[Math.floor(Math.random() * keys.length)]; let tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key]; // Ensure we don't generate the same shape twice in a row if (excludeShape && JSON.stringify(tetromino.shape) === JSON.stringify(excludeShape)) { // Try up to 7 times to get a different shape for (let attempts = 0; attempts < 7; attempts++) { key = keys[Math.floor(Math.random() * keys.length)]; tetromino = isP2 ? TETROMINOS_P2[key] : TETROMINOS[key]; if (JSON.stringify(tetromino.shape) !== JSON.stringify(excludeShape)) break; } } return { shape: tetromino.shape, color: tetromino.color, x: Math.floor(BOARD_WIDTH / 2) - Math.floor(tetromino.shape[0].length / 2), y: 0 }; }; const rotate = (matrix: number[][]): number[][] => { const rows = matrix.length; const cols = matrix[0].length; const result: number[][] = []; for (let col = 0; col < cols; col++) { const newRow: number[] = []; for (let row = rows - 1; row >= 0; row--) newRow.push(matrix[row][col]); result.push(newRow); } return result; }; const Tetris = () => { const { playSound } = useSettings(); const { checkGameScoreAchievements, unlockMaxScore } = useAchievements(); // Mode selection const [gameMode, setGameMode] = useState(null); // 1P state const [board, setBoard] = useState(createBoard); const [piece, setPiece] = useState(randomTetromino); const [nextPiece, setNextPiece] = useState(randomTetromino); const [score, setScore] = useState(0); const [highScore, setHighScore] = useState(0); const [lines, setLines] = useState(0); // 2P state const [player1, setPlayer1] = useState(() => { const p1Piece = randomTetromino(); const p1Next = randomTetromino(false, p1Piece.shape); return { board: createBoard(), piece: p1Piece, nextPiece: p1Next, score: 0, lines: 0, gameOver: false, }; }); const [player2, setPlayer2] = useState(() => { const p2Piece = randomTetromino(true); const p2Next = randomTetromino(true, p2Piece.shape); return { board: createBoard(), piece: p2Piece, nextPiece: p2Next, score: 0, lines: 0, gameOver: false, }; }); const [winner, setWinner] = useState(null); // Common state const [gameOver, setGameOver] = useState(false); const [gameComplete, setGameComplete] = useState(false); const [isPaused, setIsPaused] = useState(false); const [gameStarted, setGameStarted] = useState(false); const [showGlitchCrash, setShowGlitchCrash] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const gameRef = useRef(null); const { enterFullscreen, exitFullscreen } = useBrowserFullscreen(); const getCellSize = useCallback(() => { if (typeof window === 'undefined') return 28; const isMobile = window.innerWidth < 768; if (isMobile) { const maxWidth = window.innerWidth - 40; const maxHeight = window.innerHeight - 440; return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 24); } if (gameMode === '2p') { return isFullscreen ? 26 : 22; } return isFullscreen ? 34 : 28; }, [isFullscreen, gameMode]); const [cellSize, setCellSize] = useState(getCellSize); const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; useEffect(() => { const handleResize = () => setCellSize(getCellSize()); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [getCellSize]); useEffect(() => { setCellSize(getCellSize()); }, [isFullscreen, getCellSize, gameMode]); useEffect(() => { if (gameStarted && isMobile && !isFullscreen) { setIsFullscreen(true); enterFullscreen(); } }, [gameStarted, isMobile, isFullscreen, enterFullscreen]); const toggleFullscreen = async () => { if (!isFullscreen) { setIsFullscreen(true); await enterFullscreen(); } else { setIsFullscreen(false); await exitFullscreen(); } playSound('click'); }; useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape' && isFullscreen) { setIsFullscreen(false); exitFullscreen(); } }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); }, [isFullscreen, exitFullscreen]); useEffect(() => { const saved = localStorage.getItem(HIGHSCORE_KEY); if (saved) setHighScore(Math.min(parseInt(saved, 10), MAX_SCORE)); }, []); useEffect(() => { if (score > 0 && gameMode === '1p') { checkGameScoreAchievements('tetris', score); if (score >= MAX_SCORE) unlockMaxScore(); } }, [score, checkGameScoreAchievements, unlockMaxScore, gameMode]); const isValidMove = useCallback((newPiece: Piece, currentBoard: Board): boolean => { for (let y = 0; y < newPiece.shape.length; y++) { for (let x = 0; x < newPiece.shape[y].length; x++) { if (newPiece.shape[y][x]) { const newX = newPiece.x + x; const newY = newPiece.y + y; if (newX < 0 || newX >= BOARD_WIDTH || newY >= BOARD_HEIGHT) return false; if (newY >= 0 && currentBoard[newY][newX]) return false; } } } return true; }, []); const mergePiece = useCallback((currentBoard: Board, currentPiece: Piece): Board => { const newBoard = currentBoard.map(row => [...row]); for (let y = 0; y < currentPiece.shape.length; y++) { for (let x = 0; x < currentPiece.shape[y].length; x++) { if (currentPiece.shape[y][x]) { const boardY = currentPiece.y + y; const boardX = currentPiece.x + x; if (boardY >= 0) newBoard[boardY][boardX] = currentPiece.color; } } } return newBoard; }, []); const clearLines = useCallback((currentBoard: Board): { board: Board; cleared: number } => { const newBoard = currentBoard.filter(row => row.some(cell => !cell)); const cleared = BOARD_HEIGHT - newBoard.length; while (newBoard.length < BOARD_HEIGHT) newBoard.unshift(Array(BOARD_WIDTH).fill(null)); return { board: newBoard, cleared }; }, []); // 1P movement functions const moveDown = useCallback(() => { if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return; const newPiece = { ...piece, y: piece.y + 1 }; if (isValidMove(newPiece, board)) { setPiece(newPiece); } else { const mergedBoard = mergePiece(board, piece); const { board: clearedBoard, cleared } = clearLines(mergedBoard); if (cleared > 0) { playSound('success'); setLines(prev => prev + cleared); setScore(prev => { const newScore = Math.min(prev + cleared * 100 * cleared, MAX_SCORE); if (newScore >= MAX_SCORE) setShowGlitchCrash(true); if (newScore > highScore) { setHighScore(newScore); localStorage.setItem(HIGHSCORE_KEY, newScore.toString()); } return newScore; }); } else { playSound('click'); } setBoard(clearedBoard); const newTetromino = nextPiece; setNextPiece(randomTetromino()); if (!isValidMove(newTetromino, clearedBoard)) { setGameOver(true); playSound('error'); } else { setPiece(newTetromino); } } }, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, mergePiece, clearLines, playSound, highScore, nextPiece, gameMode]); const moveLeft = useCallback(() => { if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return; const newPiece = { ...piece, x: piece.x - 1 }; if (isValidMove(newPiece, board)) { setPiece(newPiece); playSound('hover'); } }, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound, gameMode]); const moveRight = useCallback(() => { if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return; const newPiece = { ...piece, x: piece.x + 1 }; if (isValidMove(newPiece, board)) { setPiece(newPiece); playSound('hover'); } }, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound, gameMode]); const rotatePiece = useCallback(() => { if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return; const rotatedShape = rotate(piece.shape); const newPiece = { ...piece, shape: rotatedShape }; if (isValidMove(newPiece, board)) { setPiece(newPiece); playSound('hover'); } }, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound, gameMode]); const hardDrop = useCallback(() => { if (gameOver || gameComplete || isPaused || !gameStarted || gameMode !== '1p') return; let newPiece = { ...piece }; while (isValidMove({ ...newPiece, y: newPiece.y + 1 }, board)) newPiece.y++; setPiece(newPiece); playSound('click'); }, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound, gameMode]); const startGame = (mode: GameMode) => { setGameMode(mode); if (mode === '1p') { setBoard(createBoard()); setPiece(randomTetromino()); setNextPiece(randomTetromino()); setScore(0); setLines(0); } else { setPlayer1({ board: createBoard(), piece: randomTetromino(), nextPiece: randomTetromino(), score: 0, lines: 0, gameOver: false, }); setPlayer2({ board: createBoard(), piece: randomTetromino(true), nextPiece: randomTetromino(true), score: 0, lines: 0, gameOver: false, }); setWinner(null); } setGameOver(false); setGameComplete(false); setIsPaused(false); setGameStarted(true); playSound('success'); gameRef.current?.focus(); }; const togglePause = () => { if (!gameStarted || gameOver || gameComplete) return; setIsPaused(prev => !prev); playSound('click'); }; // 2P movement helpers const movePlayer = useCallback((playerNum: 1 | 2, action: 'left' | 'right' | 'down' | 'rotate' | 'drop') => { if (gameOver || isPaused || !gameStarted || gameMode !== '2p') return; const setPlayer = playerNum === 1 ? setPlayer1 : setPlayer2; const player = playerNum === 1 ? player1 : player2; const isP2 = playerNum === 2; if (player.gameOver) return; setPlayer(prev => { let newPiece = { ...prev.piece }; switch (action) { case 'left': newPiece.x--; if (!isValidMove(newPiece, prev.board)) return prev; playSound('hover'); return { ...prev, piece: newPiece }; case 'right': newPiece.x++; if (!isValidMove(newPiece, prev.board)) return prev; playSound('hover'); return { ...prev, piece: newPiece }; case 'rotate': newPiece.shape = rotate(prev.piece.shape); if (!isValidMove(newPiece, prev.board)) return prev; playSound('hover'); return { ...prev, piece: newPiece }; case 'drop': while (isValidMove({ ...newPiece, y: newPiece.y + 1 }, prev.board)) newPiece.y++; playSound('click'); return { ...prev, piece: newPiece }; case 'down': newPiece.y++; if (isValidMove(newPiece, prev.board)) { return { ...prev, piece: newPiece }; } else { const mergedBoard = mergePiece(prev.board, prev.piece); const { board: clearedBoard, cleared } = clearLines(mergedBoard); const newScore = prev.score + (cleared > 0 ? cleared * 100 * cleared : 0); const newLines = prev.lines + cleared; if (cleared > 0) playSound('success'); else playSound('click'); const newTetromino = prev.nextPiece; const nextNext = randomTetromino(isP2, prev.nextPiece.shape); if (!isValidMove(newTetromino, clearedBoard)) { playSound('error'); return { ...prev, board: clearedBoard, score: newScore, lines: newLines, gameOver: true }; } return { ...prev, board: clearedBoard, piece: newTetromino, nextPiece: nextNext, score: newScore, lines: newLines, }; } } return prev; }); }, [gameOver, isPaused, gameStarted, gameMode, player1, player2, isValidMove, mergePiece, clearLines, playSound]); // Check 2P win condition useEffect(() => { if (gameMode !== '2p' || !gameStarted) return; if (player1.gameOver && player2.gameOver) { setGameOver(true); setWinner(player1.score > player2.score ? 'P1 Wins!' : player2.score > player1.score ? 'P2 Wins!' : 'Draw!'); } else if (player1.gameOver) { setGameOver(true); setWinner('P2 Wins!'); } else if (player2.gameOver) { setGameOver(true); setWinner('P1 Wins!'); } }, [player1.gameOver, player2.gameOver, player1.score, player2.score, gameMode, gameStarted]); // Keyboard handler useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!gameStarted) return; if (gameMode === '1p') { switch (e.key) { case 'ArrowLeft': case 'a': e.preventDefault(); moveLeft(); break; case 'ArrowRight': case 'd': e.preventDefault(); moveRight(); break; case 'ArrowDown': case 's': e.preventDefault(); moveDown(); break; case 'ArrowUp': case 'w': e.preventDefault(); rotatePiece(); break; case ' ': e.preventDefault(); hardDrop(); break; case 'p': e.preventDefault(); togglePause(); break; } } else { // P1: WASD + Q for drop if (e.key === 'w' || e.key === 'W') { e.preventDefault(); movePlayer(1, 'rotate'); } if (e.key === 'a' || e.key === 'A') { e.preventDefault(); movePlayer(1, 'left'); } if (e.key === 's' || e.key === 'S') { e.preventDefault(); movePlayer(1, 'down'); } if (e.key === 'd' || e.key === 'D') { e.preventDefault(); movePlayer(1, 'right'); } if (e.key === 'q' || e.key === 'Q') { e.preventDefault(); movePlayer(1, 'drop'); } // P2: Arrows + / for drop if (e.key === 'ArrowUp') { e.preventDefault(); movePlayer(2, 'rotate'); } if (e.key === 'ArrowLeft') { e.preventDefault(); movePlayer(2, 'left'); } if (e.key === 'ArrowDown') { e.preventDefault(); movePlayer(2, 'down'); } if (e.key === 'ArrowRight') { e.preventDefault(); movePlayer(2, 'right'); } if (e.key === '/') { e.preventDefault(); movePlayer(2, 'drop'); } if (e.key === 'p' || e.key === 'P') { e.preventDefault(); togglePause(); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [moveLeft, moveRight, moveDown, rotatePiece, hardDrop, gameStarted, gameMode, movePlayer]); // 1P tick useEffect(() => { if (!gameStarted || gameOver || gameComplete || isPaused || gameMode !== '1p') return; const interval = setInterval(moveDown, TICK_SPEED); return () => clearInterval(interval); }, [moveDown, gameStarted, gameOver, gameComplete, isPaused, gameMode]); // 2P tick useEffect(() => { if (!gameStarted || gameOver || isPaused || gameMode !== '2p') return; const interval = setInterval(() => { if (!player1.gameOver) movePlayer(1, 'down'); if (!player2.gameOver) movePlayer(2, 'down'); }, TICK_SPEED); return () => clearInterval(interval); }, [gameStarted, gameOver, isPaused, gameMode]); const renderBoard1P = () => { const displayBoard = board.map(row => [...row]); if (gameStarted && !gameOver && !gameComplete) { for (let y = 0; y < piece.shape.length; y++) { for (let x = 0; x < piece.shape[y].length; x++) { if (piece.shape[y][x]) { const boardY = piece.y + y; const boardX = piece.x + x; if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) displayBoard[boardY][boardX] = piece.color; } } } } return displayBoard.map((row, y) => (
{row.map((cell, x) => (
))}
)); }; const renderBoard2P = (player: PlayerState, borderColor: string) => { const displayBoard = player.board.map(row => [...row]); if (gameStarted && !player.gameOver) { for (let y = 0; y < player.piece.shape.length; y++) { for (let x = 0; x < player.piece.shape[y].length; x++) { if (player.piece.shape[y][x]) { const boardY = player.piece.y + y; const boardX = player.piece.x + x; if (boardY >= 0 && boardY < BOARD_HEIGHT && boardX >= 0 && boardX < BOARD_WIDTH) displayBoard[boardY][boardX] = player.piece.color; } } } } return displayBoard.map((row, y) => (
{row.map((cell, x) => (
))}
)); }; if (showGlitchCrash) return window.location.reload()} />; // Mode selection screen if (!gameMode) { return (
{'<'} Back

Tetris

Select game mode

{!isMobile && ( )}
{isMobile && (

2 Player mode requires a keyboard and is not available on mobile devices

)}

HIGH SCORE: {highScore.toLocaleString()}

); } return (

Tetris {gameMode === '2p' && 2P}

{gameMode === '1p' ? ( <>
{renderBoard1P()}
{!isMobile && (
{/* Next Piece Preview */}

Next

{nextPiece.shape.map((row, y) => (
{row.map((val, x) => (
))}
))}
{/* Score Panel */}

Score

{score.toLocaleString()}

{/* High Score */}

High Score

{highScore.toLocaleString()}

max: 4,294,967,296

{/* Lines */}

Lines

{lines}

{/* Controls */}

Controls

← → ↑ ↓ / WASD

Space: Hard Drop

P: Pause

{/* Action Button */} {!gameStarted || gameOver || gameComplete ? ( ) : ( )}
)} ) : ( <> {/* P1 Board */}
P1 (WASD/Q)
{renderBoard2P(player1, 'border-primary/20')}

SCORE

{player1.score}

LINES

{player1.lines}

{player1.gameOver && GAME OVER}
{/* P1 Next Piece */}

Next

{player1.nextPiece.shape.map((row, y) => (
{row.map((val, x) => (
))}
))}
{/* Center controls */}

CONTROLS

P1: WASD Q

P2: ↑↓←→ /

P: Pause

{!gameStarted || gameOver ? ( ) : ( )}
{/* P2 Board */}
P2 (↑↓←→/)
{renderBoard2P(player2, 'border-purple-500/20')}

SCORE

{player2.score}

LINES

{player2.lines}

{player2.gameOver && GAME OVER}
{/* P2 Next Piece */}

Next

{player2.nextPiece.shape.map((row, y) => (
{row.map((val, x) => (
))}
))}
)} {isMobile && gameMode === '1p' && ( <>

SCORE

{score.toLocaleString()}

HIGH

{highScore.toLocaleString()}

LINES

{lines}

{gameStarted && !gameOver && !gameComplete ? (
DROP
) : ( )}
)}
{/* Overlays */} {!isMobile && (gameOver || isPaused || gameComplete) && gameStarted && (

{gameMode === '2p' && gameOver ? winner : gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}

{gameMode === '1p' && (gameOver || gameComplete) && ( <>

Final Score: {score.toLocaleString()}

Lines: {lines}

)} {gameMode === '2p' && gameOver && (

P1 Score

{player1.score}

P2 Score

{player2.score}

)}
)} ); }; export default Tetris;