From 3f2219296a24ced10e8bef3a8a8063f77269cf26 Mon Sep 17 00:00:00 2001 From: Yevhenii Datsenko Date: Thu, 22 Jan 2026 18:12:07 +0200 Subject: [PATCH] (SP: 1) [Frontend] About Us Page. Fixed game, topics, mobile layout - Fixed mobile tabs in FeaturesSection (icon-only on mobile) - Fixed game bugs: collision detection, animation, scoring system - Added multiple obstacle types with level progression - Improved game sizing for mobile while preserving desktop - Updated TopicsSection with local SVG icons and hover borders - Made DynamicGridBackground static grid opt-in via showStaticGrid prop - Limited SponsorsWall to display max 10 sponsors - Optimized CommunitySection button layout for mobile --- frontend/app/globals.css | 9 + .../components/about/CommunitySection.tsx | 47 +- frontend/components/about/FeaturesSection.tsx | 67 +-- frontend/components/about/HeroSection.tsx | 2 +- frontend/components/about/InteractiveGame.tsx | 508 +++++++++++++----- frontend/components/about/SponsorsWall.tsx | 5 +- frontend/components/about/TopicsSection.tsx | 6 +- .../shared/DynamicGridBackground.tsx | 10 +- frontend/data/about.ts | 43 +- 9 files changed, 485 insertions(+), 212 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index c8da09dc..1aca32ae 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -251,3 +251,12 @@ .hover\:pause:hover .animate-marquee-vertical { animation-play-state: paused; } + +/* Hide scrollbar for horizontal scroll containers */ +.scrollbar-hide { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} +.scrollbar-hide::-webkit-scrollbar { + display: none; /* Chrome, Safari and Opera */ +} diff --git a/frontend/components/about/CommunitySection.tsx b/frontend/components/about/CommunitySection.tsx index 9c222e9f..f8232dd5 100644 --- a/frontend/components/about/CommunitySection.tsx +++ b/frontend/components/about/CommunitySection.tsx @@ -46,27 +46,46 @@ export function CommunitySection() {
- + + Have a success story or feature request? + + + + Join Discussion + + +
+ + -
- - Have a success story or feature request? - -
- - + Have a success story or feature request? + + + diff --git a/frontend/components/about/FeaturesSection.tsx b/frontend/components/about/FeaturesSection.tsx index 8faf9652..3d1839b5 100644 --- a/frontend/components/about/FeaturesSection.tsx +++ b/frontend/components/about/FeaturesSection.tsx @@ -128,39 +128,40 @@ export function FeaturesSection() { -
- {features.map((feature) => { - const isActive = activeTab === feature.id - return ( - - ) - })} +
+
+ {features.map((feature) => { + const isActive = activeTab === feature.id + return ( + + ) + })} +
diff --git a/frontend/components/about/HeroSection.tsx b/frontend/components/about/HeroSection.tsx index 8f855d4c..d3765e89 100644 --- a/frontend/components/about/HeroSection.tsx +++ b/frontend/components/about/HeroSection.tsx @@ -15,7 +15,7 @@ export function HeroSection({ stats }: { stats?: PlatformStats }) { } return ( - +
diff --git a/frontend/components/about/InteractiveGame.tsx b/frontend/components/about/InteractiveGame.tsx index e36be3ed..47f34c70 100644 --- a/frontend/components/about/InteractiveGame.tsx +++ b/frontend/components/about/InteractiveGame.tsx @@ -2,62 +2,192 @@ import { useState, useEffect, useRef, useCallback } from "react" import { motion, AnimatePresence } from "framer-motion" -import { Heart, Bug, Play, X, RotateCcw, Zap } from "lucide-react" +import { Heart, Bug, Play, X, RotateCcw, Zap, Skull, Rabbit } from "lucide-react" + +const GAME_WIDTH_DESKTOP = 540 +const GAME_WIDTH_MOBILE = 320 +const IDLE_WIDTH_DESKTOP = 280 +const IDLE_WIDTH_MOBILE = 240 + +const GROUND_Y_DESKTOP = 32 +const GROUND_Y_MOBILE = 24 +const PLAYER_X_DESKTOP = 64 +const PLAYER_X_MOBILE = 40 +const PLAYER_SIZE_DESKTOP = 40 +const PLAYER_SIZE_MOBILE = 32 +const JUMP_HEIGHT_DESKTOP = 90 +const JUMP_HEIGHT_MOBILE = 70 +const JUMP_DURATION = 500 + +type ObstacleType = 'ground' | 'flying' | 'fast' | 'tall' + +interface Obstacle { + type: ObstacleType + x: number + size: number + heightOffset: number + speedMultiplier: number +} + +const OBSTACLE_CONFIGS: Record> = { + ground: { type: 'ground', size: 32, heightOffset: 0, speedMultiplier: 1 }, + flying: { type: 'flying', size: 28, heightOffset: 45, speedMultiplier: 1 }, + fast: { type: 'fast', size: 24, heightOffset: 0, speedMultiplier: 1.2 }, + tall: { type: 'tall', size: 38, heightOffset: 0, speedMultiplier: 0.95 }, +} + +const LEVEL_THRESHOLDS = [ + { score: 0, types: ['ground'] as ObstacleType[], name: 'Level 1', baseSpeed: 3.5 }, + { score: 8, types: ['ground', 'fast'] as ObstacleType[], name: 'Level 2', baseSpeed: 4 }, + { score: 18, types: ['ground', 'fast', 'flying'] as ObstacleType[], name: 'Level 3', baseSpeed: 4.5 }, + { score: 30, types: ['ground', 'fast', 'flying', 'tall'] as ObstacleType[], name: 'Level 4', baseSpeed: 5 }, +] + +function getBaseSpeed(score: number): number { + let speed = 3.5 + for (const level of LEVEL_THRESHOLDS) { + if (score >= level.score) { + speed = level.baseSpeed + } + } + return speed +} + +function getAvailableTypes(score: number): ObstacleType[] { + let types: ObstacleType[] = ['ground'] + for (const level of LEVEL_THRESHOLDS) { + if (score >= level.score) { + types = level.types + } + } + return types +} + +function getCurrentLevel(score: number): string { + let name = 'Level 1' + for (const level of LEVEL_THRESHOLDS) { + if (score >= level.score) { + name = level.name + } + } + return name +} + +function getRandomObstacle(score: number, gameWidth: number): Obstacle { + const types = getAvailableTypes(score) + let selectedType: ObstacleType + const rand = Math.random() + + if (types.length === 1) { + selectedType = 'ground' + } else if (types.length === 2) { + selectedType = rand < 0.7 ? 'ground' : types[1] + } else if (types.length === 3) { + if (rand < 0.5) selectedType = 'ground' + else if (rand < 0.75) selectedType = types[1] + else selectedType = types[2] + } else { + if (rand < 0.4) selectedType = 'ground' + else if (rand < 0.6) selectedType = types[1] + else if (rand < 0.8) selectedType = types[2] + else selectedType = types[3] + } + + return { + ...OBSTACLE_CONFIGS[selectedType], + x: gameWidth + 40, + } +} export function InteractiveGame() { const [mode, setMode] = useState<'idle' | 'preview' | 'playing'>('idle') const [gameOver, setGameOver] = useState(false) const [score, setScore] = useState(0) - - const [highScore, setHighScore] = useState(() => { - if (typeof window !== 'undefined') { - const saved = localStorage.getItem('devlovers_highscore') - return saved ? parseInt(saved, 10) : 0 - } - return 0 - }) + const [levelUpFlash, setLevelUpFlash] = useState(false) + const [isMobile, setIsMobile] = useState(false) - const [isJumping, setIsJumping] = useState(false) - const [gameSpeed, setGameSpeed] = useState(1.8) - const [resetKey, setResetKey] = useState(0) + const gameWidth = isMobile ? GAME_WIDTH_MOBILE : GAME_WIDTH_DESKTOP + const idleWidth = isMobile ? IDLE_WIDTH_MOBILE : IDLE_WIDTH_DESKTOP + const playerX = isMobile ? PLAYER_X_MOBILE : PLAYER_X_DESKTOP + const groundY = isMobile ? GROUND_Y_MOBILE : GROUND_Y_DESKTOP + const playerSize = isMobile ? PLAYER_SIZE_MOBILE : PLAYER_SIZE_DESKTOP + const jumpHeight = isMobile ? JUMP_HEIGHT_MOBILE : JUMP_HEIGHT_DESKTOP + const obstacleEndX = -60 - const pillRef = useRef(null) + const [highScore, setHighScore] = useState(0) + + useEffect(() => { + const checkMobile = () => setIsMobile(window.innerWidth < 640) + checkMobile() + window.addEventListener('resize', checkMobile) + return () => window.removeEventListener('resize', checkMobile) + }, []) + + useEffect(() => { + const saved = localStorage.getItem('devlovers_highscore') + if (saved) setHighScore(parseInt(saved, 10)) + }, []) + + const [playerY, setPlayerY] = useState(0) + const [obstacle, setObstacle] = useState(() => getRandomObstacle(0, GAME_WIDTH_DESKTOP)) + const [gameSpeed, setGameSpeed] = useState(3.5) + + const isJumpingRef = useRef(false) + const jumpStartTime = useRef(0) + const lastTimeRef = useRef(0) const requestRef = useRef(null) - const playerRef = useRef(null) - const obstacleRef = useRef(null) + const pillRef = useRef(null) + const hasPassedRef = useRef(false) + const prevLevelRef = useRef('Level 1') const exitGame = useCallback(() => { setMode('idle') setGameOver(false) setScore(0) - setGameSpeed(1.8) + setPlayerY(0) + setObstacle(getRandomObstacle(0, gameWidth)) + setGameSpeed(3.5) + isJumpingRef.current = false + hasPassedRef.current = false + prevLevelRef.current = 'Level 1' if (requestRef.current !== null) cancelAnimationFrame(requestRef.current) - }, []) + }, [gameWidth]) const handleRetry = useCallback(() => { setGameOver(false) setScore(0) - setGameSpeed(1.8) - setResetKey(prev => prev + 1) - }, []) + setPlayerY(0) + setObstacle(getRandomObstacle(0, gameWidth)) + setGameSpeed(3.5) + isJumpingRef.current = false + hasPassedRef.current = false + lastTimeRef.current = 0 + prevLevelRef.current = 'Level 1' + }, [gameWidth]) - const handleGameOver = useCallback(() => { + const handleGameOver = useCallback((finalScore: number) => { setGameOver(true) - setHighScore(prev => { - const newHigh = Math.max(prev, score) - if (typeof window !== 'undefined') { - localStorage.setItem('devlovers_highscore', newHigh.toString()) - } - return newHigh - }) - }, [score]) + if (finalScore > highScore) { + setHighScore(finalScore) + localStorage.setItem('devlovers_highscore', finalScore.toString()) + } + }, [highScore]) const jump = useCallback(() => { - if (!isJumping && mode === 'playing' && !gameOver) { - setIsJumping(true) - setTimeout(() => setIsJumping(false), 550) + if (!isJumpingRef.current && mode === 'playing' && !gameOver) { + isJumpingRef.current = true + jumpStartTime.current = performance.now() } - }, [isJumping, mode, gameOver]) + }, [mode, gameOver]) + + useEffect(() => { + const currentLevel = getCurrentLevel(score) + if (currentLevel !== prevLevelRef.current && score > 0) { + prevLevelRef.current = currentLevel + setLevelUpFlash(true) + setTimeout(() => setLevelUpFlash(false), 500) + } + }, [score]) useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -74,8 +204,11 @@ export function InteractiveGame() { if (mode !== 'playing') return if (e.code === "Space" || e.code === "ArrowUp") { e.preventDefault() - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - gameOver ? handleRetry() : jump() + if (gameOver) { + handleRetry() + } else { + jump() + } } if (e.code === "Escape") exitGame() } @@ -84,42 +217,136 @@ export function InteractiveGame() { }, [mode, gameOver, jump, handleRetry, exitGame]) useEffect(() => { - if (mode !== 'playing' || gameOver) return + if (mode !== 'playing' || gameOver) { + if (requestRef.current !== null) cancelAnimationFrame(requestRef.current) + return + } + + const gameLoop = (currentTime: number) => { + if (lastTimeRef.current === 0) { + lastTimeRef.current = currentTime + } + + const deltaTime = (currentTime - lastTimeRef.current) / 1000 + lastTimeRef.current = currentTime + + let newPlayerY = 0 + if (isJumpingRef.current) { + const jumpElapsed = currentTime - jumpStartTime.current + const jumpProgress = jumpElapsed / JUMP_DURATION + + if (jumpProgress >= 1) { + isJumpingRef.current = false + newPlayerY = 0 + } else { + newPlayerY = jumpHeight * Math.sin(jumpProgress * Math.PI) + } + } + setPlayerY(newPlayerY) - const checkFrame = () => { - const player = playerRef.current - const obstacle = obstacleRef.current + setObstacle(prevObstacle => { + const effectiveSpeed = gameSpeed * prevObstacle.speedMultiplier + let newX = prevObstacle.x - effectiveSpeed * deltaTime * 100 + const obstacleRight = newX + prevObstacle.size - if (player && obstacle) { - const p = player.getBoundingClientRect() - const o = obstacle.getBoundingClientRect() + if (!hasPassedRef.current && obstacleRight < playerX) { + hasPassedRef.current = true + setScore(s => { + const newScore = s + 1 + const newBaseSpeed = getBaseSpeed(newScore) + setGameSpeed(newBaseSpeed) + return newScore + }) + } + + if (newX < obstacleEndX) { + hasPassedRef.current = false + return getRandomObstacle(score, gameWidth) + } + + return { ...prevObstacle, x: newX } + }) + + setObstacle(currentObstacle => { + const pLeft = playerX + 8 + const pRight = playerX + playerSize - 8 + const playerBottom = groundY + newPlayerY + const playerTop = playerBottom + playerSize - 8 - const paddingX = 16 - const paddingY = 12 - - const isColliding = - p.right - paddingX > o.left + paddingX && - p.left + paddingX < o.right - paddingX && - p.bottom - paddingY > o.top + paddingY + const oLeft = currentObstacle.x + 6 + const oRight = currentObstacle.x + currentObstacle.size - 6 + const obstacleBottom = groundY + currentObstacle.heightOffset + const obstacleTop = obstacleBottom + currentObstacle.size - 4 + + const isColliding = + pRight > oLeft && + pLeft < oRight && + playerBottom < obstacleTop && + playerTop > obstacleBottom if (isColliding) { - handleGameOver() - return + setScore(s => { + handleGameOver(s) + return s + }) } - setScore(s => { - const newScore = s + 1 - if (newScore % 300 === 0) { - setGameSpeed(prev => Math.max(0.7, prev * 0.95)) - } - return newScore - }) - } - requestRef.current = requestAnimationFrame(checkFrame) + return currentObstacle + }) + + requestRef.current = requestAnimationFrame(gameLoop) } - requestRef.current = requestAnimationFrame(checkFrame) - return () => { if (requestRef.current !== null) cancelAnimationFrame(requestRef.current) } - }, [mode, gameOver, handleGameOver, resetKey]) + + lastTimeRef.current = 0 + requestRef.current = requestAnimationFrame(gameLoop) + + return () => { + if (requestRef.current !== null) cancelAnimationFrame(requestRef.current) + } + }, [mode, gameOver, gameSpeed, handleGameOver, score, playerX, obstacleEndX, gameWidth, groundY, playerSize, jumpHeight]) + + const renderObstacle = (obs: Obstacle) => { + const baseClasses = "transition-colors" + + switch (obs.type) { + case 'flying': + return ( +
+ +
+
+ ) + case 'fast': + return ( +
+ +
+ ) + case 'tall': + return ( +
+ +
+
+ ) + default: + return ( + + ) + } + } return ( mode === 'idle' && setMode('preview')} onHoverEnd={() => mode === 'preview' && setMode('idle')} - className="relative mb-14 select-none border border-neutral-200 dark:border-white/10 bg-white/80 dark:bg-[#0a0a0a]/80 backdrop-blur-xl shadow-2xl z-50 mx-auto group overflow-hidden" + className="relative mb-8 md:mb-14 select-none border border-neutral-200/50 dark:border-white/10 bg-white/40 dark:bg-white/5 backdrop-blur-md shadow-xl z-50 mx-auto group overflow-hidden" onClick={() => mode !== 'playing' && setMode('playing')} > + + {levelUpFlash && ( + + )} + + {mode === 'idle' ? ( - -
- - +
+ + DevLovers Arcade
- READY + READY
) : ( - -
-
+
+
- Status + + {getCurrentLevel(score)} + {gameOver ? "CRASHED" : "RUNNING"}
- -
+ +
+
+ + {getCurrentLevel(score)} + +
+ +
- High - - {Math.floor(highScore / 5).toString().padStart(4, '0')} + High + + {highScore.toString().padStart(3, '0')}
-
+
- Score - - {Math.floor(score / 5).toString().padStart(5, '0')} + Score + + {score.toString().padStart(3, '0')}
-
-
- -
- -
- -
-
- -
- +
+
+ +
10 ? 'rotate(-15deg) scale(0.95)' : 'rotate(0deg) scale(1)', + transition: 'transform 0.1s ease-out' + }} + > +
+
+ +
{mode === 'playing' && ( -
- + {renderObstacle(obstacle)}
)} @@ -240,8 +487,8 @@ export function InteractiveGame() { )} {gameOver && ( -
@@ -249,8 +496,8 @@ export function InteractiveGame() { System Failure
-
)} - -
+ +
)} - - ) } diff --git a/frontend/components/about/SponsorsWall.tsx b/frontend/components/about/SponsorsWall.tsx index 1b2b30d8..07415439 100644 --- a/frontend/components/about/SponsorsWall.tsx +++ b/frontend/components/about/SponsorsWall.tsx @@ -11,9 +11,10 @@ interface SponsorsWallProps { sponsors?: Sponsor[] } +const MAX_SPONSORS = 10 + export function SponsorsWall({ sponsors = [] }: SponsorsWallProps) { - - const displaySponsors = sponsors + const displaySponsors = sponsors.slice(0, MAX_SPONSORS) return (
diff --git a/frontend/components/about/TopicsSection.tsx b/frontend/components/about/TopicsSection.tsx index e3e82bd3..48b70326 100644 --- a/frontend/components/about/TopicsSection.tsx +++ b/frontend/components/about/TopicsSection.tsx @@ -67,11 +67,11 @@ function TopicCard({ topic, index }: { topic: Topic, index: number }) {
- {topic.name}
diff --git a/frontend/components/shared/DynamicGridBackground.tsx b/frontend/components/shared/DynamicGridBackground.tsx index 554117f9..fb8f4d99 100644 --- a/frontend/components/shared/DynamicGridBackground.tsx +++ b/frontend/components/shared/DynamicGridBackground.tsx @@ -8,11 +8,13 @@ import { cn } from '@/lib/utils'; type DynamicGridBackgroundProps = { className?: string; children?: ReactNode; + showStaticGrid?: boolean; }; export function DynamicGridBackground({ className, children, + showStaticGrid = false, }: DynamicGridBackgroundProps) { const mouseX = useMotionValue(0); const mouseY = useMotionValue(0); @@ -29,9 +31,11 @@ export function DynamicGridBackground({ onMouseMove={handleMouseMove} className={cn('group/dynamic relative overflow-hidden', className)} > -
-
-
+ {showStaticGrid && ( +
+
+
+ )}