|
| 1 | +import React, { useRef, useEffect, useState, useCallback } from 'react'; |
| 2 | +import { Link } from 'react-router-dom'; |
| 3 | +import { ArrowLeftIcon, SoccerBallIcon } from '@phosphor-icons/react'; |
| 4 | +import colors from '../../config/colors'; |
| 5 | +import useSeo from '../../hooks/useSeo'; |
| 6 | +import '../../styles/SoccerPongPage.css'; |
| 7 | + |
| 8 | +// Game Constants |
| 9 | +const GAME_WIDTH = 800; |
| 10 | +const GAME_HEIGHT = 400; |
| 11 | +const PADDLE_WIDTH = 10; |
| 12 | +const PADDLE_HEIGHT = 80; |
| 13 | +const BALL_SIZE = 15; |
| 14 | +const PADDLE_SPEED = 5; |
| 15 | +const BALL_SPEED = 4; |
| 16 | +// const MAX_SCORE = 5; // Removed hardcoded constant |
| 17 | + |
| 18 | +const SoccerPongPage = () => { |
| 19 | + useSeo({ |
| 20 | + title: 'Soccer Pong | Fezcodex', |
| 21 | + description: 'A Pong-style game with a soccer twist. Player vs. AI.', |
| 22 | + keywords: ['Fezcodex', 'soccer pong', 'arcade game', 'pong', 'soccer', 'AI game'], |
| 23 | + ogTitle: 'Soccer Pong | Fezcodex', |
| 24 | + ogDescription: 'A Pong-style game with a soccer twist. Player vs. AI.', |
| 25 | + ogImage: 'https://fezcode.github.io/logo512.png', |
| 26 | + twitterCard: 'summary_large_image', |
| 27 | + twitterTitle: 'Soccer Pong | Fezcodex', |
| 28 | + twitterDescription: 'A Pong-style game with a soccer twist. Player vs. AI.', |
| 29 | + twitterImage: 'https://fezcode.github.io/logo512.png' |
| 30 | + }); |
| 31 | + |
| 32 | + const canvasRef = useRef(null); |
| 33 | + const [playerScore, setPlayerScore] = useState(0); |
| 34 | + const [aiScore, setAiScore] = useState(0); |
| 35 | + const [gameOver, setGameOver] = useState(false); |
| 36 | + const [gameStarted, setGameStarted] = useState(false); |
| 37 | + const [winner, setWinner] = useState(null); |
| 38 | + const [goalAnimation, setGoalAnimation] = useState({ active: false, scorer: null }); |
| 39 | + const [maxScore, setMaxScore] = useState(5); // New state for configurable max score |
| 40 | + |
| 41 | + // Game state (mutable for game loop) |
| 42 | + const gameState = useRef({ |
| 43 | + playerPaddleY: GAME_HEIGHT / 2 - PADDLE_HEIGHT / 2, |
| 44 | + aiPaddleY: GAME_HEIGHT / 2 - PADDLE_HEIGHT / 2, |
| 45 | + ballX: GAME_WIDTH / 2, |
| 46 | + ballY: GAME_HEIGHT / 2, |
| 47 | + ballDx: BALL_SPEED, |
| 48 | + ballDy: BALL_SPEED, |
| 49 | + playerMoveUp: false, |
| 50 | + playerMoveDown: false, |
| 51 | + }); |
| 52 | + |
| 53 | + const resetBall = useCallback(() => { |
| 54 | + gameState.current.ballX = GAME_WIDTH / 2; |
| 55 | + gameState.current.ballY = GAME_HEIGHT / 2; |
| 56 | + gameState.current.ballDx = (Math.random() > 0.5 ? 1 : -1) * BALL_SPEED; |
| 57 | + gameState.current.ballDy = (Math.random() > 0.5 ? 1 : -1) * BALL_SPEED; |
| 58 | + }, []); |
| 59 | + |
| 60 | + const startGame = useCallback(() => { |
| 61 | + setPlayerScore(0); |
| 62 | + setAiScore(0); |
| 63 | + setGameOver(false); |
| 64 | + setWinner(null); |
| 65 | + setGoalAnimation({ active: false, scorer: null }); |
| 66 | + resetBall(); |
| 67 | + gameState.current.playerPaddleY = GAME_HEIGHT / 2 - PADDLE_HEIGHT / 2; |
| 68 | + gameState.current.aiPaddleY = GAME_HEIGHT / 2 - PADDLE_HEIGHT / 2; |
| 69 | + setGameStarted(true); |
| 70 | + }, [resetBall]); |
| 71 | + |
| 72 | + // No useEffect for auto-start, game starts only with button press |
| 73 | + |
| 74 | + useEffect(() => { |
| 75 | + const canvas = canvasRef.current; |
| 76 | + const ctx = canvas.getContext('2d'); |
| 77 | + |
| 78 | + let animationFrameId; |
| 79 | + |
| 80 | + const draw = () => { |
| 81 | + // Clear canvas |
| 82 | + ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT); |
| 83 | + |
| 84 | + // Draw field lines (optional, for soccer feel) |
| 85 | + ctx.strokeStyle = '#ccc'; |
| 86 | + ctx.lineWidth = 2; |
| 87 | + ctx.beginPath(); |
| 88 | + ctx.moveTo(GAME_WIDTH / 2, 0); |
| 89 | + ctx.lineTo(GAME_WIDTH / 2, GAME_HEIGHT); |
| 90 | + ctx.stroke(); |
| 91 | + |
| 92 | + // Draw player paddle |
| 93 | + ctx.fillStyle = '#FF4500'; // OrangeRed |
| 94 | + ctx.fillRect(0, gameState.current.playerPaddleY, PADDLE_WIDTH, PADDLE_HEIGHT); |
| 95 | + |
| 96 | + // Draw AI paddle |
| 97 | + ctx.fillStyle = '#4B0082'; // Indigo |
| 98 | + ctx.fillRect(GAME_WIDTH - PADDLE_WIDTH, gameState.current.aiPaddleY, PADDLE_WIDTH, PADDLE_HEIGHT); |
| 99 | + |
| 100 | + // Draw ball |
| 101 | + ctx.fillStyle = '#FFFFFF'; // White |
| 102 | + ctx.beginPath(); |
| 103 | + ctx.arc(gameState.current.ballX, gameState.current.ballY, BALL_SIZE / 2, 0, Math.PI * 2); |
| 104 | + ctx.fill(); |
| 105 | + }; |
| 106 | + |
| 107 | + const update = () => { |
| 108 | + if (gameOver || goalAnimation.active || !gameStarted) return; // Pause game during goal animation or if not started |
| 109 | + |
| 110 | + // Player paddle movement |
| 111 | + if (gameState.current.playerMoveUp) { |
| 112 | + gameState.current.playerPaddleY = Math.max(0, gameState.current.playerPaddleY - PADDLE_SPEED); |
| 113 | + } |
| 114 | + if (gameState.current.playerMoveDown) { |
| 115 | + gameState.current.playerPaddleY = Math.min(GAME_HEIGHT - PADDLE_HEIGHT, gameState.current.playerPaddleY + PADDLE_SPEED); |
| 116 | + } |
| 117 | + |
| 118 | + // AI paddle movement (simple AI) |
| 119 | + if (gameState.current.ballY < gameState.current.aiPaddleY + PADDLE_HEIGHT / 2) { |
| 120 | + gameState.current.aiPaddleY = Math.max(0, gameState.current.aiPaddleY - PADDLE_SPEED * 0.8); // AI is a bit slower |
| 121 | + } else if (gameState.current.ballY > gameState.current.aiPaddleY + PADDLE_HEIGHT / 2) { |
| 122 | + gameState.current.aiPaddleY = Math.min(GAME_HEIGHT - PADDLE_HEIGHT, gameState.current.aiPaddleY + PADDLE_SPEED * 0.8); |
| 123 | + } |
| 124 | + |
| 125 | + // Ball movement |
| 126 | + gameState.current.ballX += gameState.current.ballDx; |
| 127 | + gameState.current.ballY += gameState.current.ballDy; |
| 128 | + |
| 129 | + // Ball collision with top/bottom walls |
| 130 | + if (gameState.current.ballY - BALL_SIZE / 2 < 0 || gameState.current.ballY + BALL_SIZE / 2 > GAME_HEIGHT) { |
| 131 | + gameState.current.ballDy *= -1; |
| 132 | + } |
| 133 | + |
| 134 | + // Ball collision with player paddle |
| 135 | + if ( |
| 136 | + gameState.current.ballX - BALL_SIZE / 2 < PADDLE_WIDTH && |
| 137 | + gameState.current.ballY + BALL_SIZE / 2 > gameState.current.playerPaddleY && |
| 138 | + gameState.current.ballY - BALL_SIZE / 2 < gameState.current.playerPaddleY + PADDLE_HEIGHT |
| 139 | + ) { |
| 140 | + gameState.current.ballDx *= -1; |
| 141 | + // Add a slight random angle change for more dynamic play |
| 142 | + gameState.current.ballDy += (Math.random() - 0.5) * 2; |
| 143 | + } |
| 144 | + |
| 145 | + // Ball collision with AI paddle |
| 146 | + if ( |
| 147 | + gameState.current.ballX + BALL_SIZE / 2 > GAME_WIDTH - PADDLE_WIDTH && |
| 148 | + gameState.current.ballY + BALL_SIZE / 2 > gameState.current.aiPaddleY && |
| 149 | + gameState.current.ballY - BALL_SIZE / 2 < gameState.current.aiPaddleY + PADDLE_HEIGHT |
| 150 | + ) { |
| 151 | + gameState.current.ballDx *= -1; |
| 152 | + // Add a slight random angle change |
| 153 | + gameState.current.ballDy += (Math.random() - 0.5) * 2; |
| 154 | + } |
| 155 | + |
| 156 | + // Scoring |
| 157 | + if (gameState.current.ballX - BALL_SIZE / 2 < 0) { // AI scores |
| 158 | + setGoalAnimation({ active: true, scorer: 'AI' }); |
| 159 | + setTimeout(() => { |
| 160 | + setGoalAnimation({ active: false, scorer: null }); |
| 161 | + setAiScore(prev => { |
| 162 | + const newScore = prev + 1; |
| 163 | + if (newScore >= maxScore) { // Use maxScore here |
| 164 | + setGameOver(true); |
| 165 | + setWinner('AI'); |
| 166 | + } else { |
| 167 | + resetBall(); |
| 168 | + } |
| 169 | + return newScore; |
| 170 | + }); |
| 171 | + }, 1000); // Animation duration |
| 172 | + } else if (gameState.current.ballX + BALL_SIZE / 2 > GAME_WIDTH) { // Player scores |
| 173 | + setGoalAnimation({ active: true, scorer: 'Player' }); |
| 174 | + setTimeout(() => { |
| 175 | + setGoalAnimation({ active: false, scorer: null }); |
| 176 | + setPlayerScore(prev => { |
| 177 | + const newScore = prev + 1; |
| 178 | + if (newScore >= maxScore) { // Use maxScore here |
| 179 | + setGameOver(true); |
| 180 | + setWinner('Player'); |
| 181 | + } else { |
| 182 | + resetBall(); |
| 183 | + } |
| 184 | + return newScore; |
| 185 | + }); |
| 186 | + }, 1000); // Animation duration |
| 187 | + } |
| 188 | + }; |
| 189 | + |
| 190 | + const gameLoop = () => { |
| 191 | + update(); |
| 192 | + draw(); |
| 193 | + animationFrameId = requestAnimationFrame(gameLoop); |
| 194 | + }; |
| 195 | + |
| 196 | + if (gameStarted && !gameOver) { |
| 197 | + gameLoop(); |
| 198 | + } |
| 199 | + |
| 200 | + return () => { |
| 201 | + cancelAnimationFrame(animationFrameId); |
| 202 | + }; |
| 203 | + }, [gameStarted, gameOver, resetBall, goalAnimation.active, maxScore]); // Add maxScore to dependencies |
| 204 | + |
| 205 | + // Keyboard controls |
| 206 | + useEffect(() => { |
| 207 | + const handleKeyDown = (e) => { |
| 208 | + if (e.key.toLowerCase() === 'w') gameState.current.playerMoveUp = true; |
| 209 | + if (e.key.toLowerCase() === 's') gameState.current.playerMoveDown = true; |
| 210 | + }; |
| 211 | + |
| 212 | + const handleKeyUp = (e) => { |
| 213 | + if (e.key.toLowerCase() === 'w') gameState.current.playerMoveUp = false; |
| 214 | + if (e.key.toLowerCase() === 's') gameState.current.playerMoveDown = false; |
| 215 | + }; |
| 216 | + |
| 217 | + window.addEventListener('keydown', handleKeyDown); |
| 218 | + window.addEventListener('keyup', handleKeyUp); |
| 219 | + |
| 220 | + return () => { |
| 221 | + window.removeEventListener('keydown', handleKeyDown); |
| 222 | + window.removeEventListener('keyup', handleKeyUp); |
| 223 | + }; |
| 224 | + }, []); |
| 225 | + |
| 226 | + const cardStyle = { |
| 227 | + backgroundColor: colors['app-alpha-10'], |
| 228 | + borderColor: colors['app-alpha-50'], |
| 229 | + color: colors.app, |
| 230 | + }; |
| 231 | + |
| 232 | + return ( |
| 233 | + <div className="py-16 sm:py-24"> |
| 234 | + <div className="mx-auto max-w-7xl px-6 lg:px-8 text-gray-300"> |
| 235 | + <Link |
| 236 | + to="/apps" |
| 237 | + className="text-article hover:underline flex items-center justify-center gap-2 text-lg mb-4" |
| 238 | + > |
| 239 | + <ArrowLeftIcon size={24} /> Back to Apps |
| 240 | + </Link> |
| 241 | + <h1 className="text-4xl font-bold tracking-tight sm:text-6xl mb-4 flex items-center"> |
| 242 | + <span className="codex-color">fc</span> |
| 243 | + <span className="separator-color">::</span> |
| 244 | + <span className="apps-color">apps</span> |
| 245 | + <span className="separator-color">::</span> |
| 246 | + <span className="single-app-color">soccer-pong</span> |
| 247 | + </h1> |
| 248 | + <hr className="border-gray-700" /> |
| 249 | + <div className="flex justify-center items-center mt-16"> |
| 250 | + <div |
| 251 | + className="group bg-transparent border rounded-lg shadow-2xl p-6 flex flex-col justify-between relative transform overflow-hidden h-full w-full max-w-4xl" |
| 252 | + style={cardStyle} |
| 253 | + > |
| 254 | + <div |
| 255 | + className="absolute top-0 left-0 w-full h-full opacity-10" |
| 256 | + style={{ |
| 257 | + backgroundImage: |
| 258 | + 'radial-gradient(circle, white 1px, transparent 1px)', |
| 259 | + backgroundSize: '10px 10px', |
| 260 | + }} |
| 261 | + ></div> |
| 262 | + <div className="relative z-10 p-1"> |
| 263 | + <h1 className="text-3xl font-arvo font-normal mb-4 text-app flex items-center gap-2"> |
| 264 | + <SoccerBallIcon size={32} /> Soccer Pong |
| 265 | + </h1> |
| 266 | + <hr className="border-gray-700 mb-4" /> |
| 267 | + |
| 268 | + <div className="game-area"> |
| 269 | + <div className="score-board mb-4 text-xl font-bold"> |
| 270 | + Player: {playerScore} - AI: {aiScore} |
| 271 | + </div> |
| 272 | + <div className="flex justify-center items-center gap-4 mb-2"> |
| 273 | + <div className="controls-display text-sm"> |
| 274 | + W: Up, S: Down |
| 275 | + </div> |
| 276 | + <div className="max-score-config text-sm"> |
| 277 | + Score to Win: |
| 278 | + <input |
| 279 | + type="number" |
| 280 | + min="1" |
| 281 | + value={maxScore} |
| 282 | + onChange={(e) => setMaxScore(Math.max(1, parseInt(e.target.value)))} |
| 283 | + className="ml-2 w-16 bg-gray-800 text-white border border-gray-600 rounded px-2 py-1" |
| 284 | + disabled={gameStarted} // Disable input when game has started |
| 285 | + /> |
| 286 | + </div> |
| 287 | + </div> |
| 288 | + {goalAnimation.active && ( |
| 289 | + <div className={`goal-animation ${goalAnimation.scorer === 'Player' ? 'player-goal' : 'ai-goal'}`}> |
| 290 | + GOAL! |
| 291 | + </div> |
| 292 | + )} |
| 293 | + {gameOver && ( |
| 294 | + <div className="game-over-message text-center text-3xl font-bold mb-4"> |
| 295 | + {winner === 'Player' ? 'You Win!' : 'AI Wins!'} |
| 296 | + <button |
| 297 | + onClick={startGame} |
| 298 | + className="block mx-auto mt-4 px-6 py-2 rounded-md text-lg font-arvo font-normal transition-colors duration-300 ease-in-out" |
| 299 | + style={{ |
| 300 | + backgroundColor: 'rgba(0, 0, 0, 0.2)', |
| 301 | + color: cardStyle.color, |
| 302 | + borderColor: cardStyle.borderColor, |
| 303 | + border: '1px solid', |
| 304 | + }} |
| 305 | + > |
| 306 | + Play Again |
| 307 | + </button> |
| 308 | + </div> |
| 309 | + )} |
| 310 | + {!gameStarted && ( |
| 311 | + <div className="start-game-message text-center text-3xl font-bold mb-4"> |
| 312 | + Click "Play Game" to Play! |
| 313 | + <button |
| 314 | + onClick={startGame} |
| 315 | + className="block mx-auto mt-4 px-6 py-2 rounded-md text-lg font-arvo font-normal transition-colors duration-300 ease-in-out" |
| 316 | + style={{ |
| 317 | + backgroundColor: 'rgba(0, 0, 0, 0.2)', |
| 318 | + color: cardStyle.color, |
| 319 | + borderColor: cardStyle.borderColor, |
| 320 | + border: '1px solid', |
| 321 | + }} |
| 322 | + > |
| 323 | + Play Game |
| 324 | + </button> |
| 325 | + </div> |
| 326 | + )} |
| 327 | + <canvas |
| 328 | + ref={canvasRef} |
| 329 | + width={GAME_WIDTH} |
| 330 | + height={GAME_HEIGHT} |
| 331 | + className="border border-gray-600 bg-gray-900" |
| 332 | + ></canvas> |
| 333 | + </div> |
| 334 | + </div> |
| 335 | + </div> |
| 336 | + </div> |
| 337 | + </div> |
| 338 | + </div> |
| 339 | + ); |
| 340 | +}; |
| 341 | + |
| 342 | +export default SoccerPongPage; |
0 commit comments