// ---------- CONSTANTS ---------- const CANVAS_W = 400; const CANVAS_H = 600; const BRICK_COLS = 8; const BRICK_ROWS = 4; const BRICK_W = 50; const BRICK_H = 20; const PADDLE_W = 60; const PADDLE_H = 10; const PADDLE_Y = 580; // bottom edge y const BALL_R = 3; const BALL_SPEED = 3.4; // px/frame constant magnitude const MAX_DX = 4.2; // max horizontal velocity after paddle reflection const MAX_DY = 3.2; // max vertical velocity (upward limit for wall bounces) const BRICK_POINTS = 10; const LIVES = 3; // ---------- CANVAS SETUP ---------- const canvas = document.getElementById('game'); const ctx = canvas.getContext('2d'); canvas.width = CANVAS_W; canvas.height = CANVAS_H; // Scale for DPR (crisp rendering on mobile) function dpr() { return window.devicePixelRatio || 1; } let scale = dpr(); function resize() { const w = Math.min(window.innerWidth, CANVAS_W * scale); const h = Math.min(window.innerHeight, CANVAS_H * scale); canvas.width = Math.round(w * dpr()); canvas.height = Math.round(h * dpr()); ctx.scale(scale, scale); } window.addEventListener('resize', resize); resize(); // ---------- GAME STATE ---------- const keys = { left: false, right: false }; let lastTouchX = -1; let dragging = false; const paddleX = (CANVAS_W - PADDLE_W) / 2; // center at 200 const state = { ball: { x: 200, y: 570, dx: 0, dy: 0 }, paddleX: 200, score: 0, lives: LIVES, bricks: [], // array of {x,y,alive} gameMode: 'playing', // playing | gameover | won lastCollide: null, // point where collision happened this frame (for stuck-in prevention) }; // ---------- INIT BRICKS ---------- function initBricks() { state.bricks = []; let idx = 0; for (let r = 0; r < BRICK_ROWS; r++) { for (let c = 0; c < BRICK_COLS; c++) { const x = c * (BRICK_W + 8); const y = r * (BRICK_H + 12); // gap between rows state.bricks.push({ x, y, w: BRICK_W, h: BRICK_H, alive: true }); } } } initBricks(); // ---------- KEY INPUT ---------- document.addEventListener('keydown', e => { if (e.key === 'ArrowLeft' || e.key === 'KeyA') keys.left = true; if (e.key === 'ArrowRight' || e.key === 'KeyD') keys.right = true; }); document.addEventListener('keyup', e => { if (e.key === 'ArrowLeft' || e.key === 'KeyA') keys.left = false; if (e.key === 'ArrowRight' || e.key === 'KeyD') keys.right = false; }); canvas.addEventListener('mousedown', e => { if (state.gameMode !== 'playing') return; dragging = true; lastTouchX = e.clientX; }); canvas.addEventListener('mousemove', e => { if (!dragging) return; movePaddle(e.clientX); }); canvas.addEventListener('mouseleave', () => dragging = false); // Touch support canvas.addEventListener('touchstart', e => { if (e.touches.length !== 1) return; const t = e.touches[0]; if (state.gameMode !== 'playing') return; dragging = true; lastTouchX = t.clientX; }, { passive: false }); canvas.addEventListener('touchmove', e => { e.preventDefault(); if (!dragging) return; const t = e.touches[0]; movePaddle(t.clientX); }, { passive: false }); canvas.addEventListener('touchend', () => dragging = false); canvas.addEventListener('touchcancel', () => dragging = false); // ---------- PADLE MOVEMENT ---------- function movePaddle(clientX) { const cx = canvas.getBoundingClientRect().left + CANVAS_W / 2; const target = clientX - cx; // clamp const min = 0; const max = CANVAS_W - PADDLE_W; state.paddleX = Math.max(min, Math.min(max, target)); } // ---------- EASING (smooth paddle follow) ---------- const EASE_K = 0.08; // smoothing factor per frame // ---------- PHYSIC HELPER ---------- function normalizeSpeed(dx, dy) { const len = Math.sqrt(dx * dx + dy * dy); if (len === 0) return { dx: BALL_SPEED, dy: -BALL_SPEED }; return { dx: (dx / len) * BALL_SPEED, dy: (dy / len) * BALL_SPEED }; } function reflectOffWall(dx, dy, wallX, wallY, w, h) { // flip the appropriate axis depending on which side hit if (dx > 0) dx = -dx; // right wall else if (dx < 0) {} // left wall if (dy < 0) dy = -dy; // top wall // bottom handled separately } // ---------- COLLISION DETECTION (AABB, single per frame) ---------- function resolveCollisions() { // Track closest brick hit this frame to avoid double-processing let brickHit = null; const bx = state.ball.x - BALL_R; const by = state.ball.y - BALL_R; const bx2 = state.ball.x + BALL_R; const by2 = state.ball.y + BALL_R; for (let i = state.bricks.length - 1; i >= 0; i--) { const b = state.bricks[i]; if (!b.alive) continue; const brx = b.x; const bry = b.y; const bw = b.w; const bh = b.h; const br2x = brx + bw; const br2y = bry + bh; // AABB check if (bx < br2x && bx2 > brx && by < br2y && by2 > bry) { // Determine which edge we hit (use overlap ratios) const ox = (Math.min(bx2, br2x) - Math.max(bx, brx)) / (BW_PADDING); const oy = (Math.min(by2, br2y) - Math.max(by, bry)) / (BH_PADDING); // Prefer vertical hit if more overlap vertically -> flip dy if (oy > ox * 0.5) { // Flip dy and dampen slightly for realism state.ball.dy = -state.ball.dy; // Reduce speed a bit on brick hit to slow down gradually const prevMag = Math.sqrt(state.ball.dx**2 + state.ball.dy**2); state.ball.dx *= 0.98; state.ball.dy *= 0.98; state.ball.y = bry + (bh - BALL_R) - oy * bh; // clamp to brick top edge area } else { state.ball.dx = -state.ball.dx; // Side hit - keep speed mostly, small dampen state.ball.dy *= 0.995; state.ball.x = brx + (bw - BALL_R) - ox * bw; } // Kill brick b.alive = false; if (brickHit === null) brickHit = { bx: bx, by: by }; state.score += BRICK_POINTS; updateScoreDisplay(); checkWin(); } } // If ball is stuck inside a brick, nudge it out using last collision point if (brickHit && !(state.ball.x > brickHit.bx + BALL_R && state.ball.x < brickHit.bx - BALL_R && state.ball.y > brickHit.by + BALL_R && state.ball.y < brickHit.by - BALL_R)) { // Pull ball out of the brick using its velocity direction const dx = state.ball.dx; const dy = state.ball.dy; const len = Math.sqrt(dx*dx + dy*dy) || 1; if (state.ball.x > brickHit.bx && state.ball.x < brickHit.bx + brickHit.w) { // inside horizontally -> push out right state.ball.x = brickHit.x + brickHit.w + BALL_R + Math.abs(dx)/len * 2; state.ball.dx *= -1; } else if (state.ball.y > brickHit.by && state.ball.y < brickHit.by + brickHit.h) { // inside vertically -> push out up state.ball.y = brickHit.y + BALL_R + Math.abs(dy)/len * 2; state.ball.dy *= -1; } else if (state.ball.x < brickHit.bx) { state.ball.x = brickHit.x - BALL_R - Math.abs(dx)/len * 2; state.ball.dx *= -1; } else if (state.ball.y < brickHit.by) { state.ball.y = brickHit.y - BALL_R - Math.abs(dy)/len * 2; state.ball.dy *= -1; } } // Apply ball position from velocity to update grid state.ball.x += state.ball.dx; state.ball.y += state.ball.dy; // Clamp ball to canvas bounds (prevent odd edge cases) state.ball.x = Math.max(BALL_R, Math.min(CANVAS_W - BALL_R, state.ball.x)); state.ball.y = Math.max(BALL_R, Math.min(CANVAS_H - BALL_R, state.ball.y)); } function BW_PADDING = 2; // used for overlap ratio calc (inline) const BH_PADDING = 2; // ---------- Paddle Collision ---------- function checkPaddleCollision() { const bx = state.ball.x - BALL_R; by = state.ball.y - BALL_R; bx2 = state.ball.x + BALL_R; by2 = state.ball.y + BALL_R; const px = state.paddleX; const py = PADDLE_Y; const pw = PADDLE_W; const ph = PADDLE_H; // Only process if we haven't already collided this frame with something else if (state.lastCollide) { // Re-check: is ball now clear of all previously collided objects? const cx = state.lastCollide.x + (state.lastCollide.type === 'brick' ? 0 : -pw); const cy = state.lastCollide.y; if (bx > cx && bx2 < cx + (state.lastCollide.type === 'brick' ? 50 : pw) && by < cy && by2 > cy - BALL_R + 1) { // still overlapping -> resolve later (stuck-in-brick case) return true; // mark as collision this frame } } if (bx < px + pw && bx2 > px && by < py + ph && by2 > py) { // Clamp ball to paddle surface to prevent sticking state.ball.x = px + BALL_R + (state.ball.x - (px + BALL_R)) * 0; // no drift state.ball.y = py - BALL_R; // Compute horizontal hit position within paddle (normalized 0..1) const hitPos = ((state.ball.x - px) / pw); // 0 = left edge, 1 = right edge const maxAngle = Math.PI / 8; // ~22.5° max angle from vertical let newDx = 0; if (hitPos < 0.5) { newDx = MAX_DX * (hitPos / 0.5); } else { newDx = -MAX_DX * ((1 - hitPos) / 0.5); } // Vertical component: ensure upward and magnitude preserved const prevMag = Math.sqrt(state.ball.dx**2 + state.ball.dy**2); const targetDy = -Math.sqrt(prevMag * prevMag - newDx*newDx); if (targetDy < MAX_DY) { // Normalize to max_dy const factor = MAX_DY / Math.abs(targetDy); newDx *= factor; targetDy *= factor; } state.ball.dx = newDx; state.ball.dy = -Math.max(MAX_DY, Math.abs(state.ball.dy)) * (targetDy / Math.abs(state.ball.dy) || 1); // Simpler robust approach: state.ball.dy = -(Math.sqrt(prevMag * prevMag - newDx*newDx) || 1); // Clamp ball inside paddle boundary zone state.ball.x = px + BALL_R + (state.ball.x - (px + BALL_R)); // keep at hit point state.ball.y = py - BALL_R; // Mark collision for this frame state.lastCollide = { x: px + pw / 2, y: py, type: 'paddle' }; return true; } state.lastCollide = null; return false; } // ---------- MAIN UPDATE LOOP ---------- function update() { // Paddle movement if (keys.left) { state.paddleX -= 6 * EASE_K; } if (keys.right) { state.paddleX += 6 * EASE_K; } if (dragging) { movePaddle(lastTouchX); // touch overrides keyboard easing } // Ball physics const mag = Math.sqrt(state.ball.dx**2 + state.ball.dy**2); // Normalize speed every frame for constant magnitude const s = BALL_SPEED; if (mag > 0) { state.ball.dx *= (s / mag); state.ball.dy *= (s / mag); } // Wall collisions let processedWall = false; if (state.ball.x - BALL_R < 0) { state.ball.dx = -state.ball.dx; state.ball.x = BALL_R; processedWall = true; } else if (state.ball.x + BALL_R > CANVAS_W) { state.ball.dx = -state.ball.dx; state.ball.x = CANVAS_W - BALL_R; processedWall = true; } if (!processedWall || state.ball.y - BALL_R < 0) { state.ball.dy = -state.ball.dy; state.ball.y = BALL_R; } else if (state.ball.y + BALL_R > CANVAS_H) { // Bottom: Game Over if below paddle const ballCenterY = state.ball.y; if (ballCenterY > PADDLE_Y + 8) { gameOver(); } else { state.ball.dy = -state.ball.dy; state.ball.y = PADDLE_Y - BALL_R + 2; // keep near paddle for safe bounce } } // Brick collisions (AABB, single per frame) let brickHit = false; // Use the last processed collision to avoid double-hit same frame if (!state.lastCollide || state.lastCollide.type !== 'brick') { if (checkBrickCollision()) brickHit = true; } // Paddle collision const paddleCol = checkPaddleCollision(); // If ball is still overlapping any solid object, nudge out using velocity if (state.lastCollide) { const lx = state.lastCollide.x; const ly = state.lastCollide.y; const ox = (bx2 > lx ? 1 : 0); // was right side? const oy = (by2 > ly ? 1 : 0); // was top side? // Simplified: push out in direction of velocity from nearest edge const dx = state.ball.dx; const dy = state.ball.dy; const len = Math.sqrt(dx*dx + dy*dy) || 1; let nx = lx - BALL_R - (dx/len) * 4; let ny = ly - BALL_R - (dy/len) * 4; if (bx > lx - 2 && bx < cx + 50) nx = lx + BALL_R + (dx/len) * 4; if (by < ly + 2) ny = ly + BALL_R + (dy/len) * 4; state.ball.x = Math.max(BALL_R, Math.min(CANVAS_W - BALL_R, nx)); state.ball.y = Math.max(BALL_R, Math.min(CANVAS_H - BALL_R, ny)); } } function checkBrickCollision() { const bx = state.ball.x - BALL_R; by = state.ball.y - BALL_R; bx2 = state.ball.x + BALL_R; by2 = state.ball.y + BALL_R; for (let i = state.bricks.length - 1; i >= 0; i--) { const b = state.bricks[i]; if (!b.alive) continue; const brx = b.x; const bry = b.y; const bw = b.w; const bh = b.h; // AABB if (bx < brx + bw && bx2 > brx && by < bry + bh && by2 > bry) { // Overlap ratio to determine edge const ox = (Math.min(bx2, brx+bw) - Math.max(bx, brx)) / 2; const oy = (Math.min(by2, bry+mh) - Math.max(by, bry)) / 2; // prefer vertical if hit spans more height-wise if (oy > ox * 0.55) { state.ball.dy = -state.ball.dy; // Keep speed similar, slightly dampen const prevMag = Math.sqrt(state.ball.dx**2 + state.ball.dy**2); state.ball.dx *= 0.99; state.ball.y = bry + bh - BALL_R - (oy * (bh - 2*BALL_R)); state.ball.x = brx + ((brx+bw) - bx) * 0.5 + BALL_R; // center horizontally in brick } else { state.ball.dx = -state.ball.dx; state.ball.dy *= 0.995; state.ball.x = brx + bw - BALL_R - (ox * (bw - 2*BALL_R)); state.ball.y = bry + ((bry+mh) - by) * 0.5 + BALL_R; } b.alive = false; state.lastCollide = { x: brx + bw/2, y: bry, type: 'brick' }; return true; } } return false; } // ---------- RENDER ---------- function draw() { ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); // Background ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, CANVAS_W, CANVAS_H); // Grid lines (optional subtle) ctx.strokeStyle = '#333'; ctx.lineWidth = 1; for (let x = 0; x <= CANVAS_W; x += 50) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, CANVAS_H); ctx.stroke(); } for (let y = 0; y <= CANVAS_H; y += 60) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(CANVAS_W, y); ctx.stroke(); } // Bricks const rowGap = 12; const colGap = 8; for (let r = 0; r < BRICK_ROWS; r++) { for (let c = 0; c < BRICK_COLS; c++) { const b = state.bricks[r][c]; // re-initialize? We store flat } } // Flatten bricks array properly let brickList = []; for (let r = 0; r < BRICK_ROWS; r++) { for (let c = 0; c < BRICK_COLS; c++) { const x = c * (BRICK_W + colGap); const y = r * (BRICK_H + rowGap); brickList.push({ x, y, w: BRICK_W, h: BRICK_H, alive: true }); } } // Update state.bricks from new list if needed state.bricks = brickList; if (state.gameMode === 'playing') { for (const b of state.bricks) { if (b.alive) { ctx.fillStyle = '#00ffff'; ctx.beginPath(); ctx.roundRect(b.x, b.y, b.w, b.h, 3); ctx.fill(); // Brick outline ctx.strokeStyle = '#9fffff'; ctx.lineWidth = 1; ctx.stroke(); } } } // Paddle ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.roundRect(state.paddleX, PADDLE_Y, PADDLE_W, PADDLE_H, 5); ctx.fill(); ctx.strokeStyle = '#999999'; ctx.stroke(); // Ball ctx.fillStyle = '#ffff00'; ctx.beginPath(); ctx.arc(state.ball.x, state.ball.y, BALL_R, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = '#d4a017'; ctx.lineWidth = 1; ctx.stroke(); // Score & Lives ctx.fillStyle = '#ffffff'; ctx.font = 'bold 18px Arial'; ctx.textAlign = 'left' ; ctx.fillText('Score: ' + state.score, 16, 32); const liveX = CANVAS_W - 200; ctx.fillText('Lives: ' + state.lives, liveX, 32); // Pause / game over / win overlays if (state.gameMode === 'gameover' || state.gameMode === 'won') { drawOverlay(); } // Start prompt (shown initially) if (state.gameMode === 'waiting') { ctx.fillStyle = '#fff'; ctx.font = '30px bold Arial'; ctx.textAlign = 'center'; ctx.fillText('CLICK or TAP to Start', CANVAS_W/2, 290); ctx.font = '16px Arial'; ctx.fillText('Breakout', CANVAS_W/2, 320); ctx.fillText('Desktop: Mouse/Arrows Mobile: Touch Drag', CANVAS_W/2, 350); } } // ---------- OVERLAY ---------- function drawOverlay() { const overlay = document.createElement('div'); overlay.style.cssText = ` position:fixed;inset:0;background:rgba(26,26,26,0.85); display:flex;flex-direction:center;justify-content:center;align-items:center; z-index:10; `; document.body.appendChild(overlay); const txt = document.createElement('div'); txt.style.cssText = 'color:#fff;font-family:Arial,sans-serif;font-size:48px;font-weight:bold;text-align:center;line-height:1.2'; let msg = state.gameMode === 'won' ? 'YOU WON!' : 'GAME OVER'; txt.textContent = msg; const scoreEl = document.createElement('div'); scoreEl.style.cssText = 'color:#00ffff;font-size:24px;margin-top:20px;text-align:center;'; scoreEl.textContent = 'Score: ' + state.score; const btn = document.createElement('button'); btn.textContent = 'Restart'; btn.style.cssText = 'margin-top:30px;padding:12px 28px;font-size:18px;border:none;border-radius:8px;background:#00ffff;color:#fff;cursor:pointer;'; btn.addEventListener('click', restartGame); overlay.appendChild(txt); overlay.appendChild(scoreEl); overlay.appendChild(btn); // Block canvas interaction during overlay canvas.style.pointerEvents = 'none'; setTimeout(() => { overlay.style.transition('opacity 0.3s'; overlay.style.opacity = '0'; setTimeout(() => overlay.remove(), 400); }, 600); } function restartGame() { initBricks(); // rebuild brick array (we already rebuild in draw, but safe) state.ball.dx