From 1cf33056318566f9d132f10e9b7a34345e674ab8 Mon Sep 17 00:00:00 2001 From: Daniil Polienko Date: Tue, 4 Mar 2025 18:22:27 +0300 Subject: [PATCH 1/6] feat: init pong --- CLAUDE.md | 36 +++ app/page.tsx | 4 +- components/common/PongGame.tsx | 469 +++++++++++++++++++++++++++++++++ 3 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 components/common/PongGame.tsx diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..94823c9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,36 @@ +# MOM Project Guidelines + +## Development Commands +- Build: `npm run build` +- Dev server: `npm run dev` (with Turbopack) +- Start: `npm run start` +- Lint: `npm run lint` + +## Code Style + +### TypeScript +- Strict type checking enabled +- Use explicit return types (e.g., `function Component(): ReactNode`) +- Import types with `import type` syntax +- Use type annotations for all function parameters + +### Component Structure +- React functional components with explicit return types +- Export constants at the file level when appropriate +- Props interfaces should be defined inline with component parameters + +### Formatting +- Single quotes for strings, with values in JSX wrapped in curly braces +- Tab width: 4 spaces +- Line width: 120 characters +- Use className objects with template strings for complex classnames +- Use Tailwind for styling + +### Imports +- Use absolute imports with @ alias (e.g., `@/components/common/Header`) +- Group imports by: 1) external libraries, 2) internal components, 3) types + +### Naming +- PascalCase for components and component files +- camelCase for functions, variables, and non-component files +- Use descriptive and specific names \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 2cdc6cc..8dbb598 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,11 @@ -import GridBackground from '@/components/common/GridBackground'; +import PongGame from '@/components/common/PongGame'; import type {ReactNode} from 'react'; export default function Home(): ReactNode { return (
- +

(null); + const requestIdRef = useRef(0); + const [gridSize, setGridSize] = useState(64); + const [gameState, setGameState] = useState({ + playerY: 0, + computerY: 0, + ballX: 0, + ballY: 0, + ballSpeedX: 0, + ballSpeedY: 0, + playerScore: 0, + computerScore: 0, + gameOver: false, + paused: false, + winner: null, + lastUpdateTime: 0, + targetComputerY: 0, + lastPredictionTime: 0 + }); + const [isGameStarted, setIsGameStarted] = useState(false); + + // Game speed control - higher number = slower game + const UPDATE_INTERVAL = 50; // Update game every 50ms + const PREDICTION_INTERVAL = 500; // Update AI prediction every 500ms for more human-like behavior + + // Initialize game + const initGame = useCallback(() => { + const paddleHeight = 4; // 4 cells + const initialComputerY = Math.floor(Math.random() * (15 - paddleHeight)); + + setGameState({ + playerY: Math.floor(Math.random() * (15 - paddleHeight)), + computerY: initialComputerY, + ballX: 10, + ballY: 8, + ballSpeedX: -0.3, + ballSpeedY: Math.random() > 0.5 ? 0.3 : -0.3, + playerScore: 0, + computerScore: 0, + gameOver: false, + paused: false, + winner: null, + lastUpdateTime: Date.now(), + targetComputerY: initialComputerY, // Start with target matching current position + lastPredictionTime: 0 + }); + setIsGameStarted(true); + }, []); + + // Toggle pause state + const togglePause = useCallback(() => { + if (!gameState.gameOver) { + setGameState(prev => ({ + ...prev, + paused: !prev.paused + })); + } + }, [gameState.gameOver]); + + // Handle key presses for player paddle movement + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { + if (!isGameStarted) { + // Start game on any key press if not started + initGame(); + return; + } + + if (e.key === 'Escape') { + // Toggle pause when Escape is pressed + togglePause(); + return; + } + + if (gameState.paused || gameState.gameOver) { + if (e.key === 'r' && gameState.gameOver) { + // Restart game when 'r' is pressed if game is over + initGame(); + } + return; // Don't process other keys if paused or game over + } + + if (e.key === 'ArrowUp' || e.key === 'w') { + // Move paddle up + setGameState(prev => ({ + ...prev, + playerY: Math.max(0, prev.playerY - 0.7) + })); + } else if (e.key === 'ArrowDown' || e.key === 's') { + // Move paddle down + setGameState(prev => ({ + ...prev, + playerY: Math.min(16 - 4, prev.playerY + 0.7) + })); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isGameStarted, gameState.gameOver, gameState.paused, initGame, togglePause]); + + // Game loop with throttled update + const updateGame = useCallback(() => { + if (gameState.gameOver || gameState.paused) { + return; + } + + const currentTime = Date.now(); + // Only update game state at the specified interval + if (currentTime - gameState.lastUpdateTime < UPDATE_INTERVAL) { + return; + } + + setGameState(prev => { + // Calculate next ball position + let newBallX = prev.ballX + prev.ballSpeedX; + let newBallY = prev.ballY + prev.ballSpeedY; + let newBallSpeedX = prev.ballSpeedX; + let newBallSpeedY = prev.ballSpeedY; + let newPlayerScore = prev.playerScore; + let newComputerScore = prev.computerScore; + let newGameOver = prev.gameOver; + let newWinner = prev.winner; + let newComputerY = prev.computerY; + let newTargetComputerY = prev.targetComputerY; + let newLastPredictionTime = prev.lastPredictionTime; + + // Check for potential collision with player paddle before moving ball + const willCollideWithPlayer = + newBallX <= 1 && // Ball x position will be at paddle x + newBallX + prev.ballSpeedX >= 0 && // Coming from right direction + newBallY >= prev.playerY - 0.5 && // Expanded collision area for better feeling + newBallY <= prev.playerY + 4.5 && // Expanded collision area for better feeling + prev.ballSpeedX < 0; // Moving toward player paddle + + // Check for potential collision with computer paddle before moving ball + const willCollideWithComputer = + newBallX >= 18 && // Ball x position will be at paddle x + newBallX + prev.ballSpeedX <= 19 && // Coming from left direction + newBallY >= prev.computerY - 0.5 && // Expanded collision area for better feeling + newBallY <= prev.computerY + 4.5 && // Expanded collision area for better feeling + prev.ballSpeedX > 0; // Moving toward computer paddle + + // More human-like computer paddle AI + // Only recalculate prediction occasionally to make movement appear more human + if ( + currentTime - newLastPredictionTime > PREDICTION_INTERVAL && + Math.abs(newBallX - 10) > 3 // Don't react immediately after ball reset + ) { + // Update last prediction time + newLastPredictionTime = currentTime; + + // Calculate where the ball will intersect with the computer's y-axis + let predictedY = newBallY; + + // Only predict if the ball is moving toward the computer + if (newBallSpeedX > 0) { + // Calculate how many steps until the ball reaches the computer paddle + const stepsToReach = (19 - newBallX) / newBallSpeedX; + // Predict where the ball will be at that point + predictedY = newBallY + newBallSpeedY * stepsToReach; + + // Account for bounces off the top and bottom walls + while (predictedY < 0 || predictedY > 15) { + if (predictedY < 0) { + predictedY = -predictedY; // Bounce off top wall + } + if (predictedY > 15) { + predictedY = 30 - predictedY; // Bounce off bottom wall (15*2 - predictedY) + } + } + + // Occasionally make prediction errors (human-like) + const errorChance = Math.random(); + if (errorChance < 0.3) { + // Small error - slightly off + predictedY += Math.random() * 1.5 - 0.75; + } else if (errorChance < 0.4) { + // Bigger error - significantly off + predictedY += Math.random() * 3 - 1.5; + } + + // If ball is far away, react slower with a lazy position + if (newBallX < 5 && newBallSpeedX > 0) { + // Ball just started going toward computer, move lazily to center + newTargetComputerY = 6; // Lazy center-ish position + } else { + // Target the center of the paddle to the predicted ball position + newTargetComputerY = Math.max(0, Math.min(12, predictedY - 2)); // Clamp to valid range + } + } else if (Math.abs(newComputerY - 6) > 3) { + // Ball is moving away - occasionally drift towards center + newTargetComputerY = 6; // Drift to center when ball moving away + } + } + + // Move computer paddle smoothly toward target position (human-like motion) + const distanceToTarget = newTargetComputerY - newComputerY; + + if (Math.abs(distanceToTarget) > 0.1) { + // Smooth, variable movement speed - faster when further from target + const speed = Math.min(0.2, Math.abs(distanceToTarget) * 0.1); + newComputerY += distanceToTarget > 0 ? speed : -speed; + } + + // Ball bounces off top and bottom walls + if (newBallY <= 0 || newBallY >= 15) { + newBallSpeedY = -newBallSpeedY; + newBallY = newBallY <= 0 ? 0.1 : 14.9; // Prevent getting stuck at edges + } + + // Handle collision with player paddle (left side) + if (willCollideWithPlayer) { + // Move the ball to the paddle edge to prevent going through + newBallX = 1.1; + // Bounce with increasing speed (gets faster with each hit) + newBallSpeedX = Math.abs(newBallSpeedX) * 1.05; + + // Calculate angle based on where the ball hits the paddle + // Higher on the paddle = steeper upward angle, lower = steeper downward angle + const relativeIntersectY = prev.playerY + 2 - newBallY; + const normalizedRelativeIntersectionY = relativeIntersectY / 2; // -1 to 1 + const bounceAngle = normalizedRelativeIntersectionY * 0.3; // Max angle + + newBallSpeedY = -bounceAngle; + } + + // Handle collision with computer paddle (right side) + if (willCollideWithComputer) { + // Move the ball to the paddle edge to prevent going through + newBallX = 17.9; + // Bounce with increasing speed (gets faster with each hit) + newBallSpeedX = -Math.abs(newBallSpeedX) * 1.05; + + // Calculate angle based on where the ball hits the paddle + // Higher on the paddle = steeper upward angle, lower = steeper downward angle + const relativeIntersectY = prev.computerY + 2 - newBallY; + const normalizedRelativeIntersectionY = relativeIntersectY / 2; // -1 to 1 + const bounceAngle = normalizedRelativeIntersectionY * 0.3; // Max angle + + newBallSpeedY = -bounceAngle; + } + + // Ball goes out of bounds - scoring + if (newBallX < 0) { + // Computer scores + newComputerScore++; + if (newComputerScore >= 5) { + newGameOver = true; + newWinner = 'computer'; + } else { + // Reset ball position + newBallX = 10; + newBallY = 8; + newBallSpeedX = 0.3; + newBallSpeedY = Math.random() > 0.5 ? 0.3 : -0.3; + // Reset paddle predictions + newLastPredictionTime = 0; + } + } else if (newBallX > 19) { + // Player scores + newPlayerScore++; + if (newPlayerScore >= 5) { + newGameOver = true; + newWinner = 'player'; + } else { + // Reset ball position + newBallX = 10; + newBallY = 8; + newBallSpeedX = -0.3; + newBallSpeedY = Math.random() > 0.5 ? 0.3 : -0.3; + // Reset paddle predictions + newLastPredictionTime = 0; + } + } + + return { + ...prev, + ballX: newBallX, + ballY: newBallY, + ballSpeedX: newBallSpeedX, + ballSpeedY: newBallSpeedY, + computerY: newComputerY, + playerScore: newPlayerScore, + computerScore: newComputerScore, + gameOver: newGameOver, + winner: newWinner, + lastUpdateTime: currentTime, + targetComputerY: newTargetComputerY, + lastPredictionTime: newLastPredictionTime + }; + }); + }, [gameState.gameOver, gameState.paused, gameState.lastUpdateTime, UPDATE_INTERVAL]); + + // Canvas setup and draw + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + const resizeCanvas = (): void => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + + // Determine grid size based on screen width + const newGridSize = window.innerWidth < 640 ? 32 : 64; + setGridSize(newGridSize); + + drawGame(); + }; + + const drawGame = (): void => { + if (!ctx || !canvas) { + return; + } + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw grid + ctx.strokeStyle = '#e5e5e5'; + ctx.lineWidth = 1; + + const offsetX = gridSize / 2; + const offsetY = gridSize / 2; + + // Draw vertical lines + for (let x = offsetX; x <= canvas.width; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, canvas.height); + ctx.stroke(); + } + + // Draw horizontal lines + for (let y = offsetY; y <= canvas.height; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(canvas.width, y); + ctx.stroke(); + } + + if (!isGameStarted) { + // Draw "Press any key to start" message + ctx.fillStyle = '#000'; + ctx.font = '24px Arial'; + ctx.textAlign = 'center'; + ctx.fillText('Press any key to start', canvas.width / 2, canvas.height / 2); + return; + } + + // Calculate game area dimensions (20x16 grid) + const gameWidth = 20 * gridSize; + const gameHeight = 16 * gridSize; + const gameLeft = (canvas.width - gameWidth) / 2; + const gameTop = (canvas.height - gameHeight) / 2; + + // Draw player paddle (left) + ctx.fillStyle = '#FFD915'; + ctx.fillRect(gameLeft + 0 * gridSize, gameTop + gameState.playerY * gridSize, gridSize, 4 * gridSize); + + // Draw computer paddle (right) + ctx.fillStyle = '#FFD915'; + ctx.fillRect(gameLeft + 19 * gridSize, gameTop + gameState.computerY * gridSize, gridSize, 4 * gridSize); + + // Draw ball + ctx.fillStyle = '#FFD915'; + ctx.fillRect( + gameLeft + gameState.ballX * gridSize, + gameTop + gameState.ballY * gridSize, + gridSize, + gridSize + ); + + // Draw scores + ctx.fillStyle = '#000'; + ctx.font = '24px Arial'; + ctx.textAlign = 'center'; + ctx.fillText(`${gameState.playerScore} - ${gameState.computerScore}`, canvas.width / 2, gameTop + 40); + + // Draw game over message if needed + if (gameState.gameOver) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(gameLeft + 5 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); + + ctx.fillStyle = '#fff'; + ctx.font = '24px Arial'; + ctx.textAlign = 'center'; + ctx.fillText( + `${gameState.winner === 'player' ? 'You win!' : 'MOM wins!'}`, + canvas.width / 2, + canvas.height / 2 - 260 + ); + ctx.fillText('Press R to restart', canvas.width / 2, canvas.height / 2 - 220); + } + + // Draw pause message if game is paused + if (gameState.paused) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.fillRect(gameLeft + 5 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); + + ctx.fillStyle = '#fff'; + ctx.font = '24px Arial'; + ctx.textAlign = 'center'; + ctx.fillText('PAUSED', canvas.width / 2, canvas.height / 2 - 260); + ctx.fillText('Press ESC to continue', canvas.width / 2, canvas.height / 2 - 220); + } + }; + + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + + // Game loop - the draw rate is faster than the update rate + const animate = (): void => { + if (isGameStarted && !gameState.gameOver && !gameState.paused) { + updateGame(); + } + drawGame(); + requestIdRef.current = requestAnimationFrame(animate); + }; + + requestIdRef.current = requestAnimationFrame(animate); + + return () => { + cancelAnimationFrame(requestIdRef.current); + window.removeEventListener('resize', resizeCanvas); + }; + }, [isGameStarted, gameState, gridSize, updateGame]); + + return ( + + ); +} From 4446ac9cdb3078930d370cc22a805689cc890a90 Mon Sep 17 00:00:00 2001 From: Daniil Polienko Date: Wed, 5 Mar 2025 15:23:30 +0300 Subject: [PATCH 2/6] feat: increase game width --- components/common/PongGame.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/components/common/PongGame.tsx b/components/common/PongGame.tsx index e1386fb..87e0a22 100644 --- a/components/common/PongGame.tsx +++ b/components/common/PongGame.tsx @@ -46,6 +46,7 @@ export default function PongGame(): ReactNode { // Game speed control - higher number = slower game const UPDATE_INTERVAL = 50; // Update game every 50ms const PREDICTION_INTERVAL = 500; // Update AI prediction every 500ms for more human-like behavior + const WIDTH = 26; // 26 cells // Initialize game const initGame = useCallback(() => { @@ -161,8 +162,8 @@ export default function PongGame(): ReactNode { // Check for potential collision with computer paddle before moving ball const willCollideWithComputer = - newBallX >= 18 && // Ball x position will be at paddle x - newBallX + prev.ballSpeedX <= 19 && // Coming from left direction + newBallX >= WIDTH - 2 && // Ball x position will be at paddle x + newBallX + prev.ballSpeedX <= WIDTH - 1 && // Coming from left direction newBallY >= prev.computerY - 0.5 && // Expanded collision area for better feeling newBallY <= prev.computerY + 4.5 && // Expanded collision area for better feeling prev.ballSpeedX > 0; // Moving toward computer paddle @@ -182,7 +183,7 @@ export default function PongGame(): ReactNode { // Only predict if the ball is moving toward the computer if (newBallSpeedX > 0) { // Calculate how many steps until the ball reaches the computer paddle - const stepsToReach = (19 - newBallX) / newBallSpeedX; + const stepsToReach = (WIDTH - 1 - newBallX) / newBallSpeedX; // Predict where the ball will be at that point predictedY = newBallY + newBallSpeedY * stepsToReach; @@ -254,7 +255,7 @@ export default function PongGame(): ReactNode { // Handle collision with computer paddle (right side) if (willCollideWithComputer) { // Move the ball to the paddle edge to prevent going through - newBallX = 17.9; + newBallX = WIDTH - 2.1; // Bounce with increasing speed (gets faster with each hit) newBallSpeedX = -Math.abs(newBallSpeedX) * 1.05; @@ -283,7 +284,7 @@ export default function PongGame(): ReactNode { // Reset paddle predictions newLastPredictionTime = 0; } - } else if (newBallX > 19) { + } else if (newBallX > WIDTH - 1) { // Player scores newPlayerScore++; if (newPlayerScore >= 5) { @@ -382,7 +383,7 @@ export default function PongGame(): ReactNode { } // Calculate game area dimensions (20x16 grid) - const gameWidth = 20 * gridSize; + const gameWidth = WIDTH * gridSize; const gameHeight = 16 * gridSize; const gameLeft = (canvas.width - gameWidth) / 2; const gameTop = (canvas.height - gameHeight) / 2; @@ -393,7 +394,12 @@ export default function PongGame(): ReactNode { // Draw computer paddle (right) ctx.fillStyle = '#FFD915'; - ctx.fillRect(gameLeft + 19 * gridSize, gameTop + gameState.computerY * gridSize, gridSize, 4 * gridSize); + ctx.fillRect( + gameLeft + (WIDTH - 1) * gridSize, + gameTop + gameState.computerY * gridSize, + gridSize, + 4 * gridSize + ); // Draw ball ctx.fillStyle = '#FFD915'; From ebdb93a1ab021b6be3d677e6c0746374e7659555 Mon Sep 17 00:00:00 2001 From: Daniil Polienko Date: Thu, 6 Mar 2025 18:47:52 +0300 Subject: [PATCH 3/6] feat: display paddle if game is not started --- components/common/PongGame.tsx | 100 +++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 36 deletions(-) diff --git a/components/common/PongGame.tsx b/components/common/PongGame.tsx index 87e0a22..dc7981b 100644 --- a/components/common/PongGame.tsx +++ b/components/common/PongGame.tsx @@ -25,9 +25,14 @@ export default function PongGame(): ReactNode { const canvasRef = useRef(null); const requestIdRef = useRef(0); const [gridSize, setGridSize] = useState(64); + + const paddleHeight = 4; // 4 cells + const initialPlayerY = Math.floor(Math.random() * (15 - paddleHeight)); + const initialComputerY = Math.floor(Math.random() * (15 - paddleHeight)); + const [gameState, setGameState] = useState({ - playerY: 0, - computerY: 0, + playerY: initialPlayerY, + computerY: initialComputerY, ballX: 0, ballY: 0, ballSpeedX: 0, @@ -50,11 +55,8 @@ export default function PongGame(): ReactNode { // Initialize game const initGame = useCallback(() => { - const paddleHeight = 4; // 4 cells - const initialComputerY = Math.floor(Math.random() * (15 - paddleHeight)); - setGameState({ - playerY: Math.floor(Math.random() * (15 - paddleHeight)), + playerY: initialPlayerY, computerY: initialComputerY, ballX: 10, ballY: 8, @@ -70,6 +72,7 @@ export default function PongGame(): ReactNode { lastPredictionTime: 0 }); setIsGameStarted(true); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Toggle pause state @@ -152,21 +155,51 @@ export default function PongGame(): ReactNode { let newTargetComputerY = prev.targetComputerY; let newLastPredictionTime = prev.lastPredictionTime; - // Check for potential collision with player paddle before moving ball - const willCollideWithPlayer = - newBallX <= 1 && // Ball x position will be at paddle x - newBallX + prev.ballSpeedX >= 0 && // Coming from right direction - newBallY >= prev.playerY - 0.5 && // Expanded collision area for better feeling - newBallY <= prev.playerY + 4.5 && // Expanded collision area for better feeling - prev.ballSpeedX < 0; // Moving toward player paddle - - // Check for potential collision with computer paddle before moving ball - const willCollideWithComputer = - newBallX >= WIDTH - 2 && // Ball x position will be at paddle x - newBallX + prev.ballSpeedX <= WIDTH - 1 && // Coming from left direction - newBallY >= prev.computerY - 0.5 && // Expanded collision area for better feeling - newBallY <= prev.computerY + 4.5 && // Expanded collision area for better feeling - prev.ballSpeedX > 0; // Moving toward computer paddle + // Continuous collision detection for player paddle + // Check if ball trajectory intersects with paddle during this frame + const willCollideWithPlayer = (() => { + // Only check if ball is moving toward player paddle + if (prev.ballSpeedX >= 0) { + return false; + } + + // Calculate time until x collision with player paddle (x=1) + const timeToXCollision = (1 - prev.ballX) / prev.ballSpeedX; + + // If timeToXCollision is negative or greater than 1, no collision in this frame + if (timeToXCollision < 0 || timeToXCollision > 1) { + return false; + } + + // Calculate y position at collision time + const yAtCollision = prev.ballY + prev.ballSpeedY * timeToXCollision; + + // Check if y position is within paddle range (with padding) + return yAtCollision >= prev.playerY - 0.5 && yAtCollision <= prev.playerY + 4.5; + })(); + + // Continuous collision detection for computer paddle + // Check if ball trajectory intersects with paddle during this frame + const willCollideWithComputer = (() => { + // Only check if ball is moving toward computer paddle + if (prev.ballSpeedX <= 0) { + return false; + } + + // Calculate time until x collision with computer paddle (x=WIDTH-2) + const timeToXCollision = (WIDTH - 2 - prev.ballX) / prev.ballSpeedX; + + // If timeToXCollision is negative or greater than 1, no collision in this frame + if (timeToXCollision < 0 || timeToXCollision > 1) { + return false; + } + + // Calculate y position at collision time + const yAtCollision = prev.ballY + prev.ballSpeedY * timeToXCollision; + + // Check if y position is within paddle range (with padding) + return yAtCollision >= prev.computerY - 0.5 && yAtCollision <= prev.computerY + 4.5; + })(); // More human-like computer paddle AI // Only recalculate prediction occasionally to make movement appear more human @@ -373,26 +406,17 @@ export default function PongGame(): ReactNode { ctx.stroke(); } - if (!isGameStarted) { - // Draw "Press any key to start" message - ctx.fillStyle = '#000'; - ctx.font = '24px Arial'; - ctx.textAlign = 'center'; - ctx.fillText('Press any key to start', canvas.width / 2, canvas.height / 2); - return; - } - - // Calculate game area dimensions (20x16 grid) + // Calculate game area dimensions const gameWidth = WIDTH * gridSize; const gameHeight = 16 * gridSize; const gameLeft = (canvas.width - gameWidth) / 2; const gameTop = (canvas.height - gameHeight) / 2; - // Draw player paddle (left) + // Draw player paddle (left) - even when game is not started ctx.fillStyle = '#FFD915'; ctx.fillRect(gameLeft + 0 * gridSize, gameTop + gameState.playerY * gridSize, gridSize, 4 * gridSize); - // Draw computer paddle (right) + // Draw computer paddle (right) - even when game is not started ctx.fillStyle = '#FFD915'; ctx.fillRect( gameLeft + (WIDTH - 1) * gridSize, @@ -401,7 +425,11 @@ export default function PongGame(): ReactNode { 4 * gridSize ); - // Draw ball + if (!isGameStarted) { + return; + } + + // Draw ball - only when game is started ctx.fillStyle = '#FFD915'; ctx.fillRect( gameLeft + gameState.ballX * gridSize, @@ -419,7 +447,7 @@ export default function PongGame(): ReactNode { // Draw game over message if needed if (gameState.gameOver) { ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; - ctx.fillRect(gameLeft + 5 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); + ctx.fillRect(gameLeft + 8 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); ctx.fillStyle = '#fff'; ctx.font = '24px Arial'; @@ -435,7 +463,7 @@ export default function PongGame(): ReactNode { // Draw pause message if game is paused if (gameState.paused) { ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; - ctx.fillRect(gameLeft + 5 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); + ctx.fillRect(gameLeft + 8 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); ctx.fillStyle = '#fff'; ctx.font = '24px Arial'; From 4d7f7cf6d2b8b885cc359ab21d429a659d6858ed Mon Sep 17 00:00:00 2001 From: Daniil Polienko Date: Thu, 6 Mar 2025 19:08:19 +0300 Subject: [PATCH 4/6] feat: clip paddles on Y --- components/common/PongGame.tsx | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/components/common/PongGame.tsx b/components/common/PongGame.tsx index dc7981b..6201aef 100644 --- a/components/common/PongGame.tsx +++ b/components/common/PongGame.tsx @@ -109,16 +109,16 @@ export default function PongGame(): ReactNode { } if (e.key === 'ArrowUp' || e.key === 'w') { - // Move paddle up + // Move paddle up by 1 cell setGameState(prev => ({ ...prev, - playerY: Math.max(0, prev.playerY - 0.7) + playerY: Math.max(0, Math.floor(prev.playerY) - 1) })); } else if (e.key === 'ArrowDown' || e.key === 's') { - // Move paddle down + // Move paddle down by 1 cell setGameState(prev => ({ ...prev, - playerY: Math.min(16 - 4, prev.playerY + 0.7) + playerY: Math.min(12, Math.floor(prev.playerY) + 1) })); } }; @@ -246,7 +246,8 @@ export default function PongGame(): ReactNode { newTargetComputerY = 6; // Lazy center-ish position } else { // Target the center of the paddle to the predicted ball position - newTargetComputerY = Math.max(0, Math.min(12, predictedY - 2)); // Clamp to valid range + // Quantize to align with grid cells + newTargetComputerY = Math.max(0, Math.min(12, Math.floor(predictedY - 2))); } } else if (Math.abs(newComputerY - 6) > 3) { // Ball is moving away - occasionally drift towards center @@ -258,9 +259,12 @@ export default function PongGame(): ReactNode { const distanceToTarget = newTargetComputerY - newComputerY; if (Math.abs(distanceToTarget) > 0.1) { - // Smooth, variable movement speed - faster when further from target - const speed = Math.min(0.2, Math.abs(distanceToTarget) * 0.1); - newComputerY += distanceToTarget > 0 ? speed : -speed; + // Quantize computer paddle movement to align with grid cells + if (distanceToTarget > 0) { + newComputerY = Math.min(newTargetComputerY, Math.floor(newComputerY) + 1); + } else { + newComputerY = Math.max(newTargetComputerY, Math.floor(newComputerY) - 1); + } } // Ball bounces off top and bottom walls @@ -413,14 +417,18 @@ export default function PongGame(): ReactNode { const gameTop = (canvas.height - gameHeight) / 2; // Draw player paddle (left) - even when game is not started + // Force integer position for exact grid alignment + const playerYAligned = Math.floor(gameState.playerY); ctx.fillStyle = '#FFD915'; - ctx.fillRect(gameLeft + 0 * gridSize, gameTop + gameState.playerY * gridSize, gridSize, 4 * gridSize); + ctx.fillRect(gameLeft + 0 * gridSize, gameTop + playerYAligned * gridSize, gridSize, 4 * gridSize); // Draw computer paddle (right) - even when game is not started + // Force integer position for exact grid alignment + const computerYAligned = Math.floor(gameState.computerY); ctx.fillStyle = '#FFD915'; ctx.fillRect( gameLeft + (WIDTH - 1) * gridSize, - gameTop + gameState.computerY * gridSize, + gameTop + computerYAligned * gridSize, gridSize, 4 * gridSize ); From 07001b1384ce73b68f15b306abd7d79ad013150e Mon Sep 17 00:00:00 2001 From: Daniil Polienko Date: Thu, 6 Mar 2025 19:20:49 +0300 Subject: [PATCH 5/6] style: adjust width --- app/page.tsx | 2 +- components/common/PongGame.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 8dbb598..cb57613 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -10,7 +10,7 @@ export default function Home(): ReactNode {

{'MOM GONNA'} {'TAKE CARE'}
diff --git a/components/common/PongGame.tsx b/components/common/PongGame.tsx index 6201aef..2b6b2fc 100644 --- a/components/common/PongGame.tsx +++ b/components/common/PongGame.tsx @@ -51,7 +51,7 @@ export default function PongGame(): ReactNode { // Game speed control - higher number = slower game const UPDATE_INTERVAL = 50; // Update game every 50ms const PREDICTION_INTERVAL = 500; // Update AI prediction every 500ms for more human-like behavior - const WIDTH = 26; // 26 cells + const WIDTH = 22; // 26 cells // Initialize game const initGame = useCallback(() => { @@ -455,7 +455,7 @@ export default function PongGame(): ReactNode { // Draw game over message if needed if (gameState.gameOver) { ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; - ctx.fillRect(gameLeft + 8 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); + ctx.fillRect(gameLeft + 6 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); ctx.fillStyle = '#fff'; ctx.font = '24px Arial'; @@ -471,7 +471,7 @@ export default function PongGame(): ReactNode { // Draw pause message if game is paused if (gameState.paused) { ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; - ctx.fillRect(gameLeft + 8 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); + ctx.fillRect(gameLeft + 6 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); ctx.fillStyle = '#fff'; ctx.font = '24px Arial'; From 910311f4f2e6caaee352cfcc0aae092f8f12b07e Mon Sep 17 00:00:00 2001 From: Daniil Polienko Date: Thu, 6 Mar 2025 19:23:01 +0300 Subject: [PATCH 6/6] fix: naming --- components/common/PongGame.tsx | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/components/common/PongGame.tsx b/components/common/PongGame.tsx index 2b6b2fc..45ff8a0 100644 --- a/components/common/PongGame.tsx +++ b/components/common/PongGame.tsx @@ -13,7 +13,7 @@ type TGameState = { ballSpeedY: number; playerScore: number; computerScore: number; - gameOver: boolean; + isGameOver: boolean; paused: boolean; winner: 'player' | 'computer' | null; lastUpdateTime: number; @@ -39,7 +39,7 @@ export default function PongGame(): ReactNode { ballSpeedY: 0, playerScore: 0, computerScore: 0, - gameOver: false, + isGameOver: false, paused: false, winner: null, lastUpdateTime: 0, @@ -64,7 +64,7 @@ export default function PongGame(): ReactNode { ballSpeedY: Math.random() > 0.5 ? 0.3 : -0.3, playerScore: 0, computerScore: 0, - gameOver: false, + isGameOver: false, paused: false, winner: null, lastUpdateTime: Date.now(), @@ -77,13 +77,13 @@ export default function PongGame(): ReactNode { // Toggle pause state const togglePause = useCallback(() => { - if (!gameState.gameOver) { + if (!gameState.isGameOver) { setGameState(prev => ({ ...prev, paused: !prev.paused })); } - }, [gameState.gameOver]); + }, [gameState.isGameOver]); // Handle key presses for player paddle movement useEffect(() => { @@ -100,8 +100,8 @@ export default function PongGame(): ReactNode { return; } - if (gameState.paused || gameState.gameOver) { - if (e.key === 'r' && gameState.gameOver) { + if (gameState.paused || gameState.isGameOver) { + if (e.key === 'r' && gameState.isGameOver) { // Restart game when 'r' is pressed if game is over initGame(); } @@ -127,11 +127,11 @@ export default function PongGame(): ReactNode { return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [isGameStarted, gameState.gameOver, gameState.paused, initGame, togglePause]); + }, [isGameStarted, gameState.isGameOver, gameState.paused, initGame, togglePause]); // Game loop with throttled update const updateGame = useCallback(() => { - if (gameState.gameOver || gameState.paused) { + if (gameState.isGameOver || gameState.paused) { return; } @@ -149,7 +149,7 @@ export default function PongGame(): ReactNode { let newBallSpeedY = prev.ballSpeedY; let newPlayerScore = prev.playerScore; let newComputerScore = prev.computerScore; - let newGameOver = prev.gameOver; + let isNewGameOver = prev.isGameOver; let newWinner = prev.winner; let newComputerY = prev.computerY; let newTargetComputerY = prev.targetComputerY; @@ -310,7 +310,7 @@ export default function PongGame(): ReactNode { // Computer scores newComputerScore++; if (newComputerScore >= 5) { - newGameOver = true; + isNewGameOver = true; newWinner = 'computer'; } else { // Reset ball position @@ -325,7 +325,7 @@ export default function PongGame(): ReactNode { // Player scores newPlayerScore++; if (newPlayerScore >= 5) { - newGameOver = true; + isNewGameOver = true; newWinner = 'player'; } else { // Reset ball position @@ -347,14 +347,14 @@ export default function PongGame(): ReactNode { computerY: newComputerY, playerScore: newPlayerScore, computerScore: newComputerScore, - gameOver: newGameOver, + isGameOver: isNewGameOver, winner: newWinner, lastUpdateTime: currentTime, targetComputerY: newTargetComputerY, lastPredictionTime: newLastPredictionTime }; }); - }, [gameState.gameOver, gameState.paused, gameState.lastUpdateTime, UPDATE_INTERVAL]); + }, [gameState.isGameOver, gameState.paused, gameState.lastUpdateTime, UPDATE_INTERVAL]); // Canvas setup and draw useEffect(() => { @@ -453,7 +453,7 @@ export default function PongGame(): ReactNode { ctx.fillText(`${gameState.playerScore} - ${gameState.computerScore}`, canvas.width / 2, gameTop + 40); // Draw game over message if needed - if (gameState.gameOver) { + if (gameState.isGameOver) { ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(gameLeft + 6 * gridSize, gameTop + 2 * gridSize, 10 * gridSize, 4 * gridSize); @@ -486,7 +486,7 @@ export default function PongGame(): ReactNode { // Game loop - the draw rate is faster than the update rate const animate = (): void => { - if (isGameStarted && !gameState.gameOver && !gameState.paused) { + if (isGameStarted && !gameState.isGameOver && !gameState.paused) { updateGame(); } drawGame();