From 77071a52ff80b4a61093a27e5de40f899ac5c918 Mon Sep 17 00:00:00 2001 From: Dabit Date: Tue, 10 Mar 2026 21:10:08 +0100 Subject: [PATCH] feat(02-02): implement state machine, scoring, match-end, mode/difficulty selection - Added mode/difficulty/restart key handlers to Input._handleKeyDown - Rewrote GameLoop.main() with full state machine (modeSelect, diffSelect, playing, scored, gameover) - Added score detection in GameLoop (ball exits left/right edge) - Added 1s pause after score then auto-serve - Added match-end check (first to 7 wins) with winner overlay - Added center divider line and real-time score display - Renders both paddles during gameplay - Removed Physics out-of-bounds auto-serve (GameLoop now owns scoring) - Removed Phase 1 debug speed display - Added pauseTime field to GameLoop --- index.html | 172 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 155 insertions(+), 17 deletions(-) diff --git a/index.html b/index.html index 905eefb..db84a5a 100644 --- a/index.html +++ b/index.html @@ -131,6 +131,50 @@ if (e.code === 'KeyS') { this.keys.s = true; e.preventDefault(); } if (e.code === 'ArrowUp') { this.keys.arrowUp = true; e.preventDefault(); } if (e.code === 'ArrowDown') { this.keys.arrowDown = true; e.preventDefault(); } + + // Mode selection (only when in modeSelect state) + if (e.code === 'Digit1' && GameState.gameState === 'modeSelect') { + GameState.mode = 'ai'; + GameState.gameState = 'diffSelect'; + } + if (e.code === 'Digit2' && GameState.gameState === 'modeSelect') { + GameState.mode = '2p'; + // Skip difficulty selection for 2-player — go straight to playing + GameState.gameState = 'playing'; + Physics.serveBall(); + } + + // Difficulty selection (only when in diffSelect state) + if (e.code === 'Digit1' && GameState.gameState === 'diffSelect') { + GameState.difficulty = 'easy'; + AI.init(); + GameState.gameState = 'playing'; + Physics.serveBall(); + } + if (e.code === 'Digit2' && GameState.gameState === 'diffSelect') { + GameState.difficulty = 'medium'; + AI.init(); + GameState.gameState = 'playing'; + Physics.serveBall(); + } + if (e.code === 'Digit3' && GameState.gameState === 'diffSelect') { + GameState.difficulty = 'hard'; + AI.init(); + GameState.gameState = 'playing'; + Physics.serveBall(); + } + + // Restart (only when gameover) + if (e.code === 'KeyR' && GameState.gameState === 'gameover') { + GameState.score1 = 0; + GameState.score2 = 0; + GameState.winner = null; + GameState.mode = null; + GameState.difficulty = 'medium'; + GameState.gameState = 'modeSelect'; + AI.init(); + Physics.init(Renderer.logicalWidth, Renderer.logicalHeight); + } }; this._handleKeyUp = (e) => { if (e.code === 'KeyW') this.keys.w = false; @@ -221,12 +265,6 @@ ball.vy = -Math.abs(ball.vy); } - // --- Ball out of bounds (left or right) → reset --- - if (ball.x + ball.radius < 0 || ball.x - ball.radius > this.width) { - this.serveBall(); - return; - } - // --- Paddle collision (only check when ball moving left toward paddle1) --- if (ball.vx < 0) { this._checkPaddleCollision(ball, paddle); @@ -375,6 +413,7 @@ const GameLoop = { stopHandle: null, lastTime: 0, + pauseTime: 0, start() { this.lastTime = performance.now(); @@ -387,24 +426,123 @@ main(currentTime) { this.stopHandle = window.requestAnimationFrame(this.main.bind(this)); - const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.05); // Cap at 50ms + const deltaTime = Math.min((currentTime - this.lastTime) / 1000, 0.05); this.lastTime = currentTime; - Physics.update(deltaTime); + const gs = GameState; + const ball = gs.ball; + // --- Physics update (only during active play) --- + if (gs.gameState === 'playing') { + Physics.update(deltaTime); + + // Score detection — AFTER Physics.update(), NOT inside Physics + if (ball.x + ball.radius < 0) { + // Ball exited left edge → Player 2 (or AI) scores + gs.score2++; + gs.gameState = 'scored'; + this.pauseTime = currentTime; + } else if (ball.x - ball.radius > Physics.width) { + // Ball exited right edge → Player 1 scores + gs.score1++; + gs.gameState = 'scored'; + this.pauseTime = currentTime; + } + + // Match-end check immediately after scoring + if (gs.score1 >= GameConfig.WIN_SCORE) { + gs.gameState = 'gameover'; + gs.winner = 'player1'; + } else if (gs.score2 >= GameConfig.WIN_SCORE) { + gs.gameState = 'gameover'; + gs.winner = gs.mode === 'ai' ? 'ai' : 'player2'; + } + } + + // --- Auto-serve after ~1s pause --- + if (gs.gameState === 'scored') { + const elapsed = (currentTime - this.pauseTime) / 1000; + if (elapsed >= 1.0) { + Physics.serveBall(); + gs.gameState = 'playing'; + } + } + + // --- Render --- Renderer.clear(); - // Debug: show ball speed (keep for Phase 1 verification) - Renderer.ctx.fillStyle = 'rgba(255,255,255,0.4)'; - Renderer.ctx.font = '14px monospace'; - Renderer.ctx.fillText('speed: ' + Math.round(GameState.ball.speed), 10, 20); + if (gs.gameState === 'modeSelect') { + // Mode selection prompt + Renderer.ctx.fillStyle = '#fff'; + Renderer.ctx.font = 'bold 28px monospace'; + Renderer.ctx.textAlign = 'center'; + Renderer.ctx.fillText('SUPER PONG NEXT GEN', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 60); + Renderer.ctx.font = '20px monospace'; + Renderer.ctx.fillText('Press 1 — Solo vs AI', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2); + Renderer.ctx.fillText('Press 2 — 2 Player Local', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 36); + Renderer.ctx.textAlign = 'left'; + return; + } - // Draw paddle1 - const p1 = GameState.paddle1; + if (gs.gameState === 'diffSelect') { + Renderer.ctx.fillStyle = '#fff'; + Renderer.ctx.font = 'bold 24px monospace'; + Renderer.ctx.textAlign = 'center'; + Renderer.ctx.fillText('SELECT DIFFICULTY', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 50); + Renderer.ctx.font = '20px monospace'; + Renderer.ctx.fillText('1 — Easy', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2); + Renderer.ctx.fillText('2 — Medium', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 36); + Renderer.ctx.fillText('3 — Hard', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 72); + Renderer.ctx.textAlign = 'left'; + return; + } + + // --- Gameplay rendering (playing, scored, gameover) --- + + // Paddles + const p1 = gs.paddle1; + const p2 = gs.paddle2; Renderer.drawRect(p1.x, p1.y, p1.width, p1.height, p1.color); - // Draw ball - const b = GameState.ball; - Renderer.drawCircle(b.x, b.y, b.radius, b.color); + Renderer.drawRect(p2.x, p2.y, p2.width, p2.height, p2.color); + + // Ball (stationary during scored pause) + if (gs.gameState !== 'scored') { + const b = gs.ball; + Renderer.drawCircle(b.x, b.y, b.radius, b.color); + } + + // Center divider line (dashed) + Renderer.ctx.setLineDash([10, 14]); + Renderer.ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + Renderer.ctx.lineWidth = 2; + Renderer.ctx.beginPath(); + Renderer.ctx.moveTo(Renderer.logicalWidth / 2, 0); + Renderer.ctx.lineTo(Renderer.logicalWidth / 2, Renderer.logicalHeight); + Renderer.ctx.stroke(); + Renderer.ctx.setLineDash([]); + + // Scores + Renderer.ctx.fillStyle = 'rgba(255,255,255,0.8)'; + Renderer.ctx.font = 'bold 48px monospace'; + Renderer.ctx.textAlign = 'center'; + Renderer.ctx.fillText(gs.score1, Renderer.logicalWidth / 4, 64); + Renderer.ctx.fillText(gs.score2, Renderer.logicalWidth * 3 / 4, 64); + Renderer.ctx.textAlign = 'left'; + + // Game over overlay + if (gs.gameState === 'gameover') { + const winnerName = gs.winner === 'player1' ? 'Player 1' : + gs.winner === 'ai' ? 'AI' : 'Player 2'; + Renderer.ctx.fillStyle = 'rgba(0,0,0,0.5)'; + Renderer.ctx.fillRect(0, 0, Renderer.logicalWidth, Renderer.logicalHeight); + Renderer.ctx.fillStyle = '#fff'; + Renderer.ctx.font = 'bold 36px monospace'; + Renderer.ctx.textAlign = 'center'; + Renderer.ctx.fillText(winnerName + ' Wins!', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 - 20); + Renderer.ctx.font = '20px monospace'; + Renderer.ctx.fillText('Press R to play again', Renderer.logicalWidth / 2, Renderer.logicalHeight / 2 + 24); + Renderer.ctx.textAlign = 'left'; + } } };