Skip to content

Commit 5cd20b5

Browse files
committed
new(apps): pong game
1 parent 76077ac commit 5cd20b5

File tree

5 files changed

+442
-0
lines changed

5 files changed

+442
-0
lines changed

public/apps/apps.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@
6060
"title": "Higher or Lower Card Game",
6161
"description": "Guess if the next card will be higher or lower than the current one.",
6262
"icon": "CardsThreeIcon"
63+
},
64+
{
65+
"slug": "soccer-pong",
66+
"to": "/apps/soccer-pong",
67+
"title": "Soccer Pong",
68+
"description": "A Pong-style game with a soccer twist. Player vs. AI.",
69+
"icon": "SoccerBallIcon"
6370
}
6471
]
6572
},

src/components/AnimatedRoutes.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import ExcuseGeneratorPage from '../pages/apps/ExcuseGeneratorPage';
4545
import MagicEightBallPage from '../pages/apps/MagicEightBallPage';
4646
import JSONGeneratorPage from '../pages/apps/JSONGeneratorPage';
4747
import CardGamePage from '../pages/apps/CardGamePage';
48+
import SoccerPongPage from '../pages/apps/SoccerPongPage'; // Import SoccerPongPage
4849
import SettingsPage from '../pages/SettingsPage';
4950

5051
import UsefulLinksPage from '../pages/UsefulLinksPage';
@@ -401,6 +402,7 @@ function AnimatedRoutes() {
401402
<Route path="/apps::excuse" element={<Navigate to="/apps/excuse-generator" replace />} />
402403
<Route path="/apps::8ball" element={<Navigate to="/apps/magic-8-ball" replace />} />
403404
<Route path="/apps::card" element={<Navigate to="/apps/card-game" replace />} />
405+
<Route path="/apps::sp" element={<Navigate to="/apps/soccer-pong" replace />} />
404406
{/* End of hardcoded redirects */}
405407
<Route
406408
path="/apps/ip"
@@ -430,6 +432,20 @@ function AnimatedRoutes() {
430432
</motion.div>
431433
}
432434
/>
435+
<Route
436+
path="/apps/soccer-pong"
437+
element={
438+
<motion.div
439+
initial="initial"
440+
animate="in"
441+
exit="out"
442+
variants={pageVariants}
443+
transition={pageTransition}
444+
>
445+
<SoccerPongPage />
446+
</motion.div>
447+
}
448+
/>
433449
<Route
434450
path="/apps/cron-job-generator"
435451
element={

src/pages/apps/SoccerPongPage.js

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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

Comments
 (0)