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 PADDLE_HEIGHT = 14; const BRICK_ROWS = 6; const BRICK_COLS = 10; const BRICK_HEIGHT = 20; const BRICK_GAP = 4; const MAX_SCORE = 4294967296; const HIGHSCORE_KEY = 'breakout-highscore'; interface Brick { x: number; y: number; width: number; height: number; alive: boolean; color: string; } const Breakout = () => { const { playSound } = useSettings(); const { checkGameScoreAchievements, unlockMaxScore } = useAchievements(); const [isFullscreen, setIsFullscreen] = useState(false); const [score, setScore] = useState(0); const [highScore, setHighScore] = useState(0); const [level, setLevel] = useState(1); const [lives, setLives] = useState(3); const [gameOver, setGameOver] = useState(false); const [gameStarted, setGameStarted] = useState(false); const [isPaused, setIsPaused] = useState(false); const [showGlitchCrash, setShowGlitchCrash] = useState(false); const canvasRef = useRef(null); const gameRef = useRef(null); const paddleXRef = useRef(160); const ballRef = useRef({ x: 200, y: 450, dx: 4, dy: -4 }); const bricksRef = useRef([]); const keysRef = useRef>(new Set()); const animationRef = useRef(); const { enterFullscreen, exitFullscreen } = useBrowserFullscreen(); const getCanvasSize = useCallback(() => { if (typeof window === 'undefined') return { width: 480, height: 580 }; const isMobile = window.innerWidth < 768; if (isMobile) { const maxWidth = window.innerWidth - 32; // Reserve space for header + fixed controls dock on mobile const maxHeight = window.innerHeight - 380; const aspectRatio = 480 / 580; let width = maxWidth; let height = width / aspectRatio; if (height > maxHeight) { height = maxHeight; width = height * aspectRatio; } return { width: Math.floor(width), height: Math.floor(height) }; } return isFullscreen ? { width: 600, height: 720 } : { width: 480, height: 580 }; }, [isFullscreen]); const [canvasSize, setCanvasSize] = useState(getCanvasSize); const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; useEffect(() => { const handleResize = () => setCanvasSize(getCanvasSize()); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [getCanvasSize]); useEffect(() => { setCanvasSize(getCanvasSize()); }, [isFullscreen, getCanvasSize]); const paddleWidth = isMobile ? 70 : (isFullscreen ? 100 : 90); const ballSize = isMobile ? 10 : (isFullscreen ? 14 : 12); 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('breakout', score); if (score >= MAX_SCORE) unlockMaxScore(); } }, [score, checkGameScoreAchievements, unlockMaxScore]); const initBricks = useCallback(() => { const bricks: Brick[] = []; const brickWidth = (canvasSize.width - (BRICK_COLS + 1) * BRICK_GAP) / BRICK_COLS; const colors = ['hsl(0 70% 50%)', 'hsl(30 70% 50%)', 'hsl(60 70% 50%)', 'hsl(120 70% 50%)', 'hsl(200 70% 50%)', 'hsl(280 70% 50%)']; for (let row = 0; row < BRICK_ROWS; row++) { for (let col = 0; col < BRICK_COLS; col++) { bricks.push({ x: BRICK_GAP + col * (brickWidth + BRICK_GAP), y: 60 + row * (BRICK_HEIGHT + BRICK_GAP), width: brickWidth, height: BRICK_HEIGHT, alive: true, color: colors[row % colors.length] }); } } return bricks; }, [canvasSize.width]); const resetBall = useCallback(() => { // Slower base speed (2.5 instead of 4), with gentler level scaling const speed = 2.5 + level * 0.3; ballRef.current = { x: canvasSize.width / 2, y: canvasSize.height - 60, dx: (Math.random() > 0.5 ? 1 : -1) * speed, dy: -speed }; paddleXRef.current = canvasSize.width / 2 - paddleWidth / 2; }, [canvasSize.width, canvasSize.height, paddleWidth, level]); const startGame = () => { setScore(0); setLevel(1); setLives(3); setGameOver(false); setIsPaused(false); setGameStarted(true); bricksRef.current = initBricks(); resetBall(); playSound('success'); gameRef.current?.focus(); }; const nextLevel = useCallback(() => { setLevel(prev => prev + 1); bricksRef.current = initBricks(); resetBall(); playSound('success'); }, [initBricks, resetBall, playSound]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (['ArrowLeft', 'ArrowRight', 'a', 'd'].includes(e.key)) { e.preventDefault(); keysRef.current.add(e.key); } if (e.key === 'p' && gameStarted && !gameOver) { setIsPaused(prev => !prev); playSound('click'); } }; const handleKeyUp = (e: KeyboardEvent) => { keysRef.current.delete(e.key); }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, [gameStarted, gameOver, playSound]); const handleTouchMove = useCallback((e: React.TouchEvent) => { if (!gameStarted || gameOver || isPaused) return; const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const touch = e.touches[0]; const x = touch.clientX - rect.left; paddleXRef.current = Math.max(0, Math.min(canvasSize.width - paddleWidth, x - paddleWidth / 2)); }, [gameStarted, gameOver, isPaused, canvasSize.width, paddleWidth]); const moveLeft = useCallback(() => { paddleXRef.current = Math.max(0, paddleXRef.current - 20); }, []); const moveRight = useCallback(() => { paddleXRef.current = Math.min(canvasSize.width - paddleWidth, paddleXRef.current + 20); }, [canvasSize.width, paddleWidth]); useEffect(() => { if (!gameStarted || gameOver || isPaused) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const gameLoop = () => { const paddleSpeed = isMobile ? 6 : (isFullscreen ? 10 : 8); if (keysRef.current.has('ArrowLeft') || keysRef.current.has('a')) paddleXRef.current = Math.max(0, paddleXRef.current - paddleSpeed); if (keysRef.current.has('ArrowRight') || keysRef.current.has('d')) paddleXRef.current = Math.min(canvasSize.width - paddleWidth, paddleXRef.current + paddleSpeed); const ball = ballRef.current; ball.x += ball.dx; ball.y += ball.dy; if (ball.x <= ballSize / 2 || ball.x >= canvasSize.width - ballSize / 2) { ball.dx = -ball.dx; playSound('hover'); } if (ball.y <= ballSize / 2) { ball.dy = -ball.dy; playSound('hover'); } const paddleY = canvasSize.height - 30; if (ball.y + ballSize / 2 >= paddleY && ball.y - ballSize / 2 <= paddleY + PADDLE_HEIGHT && ball.x >= paddleXRef.current && ball.x <= paddleXRef.current + paddleWidth) { const hitPos = (ball.x - paddleXRef.current) / paddleWidth; const angle = (hitPos - 0.5) * Math.PI * 0.7; const speed = Math.sqrt(ball.dx * ball.dx + ball.dy * ball.dy); ball.dx = Math.sin(angle) * speed; ball.dy = -Math.abs(Math.cos(angle) * speed); ball.y = paddleY - ballSize / 2; playSound('click'); } if (ball.y >= canvasSize.height) { setLives(prev => { const newLives = prev - 1; if (newLives <= 0) { setGameOver(true); playSound('error'); } else { resetBall(); playSound('error'); } return newLives; }); } let allDestroyed = true; for (const brick of bricksRef.current) { if (!brick.alive) continue; allDestroyed = false; if (ball.x + ballSize / 2 >= brick.x && ball.x - ballSize / 2 <= brick.x + brick.width && ball.y + ballSize / 2 >= brick.y && ball.y - ballSize / 2 <= brick.y + brick.height) { brick.alive = false; ball.dy = -ball.dy; setScore(prev => { const ns = Math.min(prev + 10, MAX_SCORE); if (ns >= MAX_SCORE) setShowGlitchCrash(true); if (ns > highScore) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } return ns; }); playSound('success'); break; } } if (allDestroyed && bricksRef.current.length > 0) nextLevel(); const computedStyle = getComputedStyle(document.documentElement); const primaryHsl = computedStyle.getPropertyValue('--primary').trim(); const primaryColor = `hsl(${primaryHsl})`; ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, canvasSize.width, canvasSize.height); for (const brick of bricksRef.current) { if (!brick.alive) continue; ctx.fillStyle = brick.color; ctx.fillRect(brick.x, brick.y, brick.width, brick.height); ctx.strokeStyle = primaryColor; ctx.globalAlpha = 0.5; ctx.strokeRect(brick.x, brick.y, brick.width, brick.height); ctx.globalAlpha = 1; } ctx.fillStyle = primaryColor; ctx.shadowColor = primaryColor; ctx.shadowBlur = 10; ctx.fillRect(paddleXRef.current, canvasSize.height - 30, paddleWidth, PADDLE_HEIGHT); ctx.shadowBlur = 0; ctx.beginPath(); ctx.arc(ball.x, ball.y, ballSize / 2, 0, Math.PI * 2); ctx.fillStyle = primaryColor; ctx.shadowColor = primaryColor; ctx.shadowBlur = 15; ctx.fill(); ctx.shadowBlur = 0; ctx.strokeStyle = primaryColor; ctx.globalAlpha = 0.5; ctx.lineWidth = 2; ctx.strokeRect(0, 0, canvasSize.width, canvasSize.height); ctx.globalAlpha = 1; animationRef.current = requestAnimationFrame(gameLoop); }; animationRef.current = requestAnimationFrame(gameLoop); return () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); }; }, [gameStarted, gameOver, isPaused, canvasSize, paddleWidth, ballSize, highScore, isFullscreen, isMobile, playSound, resetBall, nextLevel]); useEffect(() => { if (gameStarted && !gameOver) { bricksRef.current = initBricks(); resetBall(); } }, [canvasSize, gameStarted, gameOver, initBricks, resetBall]); if (showGlitchCrash) return window.location.reload()} />; return (
{'<'} Back

Breakout

{!isMobile && (

SCORE

{score.toLocaleString()}

HIGH SCORE

{highScore.toLocaleString()}

max: 4,294,967,296

LEVEL

{level}

LIVES

{'♥'.repeat(lives)}

CONTROLS

← → / A D

P: Pause

{!gameStarted || gameOver ? ( ) : ( )}
)} {isMobile && ( <>

SCORE

{score.toLocaleString()}

HIGH

{highScore.toLocaleString()}

LVL

{level}

{lives}

{gameStarted && !gameOver ? (
) : ( )}
)}
{!isMobile && (gameOver || isPaused) && gameStarted && (

{gameOver ? 'GAME OVER' : 'PAUSED'}

{gameOver && (<>

Final Score: {score.toLocaleString()}

Level: {level}

)}
)}
); }; export default Breakout;