diff --git a/src/pages/Breakout.tsx b/src/pages/Breakout.tsx index d0c8331..0027b74 100644 --- a/src/pages/Breakout.tsx +++ b/src/pages/Breakout.tsx @@ -38,14 +38,27 @@ const Breakout = () => { const [gameStarted, setGameStarted] = useState(false); const [isPaused, setIsPaused] = useState(false); const [showGlitchCrash, setShowGlitchCrash] = useState(false); + const [isTwoPlayer, setIsTwoPlayer] = useState(false); + + // P2 state + const [score2, setScore2] = useState(0); + const [lives2, setLives2] = useState(3); + const [level2, setLevel2] = useState(1); const canvasRef = useRef(null); + const canvas2Ref = useRef(null); const gameRef = useRef(null); const paddleXRef = useRef(160); + const paddle2XRef = useRef(160); const ballRef = useRef({ x: 200, y: 450, dx: 4, dy: -4 }); + const ball2Ref = useRef({ x: 200, y: 450, dx: 4, dy: -4 }); const bricksRef = useRef([]); + const bricks2Ref = useRef([]); const keysRef = useRef>(new Set()); const animationRef = useRef(); + const animation2Ref = useRef(); + const p1DeadRef = useRef(false); + const p2DeadRef = useRef(false); const { enterFullscreen, exitFullscreen } = useBrowserFullscreen(); @@ -54,7 +67,6 @@ const Breakout = () => { 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 = 560 / 680; let width = maxWidth; @@ -62,8 +74,11 @@ const Breakout = () => { if (height > maxHeight) { height = maxHeight; width = height * aspectRatio; } return { width: Math.floor(width), height: Math.floor(height) }; } + if (isTwoPlayer) { + return isFullscreen ? { width: 480, height: 580 } : { width: 400, height: 500 }; + } return isFullscreen ? { width: 700, height: 840 } : { width: 560, height: 680 }; - }, [isFullscreen]); + }, [isFullscreen, isTwoPlayer]); const [canvasSize, setCanvasSize] = useState(getCanvasSize); const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; @@ -74,10 +89,10 @@ const Breakout = () => { return () => window.removeEventListener('resize', handleResize); }, [getCanvasSize]); - useEffect(() => { setCanvasSize(getCanvasSize()); }, [isFullscreen, getCanvasSize]); + useEffect(() => { setCanvasSize(getCanvasSize()); }, [isFullscreen, getCanvasSize, isTwoPlayer]); - const paddleWidth = isMobile ? 70 : (isFullscreen ? 100 : 90); - const ballSize = isMobile ? 10 : (isFullscreen ? 14 : 12); + const paddleWidth = isMobile ? 70 : (isTwoPlayer ? 70 : (isFullscreen ? 100 : 90)); + const ballSize = isMobile ? 10 : (isTwoPlayer ? 10 : (isFullscreen ? 14 : 12)); useEffect(() => { if (gameStarted && isMobile && !isFullscreen) { setIsFullscreen(true); enterFullscreen(); } @@ -100,7 +115,6 @@ const Breakout = () => { if (saved) setHighScore(Math.min(parseInt(saved, 10), MAX_SCORE)); }, []); - // Check score achievements useEffect(() => { if (score > 0) { checkGameScoreAchievements('breakout', score); @@ -108,9 +122,9 @@ const Breakout = () => { } }, [score, checkGameScoreAchievements, unlockMaxScore]); - const initBricks = useCallback(() => { + const initBricks = useCallback((width: number) => { const bricks: Brick[] = []; - const brickWidth = (canvasSize.width - (BRICK_COLS + 1) * BRICK_GAP) / BRICK_COLS; + const brickWidth = (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++) { @@ -118,34 +132,73 @@ const Breakout = () => { } } 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 resetBall = useCallback((ballRefToReset: React.MutableRefObject<{x: number, y: number, dx: number, dy: number}>, paddleRefToReset: React.MutableRefObject, lvl: number) => { + const speed = 2.5 + lvl * 0.3; + ballRefToReset.current = { x: canvasSize.width / 2, y: canvasSize.height - 60, dx: (Math.random() > 0.5 ? 1 : -1) * speed, dy: -speed }; + paddleRefToReset.current = canvasSize.width / 2 - paddleWidth / 2; + }, [canvasSize.width, canvasSize.height, paddleWidth]); - const startGame = () => { + const startGame = (twoPlayer: boolean) => { + setIsTwoPlayer(twoPlayer); setScore(0); setLevel(1); setLives(3); setGameOver(false); setIsPaused(false); setGameStarted(true); - bricksRef.current = initBricks(); resetBall(); playSound('success'); gameRef.current?.focus(); + p1DeadRef.current = false; + p2DeadRef.current = false; + + if (twoPlayer) { + setScore2(0); setLevel2(1); setLives2(3); + } + + setTimeout(() => { + const size = getCanvasSize(); + bricksRef.current = initBricks(size.width); + resetBall(ballRef, paddleXRef, 1); + + if (twoPlayer) { + bricks2Ref.current = initBricks(size.width); + resetBall(ball2Ref, paddle2XRef, 1); + } + }, 50); + + playSound('success'); + gameRef.current?.focus(); }; - const nextLevel = useCallback(() => { - setLevel(prev => prev + 1); bricksRef.current = initBricks(); resetBall(); playSound('success'); - }, [initBricks, resetBall, playSound]); + const nextLevel = useCallback((player: 1 | 2) => { + if (player === 1) { + setLevel(prev => { + const newLevel = prev + 1; + bricksRef.current = initBricks(canvasSize.width); + resetBall(ballRef, paddleXRef, newLevel); + return newLevel; + }); + } else { + setLevel2(prev => { + const newLevel = prev + 1; + bricks2Ref.current = initBricks(canvasSize.width); + resetBall(ball2Ref, paddle2XRef, newLevel); + return newLevel; + }); + } + playSound('success'); + }, [initBricks, resetBall, playSound, canvasSize.width]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (['ArrowLeft', 'ArrowRight', 'a', 'd'].includes(e.key)) { e.preventDefault(); keysRef.current.add(e.key); } + if (isTwoPlayer) { + // P1: A/D, P2: Arrow keys + if (['a', 'd', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault(); keysRef.current.add(e.key); } + } else { + 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]); + }, [gameStarted, gameOver, playSound, isTwoPlayer]); const handleTouchMove = useCallback((e: React.TouchEvent) => { if (!gameStarted || gameOver || isPaused) return; @@ -160,91 +213,191 @@ const Breakout = () => { 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]); + // Game loop for a player + const runGameLoop = useCallback(( + canvas: HTMLCanvasElement, + ballRefParam: React.MutableRefObject<{x: number, y: number, dx: number, dy: number}>, + paddleRefParam: React.MutableRefObject, + bricksRefParam: React.MutableRefObject, + setScoreParam: React.Dispatch>, + setLivesParam: React.Dispatch>, + getLives: () => number, + playerNum: 1 | 2, + primaryColor: string, + leftKey: string, + rightKey: string, + deadRef: React.MutableRefObject + ) => { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const paddleSpeed = isMobile ? 6 : (isFullscreen ? 10 : 8); + if (keysRef.current.has(leftKey)) paddleRefParam.current = Math.max(0, paddleRefParam.current - paddleSpeed); + if (keysRef.current.has(rightKey)) paddleRefParam.current = Math.min(canvasSize.width - paddleWidth, paddleRefParam.current + paddleSpeed); + + const ball = ballRefParam.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 >= paddleRefParam.current && ball.x <= paddleRefParam.current + paddleWidth) { + const hitPos = (ball.x - paddleRefParam.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 && !deadRef.current) { + const currentLives = getLives(); + const newLives = currentLives - 1; + setLivesParam(newLives); + if (newLives <= 0) { + deadRef.current = true; + playSound('error'); + // Check if game over (both players dead in 2P, or single player dead) + if (!isTwoPlayer) { + setGameOver(true); + } else { + // Check if both dead + if ((playerNum === 1 && p2DeadRef.current) || (playerNum === 2 && p1DeadRef.current)) { + setGameOver(true); + } + } + } else { + resetBall(ballRefParam, paddleRefParam, playerNum === 1 ? level : level2); + playSound('error'); + } + } + + let allDestroyed = true; + for (const brick of bricksRefParam.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; + setScoreParam(prev => { + const ns = Math.min(prev + 10, MAX_SCORE); + if (ns >= MAX_SCORE && playerNum === 1) setShowGlitchCrash(true); + if (ns > highScore && playerNum === 1) { setHighScore(ns); localStorage.setItem(HIGHSCORE_KEY, ns.toString()); } + return ns; + }); + playSound('success'); break; + } + } + + if (allDestroyed && bricksRefParam.current.length > 0 && !deadRef.current) nextLevel(playerNum); + + ctx.fillStyle = '#0a0a0a'; ctx.fillRect(0, 0, canvasSize.width, canvasSize.height); + + for (const brick of bricksRefParam.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(paddleRefParam.current, canvasSize.height - 30, paddleWidth, PADDLE_HEIGHT); ctx.shadowBlur = 0; + + if (!deadRef.current) { + 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; + + // Draw player label in 2P mode + if (isTwoPlayer) { + ctx.font = '14px monospace'; + ctx.fillStyle = primaryColor; + ctx.textAlign = 'center'; + ctx.fillText(`P${playerNum}`, canvasSize.width / 2, 20); + + if (deadRef.current) { + ctx.font = '20px monospace'; + ctx.fillStyle = 'hsl(0 70% 50%)'; + ctx.fillText('ELIMINATED', canvasSize.width / 2, canvasSize.height / 2); + } + } + }, [canvasSize, paddleWidth, ballSize, highScore, isFullscreen, isMobile, playSound, resetBall, nextLevel, isTwoPlayer, level, level2]); + + // P1 game loop useEffect(() => { if (!gameStarted || gameOver || isPaused) return; const canvas = canvasRef.current; if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; + + const computedStyle = getComputedStyle(document.documentElement); + const primaryHsl = computedStyle.getPropertyValue('--primary').trim(); + const primaryColor = `hsl(${primaryHsl})`; 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; + const leftKey = isTwoPlayer ? 'a' : 'ArrowLeft'; + const rightKey = isTwoPlayer ? 'd' : 'ArrowRight'; + const altLeftKey = isTwoPlayer ? '' : 'a'; + const altRightKey = isTwoPlayer ? '' : 'd'; + + // Check both keys in single player + const checkLeft = leftKey; + const checkRight = rightKey; + + if (!isTwoPlayer) { + // In single player, check both arrow and A/D + if (keysRef.current.has('ArrowLeft') || keysRef.current.has('a')) { + keysRef.current.add('ArrowLeft'); + } + if (keysRef.current.has('ArrowRight') || keysRef.current.has('d')) { + keysRef.current.add('ArrowRight'); } } - - 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; - + + runGameLoop(canvas, ballRef, paddleXRef, bricksRef, setScore, setLives, () => lives, 1, primaryColor, checkLeft, checkRight, p1DeadRef); 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]); + }, [gameStarted, gameOver, isPaused, runGameLoop, isTwoPlayer, lives]); + + // P2 game loop + useEffect(() => { + if (!gameStarted || gameOver || isPaused || !isTwoPlayer) return; + const canvas = canvas2Ref.current; + if (!canvas) return; + + const computedStyle = getComputedStyle(document.documentElement); + const secondaryHsl = computedStyle.getPropertyValue('--secondary').trim(); + const secondaryColor = `hsl(${secondaryHsl})`; + + const gameLoop = () => { + runGameLoop(canvas, ball2Ref, paddle2XRef, bricks2Ref, setScore2, setLives2, () => lives2, 2, secondaryColor, 'ArrowLeft', 'ArrowRight', p2DeadRef); + animation2Ref.current = requestAnimationFrame(gameLoop); + }; + + animation2Ref.current = requestAnimationFrame(gameLoop); + return () => { if (animation2Ref.current) cancelAnimationFrame(animation2Ref.current); }; + }, [gameStarted, gameOver, isPaused, isTwoPlayer, runGameLoop, lives2]); useEffect(() => { - if (gameStarted && !gameOver) { bricksRef.current = initBricks(); resetBall(); } - }, [canvasSize, gameStarted, gameOver, initBricks, resetBall]); + if (gameStarted && !gameOver) { + bricksRef.current = initBricks(canvasSize.width); + resetBall(ballRef, paddleXRef, level); + if (isTwoPlayer) { + bricks2Ref.current = initBricks(canvasSize.width); + resetBall(ball2Ref, paddle2XRef, level2); + } + } + }, [canvasSize, gameStarted, gameOver, initBricks, resetBall, isTwoPlayer, level, level2]); + + const getWinner = () => { + if (!isTwoPlayer) return null; + if (score > score2) return 'P1'; + if (score2 > score) return 'P2'; + return 'TIE'; + }; if (showGlitchCrash) return window.location.reload()} />; @@ -255,6 +408,7 @@ const Breakout = () => {
{'<'} Back

Breakout

+ {isTwoPlayer && 2P MODE}
+
+ + +
) : ( +
+ +

MOBILE: 1 PLAYER ONLY

+
)} @@ -346,8 +548,28 @@ const Breakout = () => {

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

- {gameOver && (<>

Final Score: {score.toLocaleString()}

Level: {level}

)} - + {gameOver && isTwoPlayer && ( +

+ {getWinner() === 'TIE' ? "IT'S A TIE!" : `${getWinner()} WINS!`} +

+ )} + {gameOver && ( +
+

{isTwoPlayer ? 'P1: ' : 'Score: '}{score.toLocaleString()}

+ {isTwoPlayer &&

P2: {score2.toLocaleString()}

} +
+ )} +
+ Back + {gameOver ? ( + <> + + + + ) : ( + + )} +
)} diff --git a/src/pages/Games.tsx b/src/pages/Games.tsx index 6c2920d..1855cb4 100644 --- a/src/pages/Games.tsx +++ b/src/pages/Games.tsx @@ -44,7 +44,7 @@ const games = [ id: 'breakout', name: 'Breakout', description: 'Break bricks with a bouncing ball', - hasMultiplayer: false, + hasMultiplayer: true, ascii: `┌────────┐ │████████│ │▓▓▓▓▓▓▓▓│