const ticksPerSecond = 300; const ballSpeed = 10 * 60 / ticksPerSecond; const paddleSpeed = 10 * 60 / ticksPerSecond; const canvas = document.getElementById("pong"); const ctx = canvas.getContext("2d"); class Vector2 { constructor(x, y) { this.x = x; this.y = y; } add(v) { return new Vector2(this.x + v.x, this.y + v.y); } } class Rect { constructor(x, y, w, h) { this.x = x; this.y = y; this.w = w; this.h = h; } bottom() { return this.y + this.h; } right() { return this.x + this.w; } intersects(r) { return r.x <= this.right() && r.right() >= this.x && r.y <= this.bottom() && r.bottom() >= this.y; } fill() { ctx.clearRect(this.x, this.y, this.w, this.h); } } const digitSegments = [0x77, 0x24, 0x5d, 0x6d, 0x2e, 0x6b, 0x7b, 0x25, 0x7f, 0x6f]; function drawNumber(n, x, y) { const width = 60; const height = 100; const thickness = 10; const padding = 20; const digits = n.toString(); for (let i = 0; i < digits.length; i++) { const segments = digitSegments[digits[i] - '0']; for (let j = 0; j < 7; j++) { if ((segments & 1 << j) === 0) { continue; } switch (j) { case 0: new Rect(x, y, width, thickness).fill(); break; case 1: new Rect(x, y, thickness, height / 2).fill(); break; case 2: new Rect(x + width - thickness, y, thickness, height / 2).fill(); break; case 3: new Rect(x, y + height / 2 - thickness / 2, width, thickness).fill(); break; case 4: new Rect(x, y + height / 2, thickness, height / 2).fill(); break; case 5: new Rect(x + width - thickness, y + height / 2, thickness, height / 2).fill(); break; case 6: new Rect(x, y + height - thickness, width, thickness).fill(); break; } } x += width + padding; } } class Ball { constructor() { this.reset('left'); } bounds() { const ballSize = 30; return new Rect( this.pos.x - ballSize / 2, this.pos.y - ballSize / 2, ballSize, ballSize, ); } predictIntersection(targetX) { if (this.velocity.x <= 0) { return null; } const dist = targetX - this.pos.x; const prediction = this.pos.y + this.velocity.y * dist / this.velocity.x; if (prediction < 0) { const bounces = Math.floor(-prediction / canvas.height); if (bounces % 2 === 0) { return -prediction - canvas.height * bounces; } return prediction + canvas.height * (bounces + 1); } if (prediction > canvas.height) { const bounces = Math.floor(prediction / canvas.height); if (bounces % 2 === 0) { return prediction - canvas.height * bounces; } return -prediction + canvas.height * (bounces + 1); } return prediction; } reset(side) { this.pos = new Vector2(canvas.width / 2, canvas.height / 2); this.velocity = new Vector2(ballSpeed, 0); if (side === 'left') { this.velocity.x = -this.velocity.x; } } } class Paddle { constructor(side) { this.pos = canvas.height / 2; this.side = side; } bounds() { const paddleHeight = 200; const thickness = 10; const padding = 100; let r = new Rect( padding - thickness / 2, this.pos - paddleHeight / 2, thickness, paddleHeight, ); if (this.side === 'right') { r.x = canvas.width - padding - thickness / 2; } return r; } } function updateCanvasSize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; } const pingAudio = new Audio('/static/pong/ping.mp3'); function ping() { pingAudio.pause(); pingAudio.currentTime = 0; pingAudio.play(); } function main() { updateCanvasSize(); let playerScore = 0; let aiScore = 0; const ball = new Ball(); const paddle1 = new Paddle('left'); const paddle2 = new Paddle('right'); const keys = {}; window.addEventListener('keydown', (e) => keys[e.key] = true); window.addEventListener('keyup', (e) => delete keys[e.key]); let mousePressed = false; let mouseY = 0; window.addEventListener('mousedown', () => mousePressed = true); window.addEventListener('mouseup', () => mousePressed = false); window.addEventListener('mousemove', (e) => mouseY = e.y); let touchPressed = false; let touchY = 0; window.addEventListener('touchstart', (e) => { touchPressed = true; if (e.changedTouches.length > 0) { touchY = e.changedTouches[0].clientY; } }); window.addEventListener('touchmove', (e) => { touchPressed = true; if (e.changedTouches.length > 0) { touchY = e.changedTouches[0].clientY; } }); window.addEventListener('touchend', () => touchPressed = false); window.addEventListener('touchcancel', () => touchPressed = false); let lastFrameTime = performance.now(); let accumulator = 0; function gameLoop() { updateCanvasSize(); const now = performance.now(); const frameTime = now - lastFrameTime; lastFrameTime = now; accumulator += frameTime; const simulationSpeed = 1000 / ticksPerSecond; for (; accumulator >= simulationSpeed; accumulator -= simulationSpeed) { if ((keys["w"] || keys["ArrowUp"] || mousePressed && paddle1.pos >= mouseY + paddleSpeed || touchPressed && paddle1.pos >= touchY + paddleSpeed) && paddle1.bounds().y >= 0) { paddle1.pos -= paddleSpeed; } else if ((keys["s"] || keys["ArrowDown"] || mousePressed && paddle1.pos <= mouseY - paddleSpeed || touchPressed && paddle1.pos <= touchY - paddleSpeed) && paddle1.bounds().bottom() <= canvas.height) { paddle1.pos += paddleSpeed; } const prediction = ball.predictIntersection(paddle2.bounds().x); if (prediction !== null) { if (paddle2.pos <= prediction - paddleSpeed && paddle2.bounds().bottom() <= canvas.height) { paddle2.pos += paddleSpeed; } else if (paddle2.pos >= prediction + paddleSpeed && paddle2.bounds().y >= 0) { paddle2.pos -= paddleSpeed; } } ball.pos = ball.pos.add(ball.velocity); if (ball.bounds().intersects(paddle1.bounds()) && ball.velocity.x <= 0) { ping(); let d = (ball.pos.y - paddle1.pos) / (paddle1.bounds().h / 2 + ball.bounds().h); if (d < 0) { d = -d; } let angle = d * 75 * 2 * Math.PI / 360; if (ball.pos.y < paddle1.pos) { angle = -angle; } const speed = ballSpeed * (1 + 4 * d); ball.velocity = new Vector2(speed * Math.cos(angle), speed * Math.sin(angle)); } else if (ball.bounds().intersects(paddle2.bounds()) && ball.velocity.x >= 0) { ping(); let d = (ball.pos.y - paddle2.pos) / (paddle2.bounds().h / 2 + ball.bounds().h); if (d < 0) { d = -d; } let angle = d * 75 * 2 * Math.PI / 360; if (ball.pos.y < paddle2.pos) { angle = -angle; } angle += Math.PI; const speed = ballSpeed * (1 + 4 * d); ball.velocity = new Vector2(speed * Math.cos(angle), speed * Math.sin(angle)); } else if (ball.bounds().right() <= 0) { aiScore++; ball.reset('right'); } else if (ball.bounds().x >= canvas.width) { playerScore++; ball.reset('left'); } else if (ball.bounds().y <= 0 && ball.velocity.y < 0 || ball.bounds().bottom() >= canvas.height && ball.velocity.y > 0) { ping(); ball.velocity.y = -ball.velocity.y; } } ctx.fillRect(0, 0, canvas.width, canvas.height); paddle1.bounds().fill(); paddle2.bounds().fill(); ball.bounds().fill(); drawNumber(playerScore, 150, 30) drawNumber(aiScore, canvas.width / 2 + 150, canvas.height - 130); requestAnimationFrame(gameLoop); } gameLoop(); } main();