import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Theme, Ball, Paddle } from '../types'; import { CANVAS_WIDTH, CANVAS_HEIGHT, PADDLE_WIDTH, PADDLE_HEIGHT, BALL_RADIUS, INITIAL_BALL_SPEED_X, INITIAL_BALL_SPEED_Y_MIN, INITIAL_BALL_SPEED_Y_MAX, PLAYER_PADDLE_SPEED, AI_PADDLE_MAX_SPEED, AI_PADDLE_SENSITIVITY, WINNING_SCORE, COLORS, BALL_SPEED_INCREASE_FACTOR, } from '../constants'; interface GameCanvasProps { theme: Theme; } const initialPlayerPaddleY = CANVAS_HEIGHT / 2 - PADDLE_HEIGHT / 2; const initialAiPaddleY = CANVAS_HEIGHT / 2 - PADDLE_HEIGHT / 2; const createInitialBallState = (): Ball => ({ x: CANVAS_WIDTH / 2, y: CANVAS_HEIGHT / 2, dx: INITIAL_BALL_SPEED_X * (Math.random() > 0.5 ? 1 : -1), dy: Math.random() * (INITIAL_BALL_SPEED_Y_MAX - INITIAL_BALL_SPEED_Y_MIN) + INITIAL_BALL_SPEED_Y_MIN, radius: BALL_RADIUS, speedMultiplier: 1, }); const BACKGROUND_IMAGE_URL = 'https://picsum.photos/seed/boldwoman/800/400'; // Placeholder image export const GameCanvas: React.FC = ({ theme }) => { const canvasRef = useRef(null); const playerPaddleRef = useRef({ x: 30, y: initialPlayerPaddleY, width: PADDLE_WIDTH, height: PADDLE_HEIGHT }); const aiPaddleRef = useRef({ x: CANVAS_WIDTH - 30 - PADDLE_WIDTH, y: initialAiPaddleY, width: PADDLE_WIDTH, height: PADDLE_HEIGHT }); const ballRef = useRef(createInitialBallState()); const playerPaddleDyRef = useRef(0); const [playerScore, setPlayerScore] = useState(0); const [aiScore, setAiScore] = useState(0); const [winner, setWinner] = useState(null); const [gameRunning, setGameRunning] = useState(true); const [backgroundImage, setBackgroundImage] = useState(null); const gameColors = theme === Theme.LIGHT ? COLORS.light : COLORS.dark; useEffect(() => { const img = new Image(); img.src = BACKGROUND_IMAGE_URL; img.onload = () => { setBackgroundImage(img); }; img.onerror = () => { console.error("Failed to load background image. Using fallback color."); }; }, []); const resetBall = useCallback((isPointForPlayer?: boolean) => { ballRef.current = { ...createInitialBallState(), dx: INITIAL_BALL_SPEED_X * (isPointForPlayer ? -1 : 1), speedMultiplier: 1, }; }, []); const resetGame = useCallback(() => { setPlayerScore(0); setAiScore(0); setWinner(null); playerPaddleRef.current.y = initialPlayerPaddleY; aiPaddleRef.current.y = initialAiPaddleY; resetBall(); setGameRunning(true); }, [resetBall]); useEffect(() => { if (playerScore >= WINNING_SCORE) { setWinner('Player Wins!'); setGameRunning(false); } else if (aiScore >= WINNING_SCORE) { setWinner('AI Wins!'); setGameRunning(false); } }, [playerScore, aiScore]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; let animationFrameId: number; const drawPaddle = (paddle: Paddle) => { ctx.fillStyle = gameColors.paddle; ctx.fillRect(paddle.x, paddle.y, paddle.width, paddle.height); }; const drawBall = (ball: Ball) => { ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2); ctx.fillStyle = gameColors.ball; ctx.fill(); ctx.closePath(); }; const drawBackground = () => { if (backgroundImage) { ctx.drawImage(backgroundImage, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); } else { ctx.fillStyle = theme === Theme.LIGHT ? COLORS.light.background : COLORS.dark.background; ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); } }; const gameLoop = () => { drawBackground(); // Draw background first if (!gameRunning) { // Draw paddles and ball in their last position for the end screen drawPaddle(playerPaddleRef.current); drawPaddle(aiPaddleRef.current); drawBall(ballRef.current); if (winner) { ctx.font = 'bold 48px Arial'; ctx.fillStyle = gameColors.text; // Ensure text is visible over image ctx.textAlign = 'center'; // Add a semi-transparent overlay for better text readability if image is too busy ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; ctx.fillRect(CANVAS_WIDTH / 4, CANVAS_HEIGHT / 2 - 60, CANVAS_WIDTH / 2, 120); ctx.fillStyle = gameColors.text; // Reset to text color ctx.fillText(winner, CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 - 15); ctx.font = '24px Arial'; ctx.fillText('Click "New Game" to play again', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 30); } return; } // Move Player Paddle playerPaddleRef.current.y += playerPaddleDyRef.current; playerPaddleRef.current.y = Math.max(0, Math.min(CANVAS_HEIGHT - playerPaddleRef.current.height, playerPaddleRef.current.y)); // Move AI Paddle const aiCenter = aiPaddleRef.current.y + aiPaddleRef.current.height / 2; const ballCenterY = ballRef.current.y; let aiPaddleSpeed = (ballCenterY - aiCenter) * AI_PADDLE_SENSITIVITY; aiPaddleSpeed = Math.max(-AI_PADDLE_MAX_SPEED, Math.min(AI_PADDLE_MAX_SPEED, aiPaddleSpeed)); aiPaddleRef.current.y += aiPaddleSpeed; aiPaddleRef.current.y = Math.max(0, Math.min(CANVAS_HEIGHT - aiPaddleRef.current.height, aiPaddleRef.current.y)); const currentBall = ballRef.current; currentBall.x += currentBall.dx * currentBall.speedMultiplier; currentBall.y += currentBall.dy * currentBall.speedMultiplier; if (currentBall.y + currentBall.radius > CANVAS_HEIGHT || currentBall.y - currentBall.radius < 0) { currentBall.dy *= -1; currentBall.y = Math.max(currentBall.radius, Math.min(CANVAS_HEIGHT - currentBall.radius, currentBall.y)); } if ( currentBall.x - currentBall.radius < playerPaddleRef.current.x + playerPaddleRef.current.width && currentBall.x + currentBall.radius > playerPaddleRef.current.x && currentBall.y - currentBall.radius < playerPaddleRef.current.y + playerPaddleRef.current.height && currentBall.y + currentBall.radius > playerPaddleRef.current.y && currentBall.dx < 0 ) { currentBall.dx *= -1; let hitPos = (currentBall.y - (playerPaddleRef.current.y + playerPaddleRef.current.height / 2)) / (playerPaddleRef.current.height / 2); currentBall.dy = hitPos * Math.abs(INITIAL_BALL_SPEED_X); currentBall.dy = Math.max(INITIAL_BALL_SPEED_Y_MIN, Math.min(INITIAL_BALL_SPEED_Y_MAX, currentBall.dy)); currentBall.speedMultiplier *= BALL_SPEED_INCREASE_FACTOR; currentBall.x = playerPaddleRef.current.x + playerPaddleRef.current.width + currentBall.radius; } if ( currentBall.x + currentBall.radius > aiPaddleRef.current.x && currentBall.x - currentBall.radius < aiPaddleRef.current.x + aiPaddleRef.current.width && currentBall.y - currentBall.radius < aiPaddleRef.current.y + aiPaddleRef.current.height && currentBall.y + currentBall.radius > aiPaddleRef.current.y && currentBall.dx > 0 ) { currentBall.dx *= -1; let hitPos = (currentBall.y - (aiPaddleRef.current.y + aiPaddleRef.current.height / 2)) / (aiPaddleRef.current.height / 2); currentBall.dy = hitPos * Math.abs(INITIAL_BALL_SPEED_X); currentBall.dy = Math.max(INITIAL_BALL_SPEED_Y_MIN, Math.min(INITIAL_BALL_SPEED_Y_MAX, currentBall.dy)); currentBall.speedMultiplier *= BALL_SPEED_INCREASE_FACTOR; currentBall.x = aiPaddleRef.current.x - currentBall.radius; } if (currentBall.x - currentBall.radius < 0) { setAiScore(s => s + 1); resetBall(false); } else if (currentBall.x + currentBall.radius > CANVAS_WIDTH) { setPlayerScore(s => s + 1); resetBall(true); } drawPaddle(playerPaddleRef.current); drawPaddle(aiPaddleRef.current); drawBall(currentBall); animationFrameId = requestAnimationFrame(gameLoop); }; animationFrameId = requestAnimationFrame(gameLoop); return () => { cancelAnimationFrame(animationFrameId); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameRunning, theme, winner, gameColors, resetBall, backgroundImage]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!gameRunning) return; if (e.key === 'ArrowUp') playerPaddleDyRef.current = -PLAYER_PADDLE_SPEED; if (e.key === 'ArrowDown') playerPaddleDyRef.current = PLAYER_PADDLE_SPEED; }; const handleKeyUp = (e: KeyboardEvent) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') playerPaddleDyRef.current = 0; }; document.addEventListener('keydown', handleKeyDown); document.addEventListener('keyup', handleKeyUp); return () => { document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keyup', handleKeyUp); }; }, [gameRunning]); return (

Player: {playerScore} | AI: {aiScore}

); };