|
|
|
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'); |
|
|
|
|
|
const BASE_PADDLE_HEIGHT = 80; |
|
const PADDLE_WIDTH = 10; |
|
const BALL_SIZE = 15; |
|
const MAX_ANGLE_DEG = 60; |
|
const MAX_ANGLE_RAD = MAX_ANGLE_DEG * (Math.PI / 180); |
|
const INITIAL_BALL_SPEED = 4; |
|
const SPEED_INCREASE_FACTOR = 1.08; |
|
const MAX_BALL_SPEED = 18; |
|
const BOT_PADDLE_SPEED = 5; |
|
const BOT_PREDICTION_FACTOR = 0.85; |
|
const SPIN_FACTOR = 0.08; |
|
const MAX_SPIN = 1.5; |
|
const SPIN_DRAG = 0.995; |
|
const MAGNUS_EFFECT_STRENGTH = 0.003; |
|
const CHARGE_RATE = 150; |
|
const MAX_CHARGE_BOOST_FACTOR = 2.0; |
|
const POWERUP_SPAWN_CHANCE = 0.002; |
|
const POWERUP_DURATION = 7000; |
|
const PADDLE_SIZE_INCREASE = 40; |
|
|
|
|
|
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; |
|
let playerScore = 0; |
|
let botScore = 0; |
|
let isGamePaused = true; |
|
let lastPlayerPaddleY = paddleLeftY; |
|
let isCharging = false; |
|
let chargeStartTime = 0; |
|
let currentChargeBoost = 1.0; |
|
let activePowerUp = null; |
|
let powerUpData = { x: 0, y: 0, active: false }; |
|
|
|
|
|
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; |
|
|
|
paddleRight.style.right = '5px'; |
|
paddleLeft.style.left = '5px'; |
|
} |
|
|
|
|
|
|
|
let interactionStartY = null; |
|
|
|
function handleInteractionStart(clientY, clientX) { |
|
if (clientX < window.innerWidth / 2) { |
|
isCharging = true; |
|
chargeStartTime = performance.now(); |
|
chargeIndicator.style.display = 'block'; |
|
updateChargeIndicator(); |
|
|
|
interactionStartY = clientY; |
|
|
|
if (isGamePaused) { |
|
isGamePaused = false; |
|
|
|
|
|
} |
|
} |
|
} |
|
|
|
function handleInteractionMove(clientY, clientX) { |
|
if (clientX < window.innerWidth / 2) { |
|
|
|
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); |
|
currentChargeBoost = 1.0 + chargeLevel * (MAX_CHARGE_BOOST_FACTOR - 1.0); |
|
|
|
chargeIndicator.style.display = 'none'; |
|
chargeIndicatorFill.style.width = '0%'; |
|
|
|
} |
|
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(); |
|
}); |
|
|
|
|
|
gameArea.addEventListener('mousedown', (e) => handleInteractionStart(e.clientY, e.clientX)); |
|
gameArea.addEventListener('mousemove', (e) => { |
|
|
|
|
|
if (interactionStartY !== null || e.buttons === 1) { |
|
handleInteractionMove(e.clientY, e.clientX); |
|
} |
|
}); |
|
gameArea.addEventListener('mouseup', (e) => handleInteractionEnd()); |
|
gameArea.addEventListener('mouseleave', (e) => handleInteractionEnd()); |
|
|
|
|
|
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); |
|
} |
|
} |
|
|
|
|
|
function spawnPowerUp() { |
|
if (!powerUpData.active && Math.random() < POWERUP_SPAWN_CHANCE) { |
|
|
|
powerUpData.x = Math.random() * (window.innerWidth * 0.6) + window.innerWidth * 0.2; |
|
powerUpData.y = Math.random() * (window.innerHeight * 0.8) + window.innerHeight * 0.1; |
|
powerUpData.active = true; |
|
powerUpElement.style.left = powerUpData.x + 'px'; |
|
powerUpElement.style.top = powerUpData.y + 'px'; |
|
powerUpElement.style.display = 'block'; |
|
|
|
} |
|
} |
|
|
|
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) |
|
{ |
|
|
|
powerUpData.active = false; |
|
powerUpElement.style.display = 'none'; |
|
activatePowerUp('PaddleSizeUp'); |
|
|
|
} |
|
} |
|
|
|
function activatePowerUp(type) { |
|
clearTimeout(activePowerUp?.timeoutId); |
|
|
|
const side = (ballSpeedX > 0) ? 'left' : 'right'; |
|
|
|
|
|
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'; |
|
} |
|
|
|
const timeoutId = setTimeout(() => deactivatePowerUp(type, side), POWERUP_DURATION); |
|
activePowerUp = { type, side, timeoutId }; |
|
} |
|
|
|
} |
|
|
|
function deactivatePowerUp(type, side) { |
|
|
|
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'; |
|
} |
|
} |
|
|
|
|
|
if (activePowerUp?.timeoutId) { |
|
activePowerUp = null; |
|
} |
|
} |
|
|
|
|
|
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; |
|
|
|
|
|
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)) { |
|
|
|
showImpactEffect(isLeftPaddle ? paddleLeft : paddleRight); |
|
|
|
|
|
let paddleVelocityY = 0; |
|
if (isLeftPaddle) { |
|
paddleVelocityY = paddleLeftY - lastPlayerPaddleY; |
|
} else { |
|
|
|
|
|
paddleVelocityY = (Math.random() - 0.5) * 2; |
|
} |
|
|
|
const addedSpin = -paddleVelocityY * SPIN_FACTOR; |
|
ballSpin += addedSpin; |
|
ballSpin = Math.max(-MAX_SPIN, Math.min(MAX_SPIN, ballSpin)); |
|
|
|
|
|
const paddleCenterY = paddleTop + paddleHeight / 2; |
|
let hitPosition = (ballCenterY - paddleCenterY) / (paddleHeight / 2); |
|
hitPosition = Math.max(-1, Math.min(1, hitPosition)); |
|
|
|
|
|
let bounceAngle = hitPosition * MAX_ANGLE_RAD; |
|
|
|
bounceAngle -= ballSpin * 0.1; |
|
|
|
|
|
const currentSpeed = Math.sqrt(ballSpeedX * ballSpeedX + ballSpeedY * ballSpeedY); |
|
let chargeBoostToApply = 1.0; |
|
if (isLeftPaddle && currentChargeBoost > 1.0) { |
|
chargeBoostToApply = currentChargeBoost; |
|
currentChargeBoost = 1.0; |
|
|
|
|
|
showImpactEffect(ball, true); |
|
} |
|
|
|
let newSpeed = currentSpeed * SPEED_INCREASE_FACTOR * chargeBoostToApply; |
|
newSpeed = Math.min(newSpeed, MAX_BALL_SPEED); |
|
|
|
|
|
const directionX = isLeftPaddle ? 1 : -1; |
|
ballSpeedX = directionX * newSpeed * Math.cos(bounceAngle); |
|
|
|
ballSpeedY = newSpeed * Math.sin(bounceAngle) - ballSpin * 0.5; |
|
|
|
|
|
ballSpin *= -0.6; |
|
|
|
|
|
|
|
const overlap = ballRadius - Math.sqrt(distanceSquared); |
|
ballX += directionX * (overlap + 1); |
|
|
|
|
|
if (isLeftPaddle && ballX < paddleRightEdge) ballX = paddleRightEdge; |
|
else if (!isLeftPaddle && ballX + BALL_SIZE > paddleLeftEdge) ballX = paddleLeftEdge - BALL_SIZE; |
|
|
|
return true; |
|
} |
|
return false; |
|
} |
|
|
|
|
|
|
|
function showImpactEffect(element, strong = false) { |
|
element.classList.add('impact'); |
|
if (strong) { |
|
element.style.boxShadow = '0 0 25px 10px red'; |
|
} |
|
setTimeout(() => { |
|
element.classList.remove('impact'); |
|
element.style.boxShadow = ''; |
|
}, 100); |
|
} |
|
|
|
function updateBallTrail() { |
|
const speed = Math.sqrt(ballSpeedX * ballSpeedX + ballSpeedY * ballSpeedY); |
|
if (speed > INITIAL_BALL_SPEED * 1.5) { |
|
ball.classList.add('moving'); |
|
|
|
} else { |
|
ball.classList.remove('moving'); |
|
} |
|
|
|
void ball.offsetWidth; |
|
} |
|
|
|
|
|
|
|
function updateBotAI() { |
|
|
|
let predictedY = ballY; |
|
if (ballSpeedX > 0) { |
|
const timeToReachPaddle = (window.innerWidth - PADDLE_WIDTH - ballX) / ballSpeedX; |
|
|
|
|
|
predictedY = ballY + ballSpeedY * timeToReachPaddle; |
|
|
|
|
|
|
|
predictedY += ballSpin * MAGNUS_EFFECT_STRENGTH * timeToReachPaddle * timeToReachPaddle * ballSpeedX / 2; |
|
|
|
|
|
const targetError = (paddleRightHeight / 2) * (1 - BOT_PREDICTION_FACTOR) * (Math.random() - 0.5); |
|
predictedY += targetError; |
|
|
|
|
|
predictedY = Math.max(BALL_SIZE / 2, Math.min(predictedY, window.innerHeight - BALL_SIZE / 2)); |
|
} else { |
|
|
|
predictedY = window.innerHeight / 2; |
|
} |
|
|
|
|
|
|
|
const botPaddleCenterTarget = predictedY - paddleRightHeight / 2; |
|
const currentBotCenter = paddleRightY + paddleRightHeight / 2; |
|
|
|
if (paddleRightY + paddleRightHeight / 2 < predictedY - 5) { |
|
paddleRightY += BOT_PADDLE_SPEED; |
|
} else if (paddleRightY + paddleRightHeight / 2 > predictedY + 5) { |
|
paddleRightY -= BOT_PADDLE_SPEED; |
|
} |
|
|
|
|
|
paddleRightY = Math.max(0, Math.min(paddleRightY, window.innerHeight - paddleRightHeight)); |
|
paddleRight.style.top = paddleRightY + 'px'; |
|
} |
|
|
|
|
|
function update() { |
|
if (!isGamePaused) { |
|
|
|
ballX += ballSpeedX; |
|
|
|
ballY += ballSpeedY + (ballSpin * MAGNUS_EFFECT_STRENGTH * Math.abs(ballSpeedX)); |
|
ballSpin *= SPIN_DRAG; |
|
|
|
|
|
if (ballY <= 0) { |
|
ballY = 0; |
|
ballSpeedY *= -1; |
|
ballSpin *= 0.8; |
|
|
|
showImpactEffect(ball); |
|
} else if (ballY >= window.innerHeight - BALL_SIZE) { |
|
ballY = window.innerHeight - BALL_SIZE; |
|
ballSpeedY *= -1; |
|
ballSpin *= 0.8; |
|
|
|
showImpactEffect(ball); |
|
} |
|
|
|
|
|
let collisionOccurred = false; |
|
if (ballSpeedX < 0) { |
|
collisionOccurred = handlePaddleCollision(paddleLeftY, paddleLeftHeight, 5, true); |
|
} else { |
|
collisionOccurred = handlePaddleCollision(paddleRightY, paddleRightHeight, window.innerWidth - PADDLE_WIDTH - 5, false); |
|
} |
|
|
|
|
|
spawnPowerUp(); |
|
checkPowerUpCollision(); |
|
|
|
|
|
if (ballX + BALL_SIZE <= 0) { |
|
botScore++; |
|
botScoreDisplay.textContent = botScore; |
|
resetBall('right'); |
|
|
|
} else if (ballX >= window.innerWidth) { |
|
playerScore++; |
|
playerScoreDisplay.textContent = playerScore; |
|
resetBall('left'); |
|
|
|
} |
|
|
|
|
|
updateBotAI(); |
|
|
|
|
|
ball.style.left = ballX + 'px'; |
|
ball.style.top = ballY + 'px'; |
|
updateBallTrail(); |
|
|
|
|
|
lastPlayerPaddleY = paddleLeftY; |
|
|
|
} |
|
|
|
|
|
requestAnimationFrame(update); |
|
} |
|
|
|
|
|
function resetBall(scoringSide) { |
|
isGamePaused = true; |
|
|
|
|
|
if (activePowerUp) { |
|
clearTimeout(activePowerUp.timeoutId); |
|
deactivatePowerUp(activePowerUp.type, activePowerUp.side); |
|
} |
|
if (powerUpData.active) { |
|
powerUpData.active = false; |
|
powerUpElement.style.display = 'none'; |
|
} |
|
|
|
|
|
ballY = window.innerHeight / 2 - BALL_SIZE / 2; |
|
ballSpeedY = 0; |
|
ballSpin = 0; |
|
currentChargeBoost = 1.0; |
|
isCharging = false; |
|
chargeIndicator.style.display = 'none'; |
|
chargeIndicatorFill.style.width = '0%'; |
|
|
|
|
|
if (scoringSide === 'left') { |
|
ballX = PADDLE_WIDTH + 30; |
|
ballSpeedX = INITIAL_BALL_SPEED; |
|
} else { |
|
ballX = window.innerWidth - PADDLE_WIDTH - BALL_SIZE - 30; |
|
ballSpeedX = -INITIAL_BALL_SPEED; |
|
} |
|
|
|
|
|
ball.style.left = ballX + 'px'; |
|
ball.style.top = ballY + 'px'; |
|
ball.classList.remove('moving'); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
initializeGame(); |
|
requestAnimationFrame(update); |
|
|
|
|