Files
personal_website/src/pages/Tetris.tsx
T
gpt-engineer-app[bot] b70c67b5b2 Changes
2026-01-02 22:01:17 +00:00

375 lines
18 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 } 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)[][];
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))' },
};
type TetrominoKey = keyof typeof TETROMINOS;
interface Piece {
shape: number[][];
color: string;
x: number;
y: number;
}
const createBoard = (): Board => Array.from({ length: BOARD_HEIGHT }, () => Array(BOARD_WIDTH).fill(null));
const randomTetromino = (): Piece => {
const keys = Object.keys(TETROMINOS) as TetrominoKey[];
const key = keys[Math.floor(Math.random() * keys.length)];
const tetromino = TETROMINOS[key];
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();
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);
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 24;
const isMobile = window.innerWidth < 768;
if (isMobile) {
const maxWidth = window.innerWidth - 40;
// Reserve space for header + fixed controls dock on mobile
const maxHeight = window.innerHeight - 440;
return Math.min(Math.floor(maxWidth / BOARD_WIDTH), Math.floor(maxHeight / BOARD_HEIGHT), 22);
}
return isFullscreen ? 30 : 24;
}, [isFullscreen]);
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]);
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));
}, []);
// Check score achievements
useEffect(() => {
if (score > 0) {
checkGameScoreAchievements('tetris', score);
if (score >= MAX_SCORE) unlockMaxScore();
}
}, [score, checkGameScoreAchievements, unlockMaxScore]);
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 };
}, []);
const moveDown = useCallback(() => {
if (gameOver || gameComplete || isPaused || !gameStarted) 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]);
const moveLeft = useCallback(() => {
if (gameOver || gameComplete || isPaused || !gameStarted) return;
const newPiece = { ...piece, x: piece.x - 1 };
if (isValidMove(newPiece, board)) { setPiece(newPiece); playSound('hover'); }
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound]);
const moveRight = useCallback(() => {
if (gameOver || gameComplete || isPaused || !gameStarted) return;
const newPiece = { ...piece, x: piece.x + 1 };
if (isValidMove(newPiece, board)) { setPiece(newPiece); playSound('hover'); }
}, [piece, board, gameOver, gameComplete, isPaused, gameStarted, isValidMove, playSound]);
const rotatePiece = useCallback(() => {
if (gameOver || gameComplete || isPaused || !gameStarted) 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]);
const hardDrop = useCallback(() => {
if (gameOver || gameComplete || isPaused || !gameStarted) 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]);
const startGame = () => {
setBoard(createBoard());
setPiece(randomTetromino());
setNextPiece(randomTetromino());
setScore(0);
setLines(0);
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');
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!gameStarted) return;
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;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [moveLeft, moveRight, moveDown, rotatePiece, hardDrop, gameStarted]);
useEffect(() => {
if (!gameStarted || gameOver || gameComplete || isPaused) return;
const interval = setInterval(moveDown, TICK_SPEED);
return () => clearInterval(interval);
}, [moveDown, gameStarted, gameOver, gameComplete, isPaused]);
const renderBoard = () => {
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>
));
};
if (showGlitchCrash) return <GlitchCrash onComplete={() => window.location.reload()} />;
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">
<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>
<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' : ''}`}>
<div className="border-2 border-primary box-glow p-1 bg-background/80">{renderBoard()}</div>
{!isMobile && (
<div className="flex flex-col gap-2 min-w-[140px]">
<div className="border border-primary/50 p-3 bg-background/50 flex flex-col items-center justify-center min-h-[80px]">
<p className="font-pixel text-[10px] text-foreground/60 w-full mb-2">NEXT</p>
<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-3 h-3 ${val ? 'bg-primary box-glow' : 'bg-transparent'}`} />
))}
</div>
))}
</div>
</div>
<div className="border border-primary/50 p-3 bg-background/50"><p className="font-pixel text-[10px] text-foreground/60">SCORE</p><p className="font-minecraft text-xl text-primary text-glow">{score.toLocaleString()}</p></div>
<div className="border border-primary/50 p-3 bg-background/50"><p className="font-pixel text-[10px] text-foreground/60">HIGH SCORE</p><p className="font-minecraft text-lg text-primary text-glow">{highScore.toLocaleString()}</p><p className="font-pixel text-[8px] text-foreground/30">max: 4,294,967,296</p></div>
<div className="border border-primary/50 p-3 bg-background/50"><p className="font-pixel text-[10px] text-foreground/60">LINES</p><p className="font-minecraft text-lg text-primary text-glow">{lines}</p></div>
<div className="border border-primary/50 p-3 bg-background/50"><p className="font-pixel text-[10px] text-foreground/60 mb-1">CONTROLS</p><p className="font-pixel text-[10px] text-foreground/80"> / WASD</p><p className="font-pixel text-[10px] text-foreground/80">Space: Drop</p><p className="font-pixel text-[10px] text-foreground/80">P: Pause</p></div>
{!gameStarted || gameOver || gameComplete ? (
<button onClick={startGame} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all box-glow">{gameComplete ? 'PERFECT!' : gameOver ? 'RETRY' : 'START'}</button>
) : (
<button onClick={togglePause} className="font-minecraft text-sm py-2 px-4 border border-primary bg-primary/20 text-primary hover:bg-primary/40 transition-all">{isPaused ? 'RESUME' : 'PAUSE'}</button>
)}
</div>
)}
{isMobile && (
<>
<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} 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>
{!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">{gameComplete ? 'GAME COMPLETE!' : gameOver ? 'GAME OVER' : 'PAUSED'}</h2>
{(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></>)}
<button onClick={(gameOver || gameComplete) ? startGame : () => 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;