From d451067ee9832d0a43a3f7c282fec87c57177f50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 10:30:06 +0000 Subject: [PATCH 1/4] refactor: extract components from App.tsx, fix perf bug and lint warnings - Extract ParticleExplosion, TargetSphere, BackgroundParticles, GameScene, HUD, GameControls, GameInstructions, PauseOverlay, GameOverOverlay into src/components/ with barrel export - Fix performance bug: replace setPulseScale useState in useFrame with ref (was causing 60 React re-renders/sec) - Use delta time for TargetSphere rotation animations (frame-rate independent) - Fix all lint warnings: add return types to useAudioManager callbacks - Clean up duplicated CSS declarations in App.css - Add JSDoc documentation to all extracted components Agent-Logs-Url: https://github.com/Hack23/game/sessions/f008753c-31f7-4146-bbda-e6b53fa43687 Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- src/App.css | 79 +-- src/App.tsx | 670 +++---------------------- src/components/BackgroundParticles.tsx | 21 + src/components/GameOverlay.tsx | 296 +++++++++++ src/components/GameScene.tsx | 136 +++++ src/components/HUD.tsx | 100 ++++ src/components/ParticleExplosion.tsx | 132 +++++ src/components/TargetSphere.tsx | 191 +++++++ src/components/index.ts | 26 + src/hooks/useAudioManager.ts | 10 +- 10 files changed, 1007 insertions(+), 654 deletions(-) create mode 100644 src/components/BackgroundParticles.tsx create mode 100644 src/components/GameOverlay.tsx create mode 100644 src/components/GameScene.tsx create mode 100644 src/components/HUD.tsx create mode 100644 src/components/ParticleExplosion.tsx create mode 100644 src/components/TargetSphere.tsx create mode 100644 src/components/index.ts diff --git a/src/App.css b/src/App.css index 9bf3235..03e4937 100644 --- a/src/App.css +++ b/src/App.css @@ -41,55 +41,6 @@ color: #888; } -.app-container { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - width: 100%; - height: 100vh; - margin: 0; - padding: 0; - box-sizing: border-box; - background-color: #0d1117; - color: white; - overflow: hidden; -} - -.app-container h1 { - font-size: 2.5em; - line-height: 1.1; - margin: 0; - color: #646cff; - margin: 10px 0; - font-size: 24px; -} - -.app-container canvas { - border: 2px solid #646cff; - border-radius: 8px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - display: block; - width: 100% !important; - height: calc(100vh - 100px) !important; - max-height: none; - max-width: none; - border: none; -} - -.instructions { - color: #888; - font-style: italic; - margin: 0; - margin: 8px 0; - font-size: 14px; - color: #8b949e; -} - /* Ensure fullscreen across different devices */ html, body, @@ -99,30 +50,29 @@ body, margin: 0; padding: 0; overflow: hidden; -} - -body, -html { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - overflow: hidden; background-color: #0d1117; } .app-container { width: 100%; height: 100vh; + margin: 0; + padding: 0; + box-sizing: border-box; display: flex; flex-direction: column; align-items: center; + text-align: center; + gap: 1rem; + background-color: #0d1117; color: white; + overflow: hidden; } .app-container h1 { margin: 10px 0; font-size: 24px; + line-height: 1.1; color: #646cff; } @@ -132,18 +82,23 @@ html { height: calc(100vh - 100px) !important; max-height: none !important; max-width: none !important; + border: none; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); cursor: crosshair; } .instructions { margin: 8px 0; font-size: 14px; + font-style: italic; color: #8b949e; } /* Animation for pulse effect */ @keyframes pulse { - 0%, 100% { + 0%, + 100% { transform: scale(1); opacity: 1; } @@ -155,11 +110,13 @@ html { /* Animation for glow effect */ @keyframes glow { - 0%, 100% { + 0%, + 100% { text-shadow: 0 0 10px rgba(0, 255, 136, 0.5); } 50% { - text-shadow: 0 0 20px rgba(0, 255, 136, 0.8), 0 0 30px rgba(0, 255, 136, 0.5); + text-shadow: 0 0 20px rgba(0, 255, 136, 0.8), + 0 0 30px rgba(0, 255, 136, 0.5); } } diff --git a/src/App.tsx b/src/App.tsx index fbf399a..610ba40 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,434 +1,27 @@ -import { Canvas, ThreeEvent, useFrame } from "@react-three/fiber"; -import { OrbitControls, Sparkles, Trail } from "@react-three/drei"; -import { useRef, useState, useCallback, useEffect, useMemo } from "react"; +import { Canvas } from "@react-three/fiber"; +import { useRef, useState, useCallback, useEffect } from "react"; import type { JSX } from "react"; -import * as THREE from "three"; -import { useGameState, type GameState } from "./hooks/useGameState"; +import { useGameState } from "./hooks/useGameState"; import { useAudioManager } from "./hooks/useAudioManager"; +import { + PARTICLE_EXPLOSION_DURATION_MS, + GameScene, + HUD, + GameControls, + GameInstructions, + PauseOverlay, + GameOverOverlay, +} from "./components"; import "./App.css"; -// Constants for game mechanics -const PARTICLE_EXPLOSION_DURATION_MS = 600; // Duration of particle explosion animation in milliseconds -const HIT_AREA_MULTIPLIER = 2.5; // Multiplier for invisible hit area to make clicking easier - -// Style constants for UI elements -const OVERLAY_STYLES = { - position: "absolute" as const, - background: "rgba(33, 38, 45, 0.7)", - padding: "12px 20px", - borderRadius: "12px", - backdropFilter: "blur(10px)", - zIndex: 10, -}; - -const INSTRUCTIONS_STYLES = { - ...OVERLAY_STYLES, - bottom: "80px", - left: "50%", - transform: "translateX(-50%)", - fontSize: "16px", - textAlign: "center" as const, -}; - -const AUDIO_STATUS_STYLES = { - position: "absolute" as const, - bottom: "40px", - left: "50%", - transform: "translateX(-50%)", - zIndex: 10, - background: "rgba(33, 38, 45, 0.5)", - padding: "6px 12px", - borderRadius: "8px", - color: "#00ff88", - fontSize: "12px", -}; - -const VOLUME_CONTROL_STYLES = { - display: "flex", - alignItems: "center", - gap: "8px", - background: "rgba(33, 38, 45, 0.7)", - padding: "8px 16px", - borderRadius: "8px", - backdropFilter: "blur(10px)", -}; - /** - * Generate particle positions and colors for explosion effect - * Called once outside the component render cycle + * Root application component that orchestrates the game loop, + * audio integration, and UI overlays. + * + * Architecture: App manages high-level game state and delegates + * 3D rendering to GameScene, HUD display to HUD, and overlays + * to dedicated overlay components. */ -function generateParticleData(): { - particleCount: number; - positions: Float32Array; - colors: Float32Array; -} { - const particleCount = 50; - const positions = new Float32Array(particleCount * 3); - const colors = new Float32Array(particleCount * 3); - - for (let i = 0; i < particleCount; i++) { - const i3 = i * 3; - const theta = Math.random() * Math.PI * 2; - const phi = Math.acos(2 * Math.random() - 1); - const r = 0.3; - - positions[i3] = r * Math.sin(phi) * Math.cos(theta); - positions[i3 + 1] = r * Math.sin(phi) * Math.sin(theta); - positions[i3 + 2] = r * Math.cos(phi); - - // Mix of green and yellow colors - colors[i3] = Math.random() > 0.5 ? 0 : 1; - colors[i3 + 1] = 1; - colors[i3 + 2] = Math.random() > 0.5 ? 0.5 : 0; - } - - return { particleCount, positions, colors }; -} - -interface ParticleExplosionProps { - position: [number, number, number]; - active: boolean; -} - -function ParticleExplosion({ position, active }: ParticleExplosionProps): JSX.Element | null { - const particlesRef = useRef(null); - const [visible, setVisible] = useState(active); - const startTimeRef = useRef(0); - - // Generate particles once using useMemo with external function - const particleData = useMemo(() => generateParticleData(), []); - - useFrame((state) => { - if (!active || !particlesRef.current) return; - - if (startTimeRef.current === 0) { - startTimeRef.current = state.clock.elapsedTime; - } - - const elapsed = state.clock.elapsedTime - startTimeRef.current; - const duration = PARTICLE_EXPLOSION_DURATION_MS / 1000; // Convert ms to seconds - - if (elapsed < duration) { - const scale = 1 + elapsed * 8; - particlesRef.current.scale.set(scale, scale, scale); - - const geometry = particlesRef.current.geometry; - const positionAttr = geometry.attributes.position; - - if (positionAttr?.array) { - const positions = positionAttr.array as Float32Array; - - for (let i = 1; i < positions.length; i += 3) { - const current = positions[i]; - if (current !== undefined) { - positions[i] = current + 0.02; // particles rise up (Y coordinate) - } - } - positionAttr.needsUpdate = true; - } - - const opacity = 1 - elapsed / duration; - (particlesRef.current.material as THREE.PointsMaterial).opacity = opacity; - } else { - setVisible(false); - startTimeRef.current = 0; - } - }); - - if (!visible && !active) return null; - - return ( - - - - - - - - ); -} - -interface TargetSphereProps { - position: [number, number, number]; - onClick: () => void; - isActive: boolean; - size: number; -} - -function TargetSphere({ position, onClick, isActive, size }: TargetSphereProps): JSX.Element { - const meshRef = useRef(null); - const outerRingRef = useRef(null); - const middleRingRef = useRef(null); - const [hovered, setHovered] = useState(false); - const [pulseScale, setPulseScale] = useState(1); - - // Cleanup cursor on unmount - useEffect(() => { - return (): void => { - document.body.style.cursor = 'default'; - }; - }, []); - - // Enhanced rotation and pulse animation - useFrame((state) => { - if (!isActive || !meshRef.current) return; - - meshRef.current.rotation.y += 0.02; - meshRef.current.rotation.x += 0.01; - - // Animate rings - if (outerRingRef.current) { - outerRingRef.current.rotation.z += 0.015; - } - if (middleRingRef.current) { - middleRingRef.current.rotation.z -= 0.02; - } - - // Pulsing effect for hover state - if (hovered) { - const pulse = 1 + Math.sin(state.clock.elapsedTime * 5) * 0.1; - setPulseScale(pulse); - } - }); - - const handleClick = useCallback((e: ThreeEvent) => { - e.stopPropagation(); - if (isActive) { - onClick(); - } - }, [isActive, onClick]); - - const handlePointerOver = useCallback(() => { - if (isActive) { - setHovered(true); - document.body.style.cursor = 'crosshair'; - } - }, [isActive]); - - const handlePointerOut = useCallback(() => { - setHovered(false); - document.body.style.cursor = 'default'; - }, []); - - return ( - - t * t} - > - - - - {/* Animated outer ring for target effect */} - - - - - {/* Animated middle ring */} - - - - - {/* Center dot for precise aiming */} - - - - - - - - {/* Invisible larger hit area for easier clicking */} - - - - - - {/* Enhanced sparkle effect around target */} - {isActive && ( - - )} - - {/* Outer glow ring when hovered */} - {isActive && hovered && ( - - - - - )} - - ); -} - -function BackgroundParticles(): JSX.Element { - return ( - - ); -} - -interface GameSceneProps { - gameState: GameState; - onTargetClick: (targetId: number) => void; - onMiss: () => void; - showExplosion: boolean; - explosionPosition: [number, number, number]; -} - -function GameScene({ gameState, onTargetClick, onMiss, showExplosion, explosionPosition }: GameSceneProps): JSX.Element { - const shakeTimeRef = useRef(0); - const basePositionRef = useRef(new THREE.Vector3(0, 2, 8)); - - const handleTargetClick = useCallback((targetId: number) => { - if (!gameState.isPlaying || gameState.timeLeft <= 0) return; - onTargetClick(targetId); - shakeTimeRef.current = 0.3; // Shake for 300ms - }, [gameState.isPlaying, gameState.timeLeft, onTargetClick]); - - const handleBackgroundClick = useCallback(() => { - if (!gameState.isPlaying || gameState.timeLeft <= 0) return; - onMiss(); - }, [gameState.isPlaying, gameState.timeLeft, onMiss]); - - // Camera shake effect using state.camera from useFrame - useFrame((state, delta) => { - const camera = state.camera; - - if (shakeTimeRef.current > 0) { - shakeTimeRef.current -= delta; - const intensity = shakeTimeRef.current * 0.3; - camera.position.x = basePositionRef.current.x + (Math.random() - 0.5) * intensity; - camera.position.y = basePositionRef.current.y + (Math.random() - 0.5) * intensity; - camera.position.z = basePositionRef.current.z + (Math.random() - 0.5) * intensity; - } else if (shakeTimeRef.current <= 0) { - camera.position.lerp(basePositionRef.current, 0.1); - } - }); - - const isGameOver = gameState.timeLeft <= 0; - - return ( - <> - {/* Enhanced Lighting */} - - - - - - {/* Background particles */} - - - {/* Particle explosion effect */} - {showExplosion && ( - - )} - - {/* Multiple Target Spheres */} - {gameState.targets.map((target) => ( - handleTargetClick(target.id)} - isActive={gameState.isPlaying && !isGameOver} - size={target.size} - /> - ))} - - {/* Improved Grid floor with glow */} - - - - - - - {/* Camera controls */} - - - ); -} - function App(): JSX.Element { const { gameState, incrementScore, recordMiss, resetGame, togglePause } = useGameState(); const audioManager = useAudioManager(); @@ -495,201 +88,102 @@ function App(): JSX.Element { // Optional: play a miss sound or provide visual feedback }, [recordMiss]); - // Expose test API for E2E tests to trigger target clicks - // This bypasses Three.js raycasting which doesn't work reliably in headless CI + // Expose test API for E2E tests to trigger target clicks. + // This bypasses Three.js raycasting which doesn't work reliably in headless CI. useEffect(() => { - const handleTestTargetClick = () => { + const handleTestTargetClick = (): void => { if (gameState.isPlaying && gameState.timeLeft > 0 && gameState.targets.length > 0) { handleTargetClick(gameState.targets[0]?.id ?? 0); } }; - window.addEventListener('test:targetClick', handleTestTargetClick); - return () => window.removeEventListener('test:targetClick', handleTestTargetClick); + window.addEventListener("test:targetClick", handleTestTargetClick); + return (): void => window.removeEventListener("test:targetClick", handleTestTargetClick); }, [handleTargetClick, gameState.isPlaying, gameState.timeLeft, gameState.targets]); - const handleMuteToggle = useCallback(() => { + const handleMuteToggle = useCallback((): void => { const newMuted = !isMuted; setIsMuted(newMuted); audioManager.setMuted(newMuted); }, [isMuted, audioManager]); - const handleVolumeChange = useCallback((event: React.ChangeEvent | React.FormEvent) => { - const newVolume = parseFloat((event.target as HTMLInputElement).value); - setVolume(newVolume); - audioManager.setVolume(newVolume); - }, [audioManager]); + const handleVolumeChange = useCallback( + (event: React.ChangeEvent | React.FormEvent): void => { + const newVolume = parseFloat((event.target as HTMLInputElement).value); + setVolume(newVolume); + audioManager.setVolume(newVolume); + }, + [audioManager], + ); return (

馃幆 Target Shooter

- - {/* HUD Overlay for testing - outside canvas */} -
-
-
TIME
-
{gameState.timeLeft}s
-
- -
-
SCORE
-
{gameState.score}
- {gameState.combo > 0 &&
馃敟 COMBO x{gameState.combo}
} -
- -
-
LEVEL
-
{gameState.level}
- {gameState.highScore > 0 &&
HIGH: {gameState.highScore}
} -
- -
-
ACCURACY
-
- {gameState.totalClicks > 0 ? Math.round((gameState.successfulHits / gameState.totalClicks) * 100) : 100}% -
-
{gameState.successfulHits}/{gameState.totalClicks}
-
- -
-
TARGETS
-
{gameState.targets.length}
-
-
- - {/* Game Status and Controls */} -
-
0 ? "#00ff88" : "#ffa500", fontSize: "14px", fontWeight: "bold" }}> - {gameState.timeLeft <= 0 ? "鈴憋笍 Time's Up!" : gameState.isPlaying ? "馃幆 Active" : "鈴革笍 Paused"} -
- - - -
- - - - {Math.round(volume * 100)}% - -
-
- - {/* Instructions */} -
0 ? "#ffffff" : "#7d8590" }}> - {gameState.timeLeft <= 0 - ? `馃幃 Game Over! Final Score: ${gameState.score} | Accuracy: ${gameState.totalClicks > 0 ? Math.round((gameState.successfulHits / gameState.totalClicks) * 100) : 100}% - Click Reset to play again` - : gameState.isPlaying - ? `馃幆 Click targets to score! ${gameState.targets.length > 1 ? `${gameState.targets.length} targets active!` : ''} Build combos for bonus points! Miss penalty applies.` - : "鈴革笍 Game paused - Resume to continue"} -
- - {/* Audio status note */} - {!isMuted && ( -
馃攰 Sound enabled - Click target to hear effects
)} - + {/* Pause overlay */} - {!gameState.isPlaying && gameState.timeLeft > 0 && ( -
-
鈴革笍
-
GAME PAUSED
-
- )} - + 0} /> + {/* Game Over overlay */} - {gameState.timeLeft <= 0 && ( -
-
馃幃
-
GAME OVER
-
{gameState.score}
-
- {gameState.isNewHighScore && gameState.score > 0 - ? "馃弳 New High Score!" - : `High Score: ${gameState.highScore}`} -
-
- )} - + +
{/* Hidden DOM element for Cypress testing - indicates target sphere exists */} {gameState.isPlaying && gameState.timeLeft > 0 && ( -