815 lines
36 KiB
TypeScript
815 lines
36 KiB
TypeScript
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<GameMode | null>(null);
|
|
|
|
// 1P state
|
|
const [board, setBoard] = useState<Board>(createBoard);
|
|
const [piece, setPiece] = useState<Piece>(randomTetromino);
|
|
const [nextPiece, setNextPiece] = useState<Piece>(randomTetromino);
|
|
const [score, setScore] = useState(0);
|
|
const [highScore, setHighScore] = useState(0);
|
|
const [lines, setLines] = useState(0);
|
|
|
|
// 2P state
|
|
const [player1, setPlayer1] = useState<PlayerState>(() => {
|
|
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<PlayerState>(() => {
|
|
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<string | null>(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<HTMLDivElement>(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) => (
|
|
<div key={y} className="flex">
|
|
{row.map((cell, x) => (
|
|
<div key={`${y}-${x}`} className={`border border-primary/20 transition-colors duration-100 ${cell ? 'bg-primary box-glow' : 'bg-background/50'}`} style={{ width: cellSize, height: cellSize }} />
|
|
))}
|
|
</div>
|
|
));
|
|
};
|
|
|
|
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) => (
|
|
<div key={y} className="flex">
|
|
{row.map((cell, x) => (
|
|
<div
|
|
key={`${y}-${x}`}
|
|
className={`border transition-colors duration-100 ${borderColor}`}
|
|
style={{
|
|
width: cellSize,
|
|
height: cellSize,
|
|
backgroundColor: cell || 'transparent',
|
|
boxShadow: cell ? `0 0 5px ${cell}` : 'none',
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
));
|
|
};
|
|
|
|
if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />;
|
|
|
|
// Mode selection screen
|
|
if (!gameMode) {
|
|
return (
|
|
<motion.div
|
|
ref={gameRef}
|
|
tabIndex={0}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{ duration: 0.5 }}
|
|
className="flex flex-col h-full"
|
|
>
|
|
<div className="flex items-center gap-4 mb-4">
|
|
<Link to="/games" className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</Link>
|
|
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">Tetris</h1>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col items-center justify-center gap-6">
|
|
<p className="font-pixel text-sm text-foreground/70">Select game mode</p>
|
|
|
|
<div className="flex gap-4">
|
|
<button
|
|
onClick={() => startGame('1p')}
|
|
className="flex flex-col items-center gap-2 p-6 border-2 border-primary/50 hover:border-primary bg-background/50 hover:bg-primary/10 transition-all"
|
|
>
|
|
<User size={32} className="text-primary" />
|
|
<span className="font-minecraft text-lg text-primary">1 Player</span>
|
|
<span className="font-pixel text-xs text-foreground/60">Solo mode</span>
|
|
</button>
|
|
|
|
{!isMobile && (
|
|
<button
|
|
onClick={() => startGame('2p')}
|
|
className="flex flex-col items-center gap-2 p-6 border-2 border-purple-500/50 hover:border-purple-500 bg-background/50 hover:bg-purple-500/10 transition-all"
|
|
>
|
|
<Users size={32} className="text-purple-400" />
|
|
<span className="font-minecraft text-lg text-purple-400">2 Players</span>
|
|
<span className="font-pixel text-xs text-foreground/60">Local versus</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{isMobile && (
|
|
<p className="font-pixel text-xs text-foreground/40 text-center max-w-xs">
|
|
2 Player mode requires a keyboard and is not available on mobile devices
|
|
</p>
|
|
)}
|
|
|
|
<div className="border border-primary/30 p-3 bg-background/30 mt-4">
|
|
<p className="font-pixel text-xs text-foreground/60">HIGH SCORE: <span className="text-primary">{highScore.toLocaleString()}</span></p>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<motion.div ref={gameRef} tabIndex={0} initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.5 }}
|
|
className={`flex flex-col outline-none ${isFullscreen ? 'fixed inset-0 z-50 bg-background p-4' : 'h-full'}`}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-4">
|
|
<button onClick={() => { setGameMode(null); setGameStarted(false); }} className="font-pixel text-sm text-foreground/50 hover:text-primary transition-colors">{'<'} Back</button>
|
|
<h1 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">
|
|
Tetris {gameMode === '2p' && <span className="text-purple-400">2P</span>}
|
|
</h1>
|
|
</div>
|
|
<button onClick={toggleFullscreen} className="p-2 border border-primary/50 hover:bg-primary/20 transition-colors" title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}>
|
|
{isFullscreen ? <Minimize2 size={16} className="text-primary" /> : <Maximize2 size={16} className="text-primary" />}
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`flex ${isMobile ? 'flex-col items-center' : 'flex-row gap-4'} flex-1 ${isFullscreen ? 'justify-center items-center' : 'justify-center items-start'}`}>
|
|
{gameMode === '1p' ? (
|
|
<>
|
|
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard1P()}</div>
|
|
{!isMobile && (
|
|
<div className="flex flex-col gap-3 min-w-[160px]">
|
|
{/* Next Piece Preview */}
|
|
<div className="border-2 border-primary/50 p-4 bg-background/60">
|
|
<p className="font-pixel text-[10px] text-foreground/50 uppercase tracking-wider mb-3">Next</p>
|
|
<div className="flex justify-center">
|
|
<div className="flex flex-col gap-1">
|
|
{nextPiece.shape.map((row, y) => (
|
|
<div key={y} className="flex gap-1">
|
|
{row.map((val, x) => (
|
|
<div key={`${y}-${x}`} className={`w-4 h-4 ${val ? 'bg-primary box-glow' : 'bg-transparent'}`} />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Score Panel */}
|
|
<div className="border-2 border-primary/50 p-4 bg-background/60">
|
|
<p className="font-pixel text-[10px] text-foreground/50 uppercase tracking-wider">Score</p>
|
|
<p className="font-minecraft text-2xl text-primary text-glow-strong">{score.toLocaleString()}</p>
|
|
</div>
|
|
|
|
{/* High Score */}
|
|
<div className="border border-primary/30 p-3 bg-background/40">
|
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider">High Score</p>
|
|
<p className="font-minecraft text-lg text-primary/80">{highScore.toLocaleString()}</p>
|
|
<p className="font-pixel text-[8px] text-foreground/30 mt-1">max: 4,294,967,296</p>
|
|
</div>
|
|
|
|
{/* Lines */}
|
|
<div className="border border-primary/30 p-3 bg-background/40">
|
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider">Lines</p>
|
|
<p className="font-minecraft text-xl text-primary">{lines}</p>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="border border-primary/20 p-3 bg-background/30">
|
|
<p className="font-pixel text-[10px] text-foreground/40 uppercase tracking-wider mb-2">Controls</p>
|
|
<div className="space-y-1">
|
|
<p className="font-pixel text-[10px] text-foreground/60">← → ↑ ↓ / WASD</p>
|
|
<p className="font-pixel text-[10px] text-foreground/60">Space: Hard Drop</p>
|
|
<p className="font-pixel text-[10px] text-foreground/60">P: Pause</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Button */}
|
|
{!gameStarted || gameOver || gameComplete ? (
|
|
<button onClick={() => startGame('1p')} className="font-minecraft text-sm py-3 px-4 border-2 border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
|
{gameComplete ? 'PERFECT!' : gameOver ? 'RETRY' : 'START GAME'}
|
|
</button>
|
|
) : (
|
|
<button onClick={togglePause} className="font-minecraft text-sm py-3 px-4 border-2 border-primary/60 bg-background/50 text-primary hover:bg-primary/20 transition-all">
|
|
{isPaused ? 'RESUME' : 'PAUSE'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* P1 Board */}
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-sm bg-primary" />
|
|
<span className="font-pixel text-xs text-primary">P1 (WASD/Q)</span>
|
|
</div>
|
|
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard2P(player1, 'border-primary/20')}</div>
|
|
<div className="flex gap-2 text-center">
|
|
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-primary">{player1.score}</p></div>
|
|
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-primary">{player1.lines}</p></div>
|
|
</div>
|
|
{player1.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
|
</div>
|
|
|
|
{/* P1 Next Piece */}
|
|
<div className="border-2 border-primary/50 p-4 bg-background/60">
|
|
<p className="font-pixel text-[10px] text-foreground/50 uppercase tracking-wider mb-3">Next</p>
|
|
<div className="flex justify-center">
|
|
<div className="flex flex-col gap-1">
|
|
{player1.nextPiece.shape.map((row, y) => (
|
|
<div key={y} className="flex gap-1">
|
|
{row.map((val, x) => (
|
|
<div key={`${y}-${x}`} className={`w-4 h-4 ${val ? 'bg-primary box-glow' : 'bg-transparent'}`} />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center controls */}
|
|
<div className="flex flex-col gap-2 min-w-[100px] items-center">
|
|
<div className="border border-primary/50 p-2 bg-background/50 text-center">
|
|
<p className="font-pixel text-[8px] text-foreground/60 mb-1">CONTROLS</p>
|
|
<p className="font-pixel text-[8px] text-primary">P1: WASD Q</p>
|
|
<p className="font-pixel text-[8px] text-purple-400">P2: ↑↓←→ /</p>
|
|
<p className="font-pixel text-[8px] text-foreground/60 mt-1">P: Pause</p>
|
|
</div>
|
|
{!gameStarted || gameOver ? (
|
|
<button onClick={() => startGame('2p')} className="font-minecraft text-xs py-2 px-3 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
|
{gameOver ? 'AGAIN' : 'START'}
|
|
</button>
|
|
) : (
|
|
<button onClick={togglePause} className="font-minecraft text-xs py-2 px-3 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all">
|
|
{isPaused ? 'RESUME' : 'PAUSE'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* P2 Board */}
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: 'hsl(280 70% 50%)' }} />
|
|
<span className="font-pixel text-xs text-purple-400">P2 (↑↓←→/)</span>
|
|
</div>
|
|
<div className="border-2 border-purple-500 p-1 bg-background/80" style={{ boxShadow: '0 0 10px hsl(280 70% 50% / 0.5)' }}>{renderBoard2P(player2, 'border-purple-500/20')}</div>
|
|
<div className="flex gap-2 text-center">
|
|
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-purple-400">{player2.score}</p></div>
|
|
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-purple-400">{player2.lines}</p></div>
|
|
</div>
|
|
{player2.gameOver && <span className="font-pixel text-xs text-destructive">GAME OVER</span>}
|
|
</div>
|
|
|
|
{/* P2 Next Piece */}
|
|
<div className="border-2 border-purple-500/50 p-4 bg-background/60">
|
|
<p className="font-pixel text-[10px] text-purple-400/70 uppercase tracking-wider mb-3">Next</p>
|
|
<div className="flex justify-center">
|
|
<div className="flex flex-col gap-1">
|
|
{player2.nextPiece.shape.map((row, y) => (
|
|
<div key={y} className="flex gap-1">
|
|
{row.map((val, x) => (
|
|
<div key={`${y}-${x}`} className={`w-4 h-4 ${val ? 'bg-purple-400' : 'bg-transparent'}`} style={{ boxShadow: val ? '0 0 6px hsl(280 70% 50%)' : 'none' }} />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{isMobile && gameMode === '1p' && (
|
|
<>
|
|
<MobileControlsSpacer />
|
|
<MobileControlsDock>
|
|
<div className="flex flex-col items-center gap-2">
|
|
<div className="flex gap-4 text-center">
|
|
<div><p className="font-pixel text-[8px] text-foreground/60">SCORE</p><p className="font-minecraft text-sm text-primary">{score.toLocaleString()}</p></div>
|
|
<div><p className="font-pixel text-[8px] text-foreground/60">HIGH</p><p className="font-minecraft text-sm text-primary">{highScore.toLocaleString()}</p></div>
|
|
<div><p className="font-pixel text-[8px] text-foreground/60">LINES</p><p className="font-minecraft text-sm text-primary">{lines}</p></div>
|
|
</div>
|
|
|
|
{gameStarted && !gameOver && !gameComplete ? (
|
|
<div className="grid grid-cols-3 gap-1 mt-2">
|
|
<div />
|
|
<GameTouchButton onAction={rotatePiece} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg" interval={200}>↑</GameTouchButton>
|
|
<div />
|
|
<GameTouchButton onAction={moveLeft} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">←</GameTouchButton>
|
|
<GameTouchButton onAction={hardDrop} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-[10px]" interval={500}>DROP</GameTouchButton>
|
|
<GameTouchButton onAction={moveRight} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">→</GameTouchButton>
|
|
<button onClick={togglePause} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-[10px] select-none">{isPaused ? '▶' : '❚❚'}</button>
|
|
<GameTouchButton onAction={moveDown} className="p-4 border border-primary/50 active:bg-primary/40 text-primary font-pixel text-lg">↓</GameTouchButton>
|
|
<div />
|
|
</div>
|
|
) : (
|
|
<button onClick={() => startGame('1p')} className="font-minecraft text-sm py-3 px-8 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">{gameComplete ? 'PERFECT!' : gameOver ? 'RETRY' : 'START'}</button>
|
|
)}
|
|
</div>
|
|
</MobileControlsDock>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Overlays */}
|
|
{!isMobile && (gameOver || isPaused || gameComplete) && gameStarted && (
|
|
<div className="fixed inset-0 bg-background/80 flex items-center justify-center z-50">
|
|
<div className="border-2 border-primary box-glow-strong p-6 bg-background text-center">
|
|
<h2 className="font-minecraft text-2xl text-primary text-glow-strong mb-3">
|
|
{gameMode === '2p' && gameOver ? winner : gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}
|
|
</h2>
|
|
{gameMode === '1p' && (gameOver || gameComplete) && (
|
|
<>
|
|
<p className="font-pixel text-sm text-foreground/80 mb-1">Final Score: {score.toLocaleString()}</p>
|
|
<p className="font-pixel text-xs text-foreground/60 mb-3">Lines: {lines}</p>
|
|
</>
|
|
)}
|
|
{gameMode === '2p' && gameOver && (
|
|
<div className="flex gap-4 justify-center mb-3">
|
|
<div>
|
|
<p className="font-pixel text-[10px] text-foreground/60">P1 Score</p>
|
|
<p className="font-minecraft text-lg text-primary">{player1.score}</p>
|
|
</div>
|
|
<div>
|
|
<p className="font-pixel text-[10px] text-foreground/60">P2 Score</p>
|
|
<p className="font-minecraft text-lg text-purple-400">{player2.score}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<button onClick={(gameOver || gameComplete) ? () => startGame(gameMode!) : () => setIsPaused(false)} className="font-minecraft text-sm py-2 px-6 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">
|
|
{(gameOver || gameComplete) ? 'PLAY AGAIN' : 'RESUME'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
export default Tetris; |