diff --git a/.vscode/launch.json b/.vscode/launch.json index e7265cc1f844..7526001d701a 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,16 @@ { "version": "0.2.0", "configurations": [ + { + "name": "Electron Main", + "program": "${workspaceFolder}/main.js", + "request": "launch", + "runtimeExecutable": "electron", + "skipFiles": [ + "/**" + ], + "type": "node" + }, { "type": "node", "request": "attach", diff --git a/public/pong/game.js b/public/pong/game.js new file mode 100644 index 000000000000..09d67c58a303 --- /dev/null +++ b/public/pong/game.js @@ -0,0 +1,150 @@ +const canvas = document.getElementById('game-canvas'); +const context = canvas.getContext('2d'); +const playerScoreElement = document.getElementById('player-score'); +const computerScoreElement = document.getElementById('computer-score'); +const messageElement = document.getElementById('game-message'); +const resetButton = document.getElementById('reset-button'); + +const court = { width: canvas.width, height: canvas.height }; +const paddle = { width: 16, height: 104, speed: 8 }; +const ball = { size: 14, speed: 6, maxSpeed: 11 }; + +const player = { x: 30, y: 0, score: 0, velocity: 0 }; +const computer = { x: court.width - 46, y: 0, score: 0, velocity: 0 }; +const keys = new Set(); +let animationFrame; +let gameStarted = false; + +function resetBall(direction = Math.random() > 0.5 ? 1 : -1) { + ball.x = court.width / 2 - ball.size / 2; + ball.y = court.height / 2 - ball.size / 2; + ball.velocityX = direction * ball.speed; + ball.velocityY = (Math.random() * 4 - 2) || 1; + gameStarted = false; + messageElement.textContent = 'Press an arrow key to serve'; + messageElement.classList.remove('hidden'); +} + +function resetGame() { + player.score = 0; + computer.score = 0; + playerScoreElement.textContent = '0'; + computerScoreElement.textContent = '0'; + player.y = court.height / 2 - paddle.height / 2; + computer.y = player.y; + resetBall(); +} + +function serve() { + if (!gameStarted) { + gameStarted = true; + messageElement.classList.add('hidden'); + } +} + +function movePlayer() { + const movingUp = keys.has('ArrowUp') || keys.has('w'); + const movingDown = keys.has('ArrowDown') || keys.has('s'); + player.velocity = (movingDown ? paddle.speed : 0) - (movingUp ? paddle.speed : 0); + player.y = Math.max(0, Math.min(court.height - paddle.height, player.y + player.velocity)); +} + +function moveComputer() { + const target = ball.y + ball.size / 2 - paddle.height / 2; + const difference = target - computer.y; + const movement = Math.sign(difference) * Math.min(Math.abs(difference), 5.3); + computer.y = Math.max(0, Math.min(court.height - paddle.height, computer.y + movement)); +} + +function overlaps(paddleObject) { + return ( + ball.x < paddleObject.x + paddle.width && + ball.x + ball.size > paddleObject.x && + ball.y < paddleObject.y + paddle.height && + ball.y + ball.size > paddleObject.y + ); +} + +function bounceFromPaddle(paddleObject, direction) { + const paddleCenter = paddleObject.y + paddle.height / 2; + const ballCenter = ball.y + ball.size / 2; + const impact = (ballCenter - paddleCenter) / (paddle.height / 2); + const nextSpeed = Math.min(ball.maxSpeed, Math.abs(ball.velocityX) + 0.35); + ball.velocityX = direction * nextSpeed; + ball.velocityY = impact * 6; + ball.x = direction === 1 ? paddleObject.x + paddle.width : paddleObject.x - ball.size; +} + +function scorePoint(scoringPlayer) { + scoringPlayer.score += 1; + playerScoreElement.textContent = String(player.score); + computerScoreElement.textContent = String(computer.score); + resetBall(scoringPlayer === player ? 1 : -1); +} + +function update() { + movePlayer(); + moveComputer(); + if (!gameStarted) return; + + ball.x += ball.velocityX; + ball.y += ball.velocityY; + + if (ball.y <= 0 || ball.y + ball.size >= court.height) { + ball.velocityY *= -1; + ball.y = Math.max(0, Math.min(court.height - ball.size, ball.y)); + } + + if (ball.velocityX < 0 && overlaps(player)) bounceFromPaddle(player, 1); + if (ball.velocityX > 0 && overlaps(computer)) bounceFromPaddle(computer, -1); + + if (ball.x + ball.size < 0) scorePoint(computer); + if (ball.x > court.width) scorePoint(player); +} + +function draw() { + context.clearRect(0, 0, court.width, court.height); + context.fillStyle = '#101729'; + context.fillRect(0, 0, court.width, court.height); + + context.setLineDash([8, 14]); + context.strokeStyle = '#36415f'; + context.lineWidth = 3; + context.beginPath(); + context.moveTo(court.width / 2, 18); + context.lineTo(court.width / 2, court.height - 18); + context.stroke(); + context.setLineDash([]); + + context.fillStyle = '#7ee7cc'; + context.fillRect(player.x, player.y, paddle.width, paddle.height); + context.fillStyle = '#f8b26a'; + context.fillRect(computer.x, computer.y, paddle.width, paddle.height); + + context.fillStyle = '#f5f7ff'; + context.beginPath(); + context.arc(ball.x + ball.size / 2, ball.y + ball.size / 2, ball.size / 2, 0, Math.PI * 2); + context.fill(); +} + +function gameLoop() { + update(); + draw(); + animationFrame = requestAnimationFrame(gameLoop); +} + +document.addEventListener('keydown', (event) => { + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'w', 's'].includes(event.key)) { + event.preventDefault(); + keys.add(event.key); + if (event.key.startsWith('Arrow')) serve(); + } +}); + +document.addEventListener('keyup', (event) => keys.delete(event.key)); +resetButton.addEventListener('click', resetGame); + +resetGame(); +gameLoop(); + +window.addEventListener('beforeunload', () => cancelAnimationFrame(animationFrame)); diff --git a/public/pong/index.html b/public/pong/index.html new file mode 100644 index 000000000000..52bbe34fae9d --- /dev/null +++ b/public/pong/index.html @@ -0,0 +1,43 @@ + + + + + + Pong + + + +
+
+
+

ARCADE CLASSIC

+

Pong

+
+ +
+ +
+
+ You + 0 +
+ : +
+ Computer + 0 +
+
+ +
+ +
Press an arrow key to serve
+
+ +

+ Move your paddle with or W S. + Press to serve again after a point. +

+
+ + + diff --git a/public/pong/styles.css b/public/pong/styles.css new file mode 100644 index 000000000000..00c08b2a759c --- /dev/null +++ b/public/pong/styles.css @@ -0,0 +1,177 @@ +* { + box-sizing: border-box; +} + +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #10131f; + color: #f5f7ff; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; + background: radial-gradient(circle at top, #202943 0, #10131f 58%); +} + +.game-shell { + width: min(960px, calc(100% - 32px)); + margin: 0 auto; + padding: 48px 0 36px; +} + +.game-header, +.scoreboard { + display: flex; + align-items: center; + justify-content: space-between; +} + +.eyebrow { + margin: 0 0 8px; + color: #7ee7cc; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.18em; +} + +h1 { + margin: 0; + font-size: clamp(2.4rem, 7vw, 4.5rem); + letter-spacing: -0.07em; + line-height: 0.9; +} + +.reset-button { + border: 1px solid #495575; + border-radius: 999px; + padding: 10px 18px; + color: #f5f7ff; + background: #202943; + cursor: pointer; + font: inherit; + font-weight: 700; + transition: background 160ms ease, transform 160ms ease; +} + +.reset-button:hover, +.reset-button:focus-visible { + background: #2e3a60; + outline: none; + transform: translateY(-1px); +} + +.scoreboard { + justify-content: center; + gap: 24px; + margin: 38px 0 18px; +} + +.score-card { + display: grid; + gap: 2px; + min-width: 110px; +} + +.computer-score-card { + text-align: right; +} + +.score-label { + color: #aab4d0; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.score-card strong { + font-size: 3rem; + line-height: 1; +} + +.score-divider { + color: #586584; + font-size: 2.5rem; + font-weight: 300; +} + +.court-wrap { + position: relative; + overflow: hidden; + border: 1px solid #3b4664; + border-radius: 18px; + padding: 10px; + background: #090c16; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.3); +} + +canvas { + display: block; + width: 100%; + height: auto; + border-radius: 10px; + background: #101729; +} + +.game-message { + position: absolute; + top: 50%; + left: 50%; + padding: 9px 15px; + border: 1px solid rgba(126, 231, 204, 0.35); + border-radius: 999px; + color: #d7fff4; + background: rgba(16, 19, 31, 0.78); + font-size: 0.85rem; + font-weight: 700; + transform: translate(-50%, -50%); + transition: opacity 180ms ease; +} + +.game-message.hidden { + opacity: 0; + pointer-events: none; +} + +.instructions { + margin: 18px 0 0; + color: #aab4d0; + font-size: 0.9rem; + text-align: center; +} + +kbd { + display: inline-block; + min-width: 25px; + margin: 0 2px; + padding: 2px 6px; + border: 1px solid #495575; + border-bottom-width: 2px; + border-radius: 5px; + color: #f5f7ff; + background: #202943; + font-size: 0.8rem; + text-align: center; +} + +@media (max-width: 560px) { + .game-shell { + padding-top: 28px; + } + + .game-header { + align-items: flex-start; + } + + .reset-button { + padding: 8px 13px; + font-size: 0.85rem; + } + + .instructions { + line-height: 2; + } +}