// --- Get Elements --- const gameArea = document.getElementById('gameArea'); const paddleLeft = document.getElementById('paddleLeft'); const paddleRight = document.getElementById('paddleRight'); const ball = document.getElementById('ball'); const powerUpElement = document.getElementById('powerUp'); const playerScoreDisplay = document.getElementById('playerScore'); const botScoreDisplay = document.getElementById('botScore'); const chargeIndicator = document.getElementById('chargeIndicator'); const chargeIndicatorFill = document.getElementById('chargeIndicatorFill'); // --- Constants --- const BASE_PADDLE_HEIGHT = 80; const PADDLE_WIDTH = 10; const BALL_SIZE = 15; const MAX_ANGLE_DEG = 60; // Increased max angle for more variation const MAX_ANGLE_RAD = MAX_ANGLE_DEG * (Math.PI / 180); const INITIAL_BALL_SPEED = 4; const SPEED_INCREASE_FACTOR = 1.08; // Slightly lower increase const MAX_BALL_SPEED = 18; const BOT_PADDLE_SPEED = 5; // Base speed const BOT_PREDICTION_FACTOR = 0.85; // 0 = No prediction, 1 = Perfect prediction (adjust for difficulty) const SPIN_FACTOR = 0.08; // How much paddle speed translates to spin const MAX_SPIN = 1.5; // Max spin value const SPIN_DRAG = 0.995; // Spin decreases slightly over time const MAGNUS_EFFECT_STRENGTH = 0.003; // How much spin affects trajectory const CHARGE_RATE = 150; // Lower value = faster charge (ms per 100% charge) const MAX_CHARGE_BOOST_FACTOR = 2.0; // Max speed multiplier for fully charged shot const POWERUP_SPAWN_CHANCE = 0.002; // Chance per frame to spawn const POWERUP_DURATION = 7000; // ms const PADDLE_SIZE_INCREASE = 40; // Pixels to add to height // --- Game State Variables --- let paddleLeftY = window.innerHeight / 2 - BASE_PADDLE_HEIGHT / 2; let paddleRightY = window.innerHeight / 2 - BASE_PADDLE_HEIGHT / 2; let paddleLeftHeight = BASE_PADDLE_HEIGHT; let paddleRightHeight = BASE_PADDLE_HEIGHT; let ballX = window.innerWidth / 2 - BALL_SIZE / 2; let ballY = window.innerHeight / 2 - BALL_SIZE / 2; let ballSpeedX = INITIAL_BALL_SPEED; let ballSpeedY = 0; let ballSpin = 0; // Positive = Clockwise (Topspin from left player), Negative = Counter-Clockwise (Backspin from left player) let playerScore = 0; let botScore = 0; let isGamePaused = true; let lastPlayerPaddleY = paddleLeftY; // For calculating spin let isCharging = false; let chargeStartTime = 0; let currentChargeBoost = 1.0; // Speed multiplier from charge let activePowerUp = null; // { type: 'PaddleSizeUp', side: 'left'/'right'/'both', timeoutId: null } let powerUpData = { x: 0, y: 0, active: false }; // --- Initial Setup --- function initializeGame() { paddleLeft.style.height = paddleLeftHeight + 'px'; paddleRight.style.height = paddleRightHeight + 'px'; paddleLeft.style.top = paddleLeftY + 'px'; paddleRight.style.top = paddleRightY + 'px'; ball.style.left = ballX + 'px'; ball.style.top = ballY + 'px'; playerScoreDisplay.textContent = playerScore; botScoreDisplay.textContent = botScore; // Place paddles correctly at start/resize paddleRight.style.right = '5px'; // Ensure right paddle is positioned correctly paddleLeft.style.left = '5px'; } // --- Input Handling (Touch & Mouse for Player) --- let interactionStartY = null; // Track touch start position function handleInteractionStart(clientY, clientX) { if (clientX < window.innerWidth / 2) { // Only charge if interacting on the left side isCharging = true; chargeStartTime = performance.now(); chargeIndicator.style.display = 'block'; updateChargeIndicator(); // Update visual immediately interactionStartY = clientY; // Store initial Y for potential movement during charge if (isGamePaused) { isGamePaused = false; // Determine starting direction if needed (optional) // ballSpeedX = Math.abs(ballSpeedX) || INITIAL_BALL_SPEED; } } } function handleInteractionMove(clientY, clientX) { if (clientX < window.innerWidth / 2) { // Only control left paddle // Move paddle based on current position, not start position paddleLeftY = clientY - paddleLeftHeight / 2; paddleLeftY = Math.max(0, Math.min(paddleLeftY, window.innerHeight - paddleLeftHeight)); paddleLeft.style.top = paddleLeftY + 'px'; } } function handleInteractionEnd() { if (isCharging) { isCharging = false; const chargeDuration = performance.now() - chargeStartTime; const chargeLevel = Math.min(chargeDuration / CHARGE_RATE, 1.0); // Cap at 100% currentChargeBoost = 1.0 + chargeLevel * (MAX_CHARGE_BOOST_FACTOR - 1.0); // console.log("Charge Released! Boost:", currentChargeBoost.toFixed(2)); // Debug chargeIndicator.style.display = 'none'; chargeIndicatorFill.style.width = '0%'; // Note: Boost is applied in handlePaddleCollision if released just before hit } interactionStartY = null; } gameArea.addEventListener('touchstart', (e) => { e.preventDefault(); const touch = e.touches[0]; handleInteractionStart(touch.clientY, touch.clientX); }, { passive: false }); gameArea.addEventListener('touchmove', (e) => { e.preventDefault(); const touch = e.touches[0]; handleInteractionMove(touch.clientY, touch.clientX); }, { passive: false }); gameArea.addEventListener('touchend', (e) => { handleInteractionEnd(); }); gameArea.addEventListener('touchcancel', (e) => { handleInteractionEnd(); // Treat cancel like end }); // Mouse fallback for Desktop gameArea.addEventListener('mousedown', (e) => handleInteractionStart(e.clientY, e.clientX)); gameArea.addEventListener('mousemove', (e) => { // Only move paddle if mouse button is potentially down OR touch isn't active // A better approach might track mouseDown state explicitly if (interactionStartY !== null || e.buttons === 1) { // Move if charging or mouse down handleInteractionMove(e.clientY, e.clientX); } }); gameArea.addEventListener('mouseup', (e) => handleInteractionEnd()); gameArea.addEventListener('mouseleave', (e) => handleInteractionEnd()); // Stop charging if mouse leaves function updateChargeIndicator() { if (isCharging) { const chargeDuration = performance.now() - chargeStartTime; const chargeLevel = Math.min(chargeDuration / CHARGE_RATE, 1.0); chargeIndicatorFill.style.width = `${chargeLevel * 100}%`; requestAnimationFrame(updateChargeIndicator); // Keep updating while charging } } // --- Power-Up Logic --- function spawnPowerUp() { if (!powerUpData.active && Math.random() < POWERUP_SPAWN_CHANCE) { // console.log("Spawning PowerUp!"); // Debug powerUpData.x = Math.random() * (window.innerWidth * 0.6) + window.innerWidth * 0.2; // Spawn near middle horizontally powerUpData.y = Math.random() * (window.innerHeight * 0.8) + window.innerHeight * 0.1; // Avoid edges vertically powerUpData.active = true; powerUpElement.style.left = powerUpData.x + 'px'; powerUpElement.style.top = powerUpData.y + 'px'; powerUpElement.style.display = 'block'; // playSound('powerUpSpawn'); } } function checkPowerUpCollision() { if (!powerUpData.active) return; const ballRect = ball.getBoundingClientRect(); const powerUpRect = powerUpElement.getBoundingClientRect(); if (ballRect.left < powerUpRect.right && ballRect.right > powerUpRect.left && ballRect.top < powerUpRect.bottom && ballRect.bottom > powerUpRect.top) { // console.log("PowerUp Hit!"); // Debug powerUpData.active = false; powerUpElement.style.display = 'none'; activatePowerUp('PaddleSizeUp'); // Hardcoded type for now // playSound('powerUpCollect'); } } function activatePowerUp(type) { clearTimeout(activePowerUp?.timeoutId); // Clear existing timer if any const side = (ballSpeedX > 0) ? 'left' : 'right'; // Give power-up to the player who last hit // console.log(`Activating ${type} for ${side}`); // Debug if (type === 'PaddleSizeUp') { if (side === 'left' || side === 'both') { paddleLeftHeight = BASE_PADDLE_HEIGHT + PADDLE_SIZE_INCREASE; paddleLeft.style.height = paddleLeftHeight + 'px'; } if (side === 'right' || side === 'both') { paddleRightHeight = BASE_PADDLE_HEIGHT + PADDLE_SIZE_INCREASE; paddleRight.style.height = paddleRightHeight + 'px'; } // Set timer to deactivate const timeoutId = setTimeout(() => deactivatePowerUp(type, side), POWERUP_DURATION); activePowerUp = { type, side, timeoutId }; } // Add other power-up types here (MultiBall, SpeedChange, etc.) } function deactivatePowerUp(type, side) { // console.log(`Deactivating ${type} for ${side}`); // Debug if (type === 'PaddleSizeUp') { if (side === 'left' || side === 'both') { paddleLeftHeight = BASE_PADDLE_HEIGHT; paddleLeft.style.height = paddleLeftHeight + 'px'; } if (side === 'right' || side === 'both') { paddleRightHeight = BASE_PADDLE_HEIGHT; paddleRight.style.height = paddleRightHeight + 'px'; } } // Add deactivation for other types if (activePowerUp?.timeoutId) { // Clean up state activePowerUp = null; } } // --- Collision Handling --- function handlePaddleCollision(paddleY, paddleHeight, paddleX, isLeftPaddle) { const ballRadius = BALL_SIZE / 2; const ballCenterX = ballX + ballRadius; const ballCenterY = ballY + ballRadius; const paddleTop = paddleY; const paddleBottom = paddleY + paddleHeight; const paddleLeftEdge = paddleX; const paddleRightEdge = paddleX + PADDLE_WIDTH; // Find closest point on paddle to ball center let closestX = Math.max(paddleLeftEdge, Math.min(ballCenterX, paddleRightEdge)); let closestY = Math.max(paddleTop, Math.min(ballCenterY, paddleBottom)); const dx = ballCenterX - closestX; const dy = ballCenterY - closestY; const distanceSquared = (dx * dx) + (dy * dy); if (distanceSquared < (ballRadius * ballRadius)) { // Collision detected // playSound('paddleHit'); showImpactEffect(isLeftPaddle ? paddleLeft : paddleRight); // --- Calculate Spin based on paddle movement --- let paddleVelocityY = 0; if (isLeftPaddle) { paddleVelocityY = paddleLeftY - lastPlayerPaddleY; // How much paddle moved since last frame } else { // Basic AI doesn't have velocity tracking here, could add if needed // For now, AI imparts less spin or random spin paddleVelocityY = (Math.random() - 0.5) * 2; // Small random spin } // Add spin based on paddle speed, clamp it const addedSpin = -paddleVelocityY * SPIN_FACTOR; // Negative because Y increases downwards ballSpin += addedSpin; ballSpin = Math.max(-MAX_SPIN, Math.min(MAX_SPIN, ballSpin)); // --- Calculate Bounce Angle --- const paddleCenterY = paddleTop + paddleHeight / 2; let hitPosition = (ballCenterY - paddleCenterY) / (paddleHeight / 2); hitPosition = Math.max(-1, Math.min(1, hitPosition)); // Clamp -1 to 1 // Angle based on hit position AND incoming spin let bounceAngle = hitPosition * MAX_ANGLE_RAD; // Spin affects bounce angle slightly (e.g., topspin makes it bounce lower) bounceAngle -= ballSpin * 0.1; // Adjust multiplier as needed // --- Calculate Speed --- const currentSpeed = Math.sqrt(ballSpeedX * ballSpeedX + ballSpeedY * ballSpeedY); let chargeBoostToApply = 1.0; if (isLeftPaddle && currentChargeBoost > 1.0) { chargeBoostToApply = currentChargeBoost; currentChargeBoost = 1.0; // Reset boost after applying // console.log("Applying Charge Boost!", chargeBoostToApply.toFixed(2)); // Debug // playSound('chargeHit'); showImpactEffect(ball, true); // Extra effect for charged hit } let newSpeed = currentSpeed * SPEED_INCREASE_FACTOR * chargeBoostToApply; newSpeed = Math.min(newSpeed, MAX_BALL_SPEED); // --- Calculate New Velocities --- const directionX = isLeftPaddle ? 1 : -1; ballSpeedX = directionX * newSpeed * Math.cos(bounceAngle); // Ball speed Y influenced by bounce angle and slightly by existing spin reversal ballSpeedY = newSpeed * Math.sin(bounceAngle) - ballSpin * 0.5; // Spin influences vertical speed // Reverse spin slightly on paddle impact (simulating friction) ballSpin *= -0.6; // Dampen and reverse spin slightly // --- Reposition Ball --- // Simple horizontal push out based on direction const overlap = ballRadius - Math.sqrt(distanceSquared); ballX += directionX * (overlap + 1); // Push out slightly more // Ensure it's fully out (optional safety) if (isLeftPaddle && ballX < paddleRightEdge) ballX = paddleRightEdge; else if (!isLeftPaddle && ballX + BALL_SIZE > paddleLeftEdge) ballX = paddleLeftEdge - BALL_SIZE; return true; // Indicate collision happened } return false; // No collision } // --- Visual Effects --- function showImpactEffect(element, strong = false) { element.classList.add('impact'); if (strong) { element.style.boxShadow = '0 0 25px 10px red'; // Stronger effect } setTimeout(() => { element.classList.remove('impact'); element.style.boxShadow = ''; // Reset specific strong effect }, 100); // Duration of the flash } function updateBallTrail() { const speed = Math.sqrt(ballSpeedX * ballSpeedX + ballSpeedY * ballSpeedY); if (speed > INITIAL_BALL_SPEED * 1.5) { // Only show trail above certain speed ball.classList.add('moving'); // Optional: Adjust trail length/opacity based on speed here if desired } else { ball.classList.remove('moving'); } // Reset the animation trick for CSS trail void ball.offsetWidth; // Trigger reflow to restart CSS transition } // --- AI Logic --- function updateBotAI() { // Predictive AI: Estimate where the ball will cross the bot's paddle line let predictedY = ballY; if (ballSpeedX > 0) { // Only predict if ball is moving towards bot const timeToReachPaddle = (window.innerWidth - PADDLE_WIDTH - ballX) / ballSpeedX; // Simple prediction (doesn't account for wall bounces during flight yet) predictedY = ballY + ballSpeedY * timeToReachPaddle; // Add prediction based on spin (Magnus effect over time) // Simplified: Average spin effect over the predicted time predictedY += ballSpin * MAGNUS_EFFECT_STRENGTH * timeToReachPaddle * timeToReachPaddle * ballSpeedX / 2; // Rough estimate // Add inaccuracy based on prediction factor const targetError = (paddleRightHeight / 2) * (1 - BOT_PREDICTION_FACTOR) * (Math.random() - 0.5); predictedY += targetError; // Clamp prediction to bounds (prevent predicting outside court) predictedY = Math.max(BALL_SIZE / 2, Math.min(predictedY, window.innerHeight - BALL_SIZE / 2)); } else { // If ball moving away, slowly center the paddle predictedY = window.innerHeight / 2; } // Move Paddle towards predicted Y const botPaddleCenterTarget = predictedY - paddleRightHeight / 2; // Target top position for paddle const currentBotCenter = paddleRightY + paddleRightHeight / 2; if (paddleRightY + paddleRightHeight / 2 < predictedY - 5) { // Move down paddleRightY += BOT_PADDLE_SPEED; } else if (paddleRightY + paddleRightHeight / 2 > predictedY + 5) { // Move up paddleRightY -= BOT_PADDLE_SPEED; } // Clamp bot paddle position paddleRightY = Math.max(0, Math.min(paddleRightY, window.innerHeight - paddleRightHeight)); paddleRight.style.top = paddleRightY + 'px'; } // --- Game Update Loop --- function update() { if (!isGamePaused) { // --- Ball Movement --- ballX += ballSpeedX; // Apply Magnus effect (curve due to spin) ballY += ballSpeedY + (ballSpin * MAGNUS_EFFECT_STRENGTH * Math.abs(ballSpeedX)); // Effect stronger at higher horizontal speed ballSpin *= SPIN_DRAG; // Spin decays slowly // --- Wall Collisions --- if (ballY <= 0) { ballY = 0; ballSpeedY *= -1; ballSpin *= 0.8; // Spin dampens on wall hit // playSound('wallHit'); showImpactEffect(ball); } else if (ballY >= window.innerHeight - BALL_SIZE) { ballY = window.innerHeight - BALL_SIZE; ballSpeedY *= -1; ballSpin *= 0.8; // playSound('wallHit'); showImpactEffect(ball); } // --- Paddle Collisions --- let collisionOccurred = false; if (ballSpeedX < 0) { // Moving left collisionOccurred = handlePaddleCollision(paddleLeftY, paddleLeftHeight, 5, true); // Left paddle at x=5 } else { // Moving right collisionOccurred = handlePaddleCollision(paddleRightY, paddleRightHeight, window.innerWidth - PADDLE_WIDTH - 5, false); // Right paddle position } // --- Power-Up Logic --- spawnPowerUp(); checkPowerUpCollision(); // --- Scoring --- if (ballX + BALL_SIZE <= 0) { botScore++; botScoreDisplay.textContent = botScore; resetBall('right'); // Bot serves // playSound('score'); } else if (ballX >= window.innerWidth) { playerScore++; playerScoreDisplay.textContent = playerScore; resetBall('left'); // Player serves // playSound('score'); } // --- AI Update --- updateBotAI(); // --- Update Visuals --- ball.style.left = ballX + 'px'; ball.style.top = ballY + 'px'; updateBallTrail(); // Track player paddle's last position AFTER potential collision handling lastPlayerPaddleY = paddleLeftY; } // End if(!isGamePaused) // Loop requestAnimationFrame(update); } // --- Reset Ball Function --- function resetBall(scoringSide) { isGamePaused = true; // Pause until next interaction // Deactivate any active powerups immediately on score if (activePowerUp) { clearTimeout(activePowerUp.timeoutId); deactivatePowerUp(activePowerUp.type, activePowerUp.side); // Visually reset paddles } if (powerUpData.active) { powerUpData.active = false; powerUpElement.style.display = 'none'; } ballY = window.innerHeight / 2 - BALL_SIZE / 2; ballSpeedY = 0; ballSpin = 0; currentChargeBoost = 1.0; // Reset charge boost isCharging = false; // Ensure charging stops chargeIndicator.style.display = 'none'; chargeIndicatorFill.style.width = '0%'; if (scoringSide === 'left') { // Player scored, player serves ballX = PADDLE_WIDTH + 30; ballSpeedX = INITIAL_BALL_SPEED; } else { // Bot scored, bot serves ballX = window.innerWidth - PADDLE_WIDTH - BALL_SIZE - 30; ballSpeedX = -INITIAL_BALL_SPEED; } // Position ball visually for restart ball.style.left = ballX + 'px'; ball.style.top = ballY + 'px'; ball.classList.remove('moving'); // Stop trail // Could add a "Tap to serve" message here } // --- Utility for Sound (Placeholder) --- // function playSound(soundName) { // // console.log("Playing sound:", soundName); // Placeholder // // In a real implementation: // // const audio = new Audio(`sounds/${soundName}.wav`); // // audio.play(); // } // --- Start Game --- initializeGame(); requestAnimationFrame(update); // Start the loop