Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Enhanced Bubble Shooter Game</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| font-family: 'Arial', sans-serif; | |
| } | |
| .game-container { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 20px; | |
| padding: 20px; | |
| backdrop-filter: blur(10px); | |
| box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); | |
| text-align: center; /* Center canvas and UI elements */ | |
| } | |
| canvas { | |
| border: 3px solid #fff; | |
| border-radius: 10px; | |
| background: #000033; /* Darker blue for contrast */ | |
| display: block; | |
| margin: 0 auto; /* Center canvas */ | |
| } | |
| .ui { | |
| color: white; | |
| margin-bottom: 15px; | |
| display: flex; | |
| justify-content: space-around; | |
| align-items: center; | |
| } | |
| .score, .shots-info { | |
| font-size: 20px; | |
| font-weight: bold; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.5); | |
| } | |
| .message-area { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| background: rgba(0,0,0,0.7); | |
| color: white; | |
| padding: 20px 40px; | |
| border-radius: 10px; | |
| font-size: 28px; | |
| font-weight: bold; | |
| display: none; /* Hidden by default */ | |
| z-index: 100; | |
| text-align: center; | |
| } | |
| .controls { | |
| margin-top: 15px; | |
| color: rgba(255, 255, 255, 0.85); | |
| font-size: 14px; | |
| } | |
| button { | |
| background-color: #ff6b6b; | |
| color: white; | |
| border: none; | |
| padding: 10px 20px; | |
| border-radius: 5px; | |
| font-size: 16px; | |
| cursor: pointer; | |
| box-shadow: 0 4px 15px rgba(0,0,0,0.2); | |
| transition: background-color 0.3s ease; | |
| margin-top: 10px; | |
| } | |
| button:hover { | |
| background-color: #ff4757; | |
| } | |
| #restartButton { | |
| display: none; /* Hidden by default */ | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="game-container"> | |
| <div class="ui"> | |
| <div class="score">Score: <span id="score">0</span></div> | |
| <div class="shots-info">Next Row In: <span id="shotsUntilNextRow">0</span> shots</div> | |
| </div> | |
| <canvas id="gameCanvas" width="600" height="700"></canvas> | |
| <div class="controls"> | |
| Mouse se aim karo aur click karke bubble shoot karo!<br> | |
| Same color ke 3+ bubbles ko connect karo | |
| </div> | |
| <button id="restartButton">Restart Game</button> | |
| </div> | |
| <div id="messageDisplay" class="message-area"></div> | |
| <script> | |
| const canvas = document.getElementById('gameCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const scoreElement = document.getElementById('score'); | |
| const shotsUntilNextRowElement = document.getElementById('shotsUntilNextRow'); | |
| const messageDisplayElement = document.getElementById('messageDisplay'); | |
| const restartButton = document.getElementById('restartButton'); | |
| // Game constants | |
| const BUBBLE_RADIUS = 18; // Slightly smaller for more bubbles | |
| const BUBBLE_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#feca57', '#ff9ff3', '#54a0ff', '#ff7f50']; // Added one more color | |
| const ROWS = 16; // Grid dimensions | |
| const COLS = Math.floor(canvas.width / (BUBBLE_RADIUS * 2)); // Dynamic cols based on canvas width and radius | |
| const INIT_ROWS_COUNT = 6; // Initial rows of bubbles | |
| const SHOTS_UNTIL_NEW_ROW_THRESHOLD = 7; // Shots before new row | |
| const GAME_OVER_ROW_INDEX = ROWS - 1; // If a bubble reaches this row index | |
| // Game variables | |
| let score = 0; | |
| let gameRunning = true; | |
| let shotsFiredSinceLastRow = 0; | |
| const bubbleGrid = []; | |
| const shooter = { | |
| x: canvas.width / 2, | |
| y: canvas.height - 40, // Moved shooter slightly up | |
| angle: -Math.PI / 2, // Default aim upwards | |
| currentBubble: null, | |
| nextBubble: null | |
| }; | |
| let movingBubble = null; | |
| function init() { | |
| score = 0; | |
| shotsFiredSinceLastRow = 0; | |
| gameRunning = true; | |
| movingBubble = null; | |
| messageDisplayElement.style.display = 'none'; | |
| restartButton.style.display = 'none'; | |
| bubbleGrid.length = 0; // Clear existing grid | |
| for (let row = 0; row < ROWS; row++) { | |
| bubbleGrid[row] = new Array(COLS).fill(null); | |
| } | |
| // Create initial bubble grid | |
| for (let row = 0; row < INIT_ROWS_COUNT; row++) { | |
| for (let col = 0; col < (row % 2 === 1 ? COLS -1 : COLS) ; col++) { | |
| if (Math.random() < 0.75) { // Density of initial bubbles | |
| bubbleGrid[row][col] = { | |
| color: BUBBLE_COLORS[Math.floor(Math.random() * BUBBLE_COLORS.length)], | |
| x: getBubbleX(col, row), | |
| y: getBubbleY(row) | |
| }; | |
| } | |
| } | |
| } | |
| shooter.currentBubble = createRandomBubble(); | |
| shooter.nextBubble = createRandomBubble(); | |
| updateScoreDisplay(); | |
| updateShotsDisplay(); | |
| } | |
| function getBubbleX(col, row) { | |
| const offsetX = (row % 2 === 1) ? BUBBLE_RADIUS : 0; | |
| return col * (BUBBLE_RADIUS * 2) + BUBBLE_RADIUS + offsetX; | |
| } | |
| function getBubbleY(row) { | |
| // Use a slight overlap for tighter packing (sqrt(3) * R for perfect hex) | |
| return row * (BUBBLE_RADIUS * 2 * 0.866) + BUBBLE_RADIUS + 10; // +10 to push grid down a bit | |
| } | |
| function createRandomBubble() { | |
| // Try to provide colors that are present on the board | |
| const existingColors = new Set(); | |
| for (let r = 0; r < ROWS; r++) { | |
| for (let c = 0; c < COLS; c++) { | |
| if (bubbleGrid[r][c]) { | |
| existingColors.add(bubbleGrid[r][c].color); | |
| } | |
| } | |
| } | |
| let availableColors = Array.from(existingColors); | |
| if (availableColors.length === 0) { // If grid is empty or has no colors somehow | |
| availableColors = [...BUBBLE_COLORS]; | |
| } | |
| return { | |
| color: availableColors[Math.floor(Math.random() * availableColors.length)], | |
| radius: BUBBLE_RADIUS | |
| }; | |
| } | |
| function drawBubble(x, y, color, radius = BUBBLE_RADIUS) { | |
| ctx.beginPath(); | |
| ctx.arc(x, y, radius, 0, Math.PI * 2); | |
| ctx.fillStyle = color; | |
| ctx.fill(); | |
| const gradient = ctx.createRadialGradient(x - radius*0.3, y - radius*0.3, 0, x, y, radius); | |
| gradient.addColorStop(0, 'rgba(255, 255, 255, 0.5)'); | |
| gradient.addColorStop(0.8, 'rgba(255, 255, 255, 0)'); | |
| ctx.fillStyle = gradient; | |
| ctx.fill(); | |
| ctx.strokeStyle = 'rgba(0,0,0,0.2)'; // Darker stroke for definition | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| function drawGrid() { | |
| for (let row = 0; row < ROWS; row++) { | |
| for (let col = 0; col < (row % 2 === 1 ? COLS -1 : COLS) ; col++) { | |
| if (bubbleGrid[row][col]) { | |
| const bubble = bubbleGrid[row][col]; | |
| drawBubble(bubble.x, bubble.y, bubble.color); | |
| } | |
| } | |
| } | |
| } | |
| function drawShooter() { | |
| // Draw shooter base (more styled) | |
| ctx.fillStyle = '#555'; | |
| ctx.beginPath(); | |
| ctx.moveTo(shooter.x - 25, shooter.y + 15); | |
| ctx.lineTo(shooter.x + 25, shooter.y + 15); | |
| ctx.lineTo(shooter.x, shooter.y - 15); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| ctx.strokeStyle = '#333'; | |
| ctx.lineWidth = 2; | |
| ctx.stroke(); | |
| // Draw trajectory line | |
| drawTrajectory(); | |
| if (shooter.currentBubble) { | |
| drawBubble(shooter.x, shooter.y, shooter.currentBubble.color); | |
| } | |
| if (shooter.nextBubble) { | |
| drawBubble(shooter.x + 55, shooter.y + 15, shooter.nextBubble.color, BUBBLE_RADIUS * 0.75); | |
| ctx.fillStyle = 'white'; | |
| ctx.font = '11px Arial'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('Next', shooter.x + 55, shooter.y + 15 + BUBBLE_RADIUS *0.75 + 12); | |
| } | |
| } | |
| function drawTrajectory() { | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; | |
| ctx.lineWidth = 2; | |
| ctx.setLineDash([5, 5]); // Dashed line | |
| let x = shooter.x; | |
| let y = shooter.y; | |
| let angle = shooter.angle; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, y); | |
| for (let i = 0; i < 150; i++) { // Max length of trajectory line | |
| x += Math.cos(angle) * 5; // Small steps for trajectory | |
| y += Math.sin(angle) * 5; | |
| if (x <= BUBBLE_RADIUS || x >= canvas.width - BUBBLE_RADIUS) { | |
| // Reflect angle for wall bounce | |
| angle = Math.PI - angle; | |
| // Ensure it doesn't get stuck by slightly adjusting position | |
| x = (x <= BUBBLE_RADIUS) ? BUBBLE_RADIUS + 1 : canvas.width - BUBBLE_RADIUS - 1; | |
| } | |
| if (y <= BUBBLE_RADIUS) { // Hit top | |
| y = BUBBLE_RADIUS; | |
| ctx.lineTo(x,y); | |
| break; | |
| } | |
| // Simplified: doesn't check trajectory collision with existing bubbles | |
| ctx.lineTo(x,y); | |
| } | |
| ctx.stroke(); | |
| ctx.setLineDash([]); // Reset line dash | |
| } | |
| function drawMovingBubble() { | |
| if (movingBubble) { | |
| drawBubble(movingBubble.x, movingBubble.y, movingBubble.color); | |
| } | |
| } | |
| function shoot() { | |
| if (!gameRunning || !shooter.currentBubble || movingBubble) return; | |
| movingBubble = { | |
| x: shooter.x, | |
| y: shooter.y, | |
| color: shooter.currentBubble.color, | |
| vx: Math.cos(shooter.angle) * 10, // Increased speed | |
| vy: Math.sin(shooter.angle) * 10, | |
| radius: BUBBLE_RADIUS | |
| }; | |
| shooter.currentBubble = shooter.nextBubble; | |
| shooter.nextBubble = createRandomBubble(); | |
| shotsFiredSinceLastRow++; | |
| updateShotsDisplay(); | |
| } | |
| function updateMovingBubble() { | |
| if (!movingBubble) return; | |
| movingBubble.x += movingBubble.vx; | |
| movingBubble.y += movingBubble.vy; | |
| if (movingBubble.x <= BUBBLE_RADIUS || movingBubble.x >= canvas.width - BUBBLE_RADIUS) { | |
| movingBubble.vx = -movingBubble.vx; | |
| // Clamp position to prevent sticking in wall | |
| movingBubble.x = Math.max(BUBBLE_RADIUS, Math.min(movingBubble.x, canvas.width - BUBBLE_RADIUS)); | |
| } | |
| if (movingBubble.y <= BUBBLE_RADIUS) { | |
| movingBubble.y = BUBBLE_RADIUS; // Snap to top if it goes above | |
| attachBubble(); | |
| return; | |
| } | |
| for (let row = 0; row < ROWS; row++) { | |
| for (let col = 0; col < (row % 2 === 1 ? COLS -1 : COLS); col++) { | |
| if (bubbleGrid[row][col]) { | |
| const bubble = bubbleGrid[row][col]; | |
| const distance = Math.sqrt( | |
| (movingBubble.x - bubble.x) ** 2 + | |
| (movingBubble.y - bubble.y) ** 2 | |
| ); | |
| if (distance < BUBBLE_RADIUS * 1.9) { // Collision threshold (slightly less than 2R) | |
| attachBubble(); | |
| return; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| function attachBubble() { | |
| if (!movingBubble || !gameRunning) return; | |
| let bestRow = -1, bestCol = -1; | |
| let minDistance = Infinity; | |
| // Iterate over potential grid slots | |
| for (let r = 0; r < ROWS; r++) { | |
| for (let c = 0; c < (r % 2 === 1 ? COLS -1 : COLS); c++) { | |
| if (bubbleGrid[r][c]) continue; // Slot already taken | |
| const gridX = getBubbleX(c, r); | |
| const gridY = getBubbleY(r); | |
| const distance = Math.sqrt((movingBubble.x - gridX) ** 2 + (movingBubble.y - gridY) ** 2); | |
| // Prioritize slots closer to the bubble's impact point and higher up | |
| if (distance < BUBBLE_RADIUS * 2.5 && distance < minDistance) { | |
| minDistance = distance; | |
| bestRow = r; | |
| bestCol = c; | |
| } | |
| } | |
| } | |
| // If no suitable empty slot found nearby (e.g. flying into dense cluster), | |
| // try to find the closest grid point based on y, then x. | |
| if (bestRow === -1 || bestCol === -1) { | |
| // Fallback: simple row/col calculation based on y/x | |
| let targetRow = Math.round((movingBubble.y - BUBBLE_RADIUS -10) / (BUBBLE_RADIUS * 2 * 0.866)); | |
| targetRow = Math.max(0, Math.min(ROWS - 1, targetRow)); | |
| let targetCol = Math.round((movingBubble.x - BUBBLE_RADIUS - (targetRow % 2 === 1 ? BUBBLE_RADIUS : 0)) / (BUBBLE_RADIUS * 2)); | |
| targetCol = Math.max(0, Math.min((targetRow % 2 === 1 ? COLS -2 : COLS -1) , targetCol)); | |
| if (!bubbleGrid[targetRow][targetCol]) { | |
| bestRow = targetRow; | |
| bestCol = targetCol; | |
| } else { // If still can't find, place it at the earliest possible slot | |
| // This part needs more robust logic for "forcing" a position if all else fails | |
| // For now, if movingBubble.y is very low, it might be an issue. | |
| // Let's assume it finds a spot for now or game over will trigger. | |
| console.warn("Could not find ideal slot, bubble might be lost or placed oddly."); | |
| // Attempt to place in the row determined by movingBubble.y, first available column | |
| for (let c_fallback = 0; c_fallback < (targetRow % 2 === 1 ? COLS -1 : COLS); c_fallback++) { | |
| if (!bubbleGrid[targetRow][c_fallback]) { | |
| bestRow = targetRow; bestCol = c_fallback; break; | |
| } | |
| } | |
| } | |
| } | |
| if (bestRow !== -1 && bestCol !== -1) { | |
| bubbleGrid[bestRow][bestCol] = { | |
| color: movingBubble.color, | |
| x: getBubbleX(bestCol, bestRow), | |
| y: getBubbleY(bestRow) | |
| }; | |
| movingBubble = null; // Bubble is now part of the grid | |
| const matches = checkMatches(bestRow, bestCol); | |
| if (bestRow >= GAME_OVER_ROW_INDEX && bubbleGrid[bestRow][bestCol]) { | |
| gameOver("Bubbles reached the bottom!"); | |
| return; | |
| } | |
| if (matches.length === 0 && shotsFiredSinceLastRow >= SHOTS_UNTIL_NEW_ROW_THRESHOLD) { | |
| addNewRow(); | |
| shotsFiredSinceLastRow = 0; | |
| updateShotsDisplay(); | |
| } | |
| checkWinCondition(); | |
| } else { // Should not happen often with fallback, but if it does: | |
| console.error("Failed to attach bubble!"); | |
| movingBubble = null; // Prevent infinite loop | |
| checkGameOver(); // Check if this state is game over | |
| } | |
| } | |
| function checkMatches(row, col) { | |
| if (!bubbleGrid[row] || !bubbleGrid[row][col]) return []; | |
| const targetColor = bubbleGrid[row][col].color; | |
| const toProcess = [{r: row, c: col}]; | |
| const matchedBubbles = []; | |
| const visited = new Set(); | |
| visited.add(`${row},${col}`); | |
| while(toProcess.length > 0) { | |
| const current = toProcess.pop(); | |
| matchedBubbles.push(current); | |
| const neighbors = getNeighbors(current.r, current.c); | |
| for (const [nr, nc] of neighbors) { | |
| const key = `${nr},${nc}`; | |
| if (!visited.has(key) && bubbleGrid[nr] && bubbleGrid[nr][nc] && bubbleGrid[nr][nc].color === targetColor) { | |
| visited.add(key); | |
| toProcess.push({r: nr, c: nc}); | |
| } | |
| } | |
| } | |
| if (matchedBubbles.length >= 3) { | |
| matchedBubbles.forEach(match => { | |
| bubbleGrid[match.r][match.c] = null; | |
| }); | |
| score += matchedBubbles.length * 10; | |
| updateScoreDisplay(); | |
| removeFloatingBubbles(); | |
| return matchedBubbles; // Return actual matches | |
| } | |
| return []; // No matches of 3 or more | |
| } | |
| function getNeighbors(row, col) { | |
| const neighbors = []; | |
| const isOddRow = row % 2 === 1; | |
| const deltas = [ | |
| // Top row neighbors | |
| { dr: -1, dc: isOddRow ? 0 : -1 }, { dr: -1, dc: isOddRow ? 1 : 0 }, | |
| // Same row neighbors | |
| { dr: 0, dc: -1 }, { dr: 0, dc: 1 }, | |
| // Bottom row neighbors | |
| { dr: 1, dc: isOddRow ? 0 : -1 }, { dr: 1, dc: isOddRow ? 1 : 0 } | |
| ]; | |
| deltas.forEach(delta => { | |
| const nr = row + delta.dr; | |
| const nc = col + delta.dc; | |
| if (nr >= 0 && nr < ROWS && nc >= 0 && nc < (nr % 2 === 1 ? COLS -1: COLS)) { | |
| // Check specific COLS limit for odd/even rows | |
| if (bubbleGrid[nr] !== undefined) { // Ensure row exists | |
| neighbors.push([nr, nc]); | |
| } | |
| } | |
| }); | |
| return neighbors; | |
| } | |
| function removeFloatingBubbles() { | |
| const connectedToTop = new Set(); | |
| for (let c = 0; c < COLS; c++) { | |
| if (bubbleGrid[0][c]) { | |
| markConnected(0, c, connectedToTop); | |
| } | |
| } | |
| let floatingCount = 0; | |
| for (let r = 0; r < ROWS; r++) { | |
| for (let c = 0; c < (r % 2 === 1 ? COLS -1: COLS); c++) { | |
| if (bubbleGrid[r][c] && !connectedToTop.has(`${r},${c}`)) { | |
| bubbleGrid[r][c] = null; | |
| floatingCount++; | |
| } | |
| } | |
| } | |
| if (floatingCount > 0) { | |
| score += floatingCount * 15; // Bonus for floating | |
| updateScoreDisplay(); | |
| } | |
| } | |
| function markConnected(r, c, connectedSet) { | |
| const key = `${r},${c}`; | |
| if (connectedSet.has(key) || !bubbleGrid[r] || !bubbleGrid[r][c]) { | |
| return; | |
| } | |
| connectedSet.add(key); | |
| const neighbors = getNeighbors(r, c); | |
| neighbors.forEach(([nr, nc]) => markConnected(nr, nc, connectedSet)); | |
| } | |
| function addNewRow() { | |
| if (!gameRunning) return; | |
| // First, check if adding a new row would immediately cause game over | |
| // by pushing existing bubbles into the game over zone. | |
| for (let c = 0; c < ( (GAME_OVER_ROW_INDEX -1) % 2 === 1 ? COLS -1 : COLS ); c++) { | |
| if (bubbleGrid[GAME_OVER_ROW_INDEX - 1] && bubbleGrid[GAME_OVER_ROW_INDEX - 1][c]) { | |
| gameOver("Bubbles overflowed!"); | |
| // Shift for visual effect then show game over | |
| // Perform the shift anyway so player sees it happen | |
| shiftRowsDown(); | |
| bubbleGrid[GAME_OVER_ROW_INDEX][c] = bubbleGrid[GAME_OVER_ROW_INDEX-1][c]; | |
| if( bubbleGrid[GAME_OVER_ROW_INDEX][c]) { | |
| bubbleGrid[GAME_OVER_ROW_INDEX][c].y = getBubbleY(GAME_OVER_ROW_INDEX); | |
| bubbleGrid[GAME_OVER_ROW_INDEX][c].x = getBubbleX(c, GAME_OVER_ROW_INDEX); | |
| } | |
| return; | |
| } | |
| } | |
| shiftRowsDown(); | |
| // Create new top row | |
| bubbleGrid[0] = new Array(COLS).fill(null); | |
| for (let c = 0; c < COLS; c++) { | |
| // For row 0 (even), max columns is COLS | |
| if (Math.random() < 0.6) { // Density of new row | |
| bubbleGrid[0][c] = { | |
| color: BUBBLE_COLORS[Math.floor(Math.random() * BUBBLE_COLORS.length)], | |
| x: getBubbleX(c, 0), | |
| y: getBubbleY(0) | |
| }; | |
| } | |
| } | |
| checkGameOver(); // Check after new row fully added | |
| } | |
| function shiftRowsDown() { | |
| for (let r = ROWS - 1; r > 0; r--) { | |
| bubbleGrid[r] = bubbleGrid[r - 1]; | |
| if (bubbleGrid[r]) { | |
| for (let c = 0; c < (r % 2 === 1 ? COLS -1 : COLS); c++) { | |
| if (bubbleGrid[r][c]) { | |
| bubbleGrid[r][c].y = getBubbleY(r); | |
| bubbleGrid[r][c].x = getBubbleX(c,r); // Important to update x too due to row offset | |
| } | |
| } | |
| } | |
| } | |
| } | |
| function checkGameOver() { | |
| if (!gameRunning) return; | |
| for (let c = 0; c < (GAME_OVER_ROW_INDEX % 2 === 1 ? COLS -1 : COLS); c++) { | |
| if (bubbleGrid[GAME_OVER_ROW_INDEX] && bubbleGrid[GAME_OVER_ROW_INDEX][c]) { | |
| gameOver("Bubbles reached the bottom line!"); | |
| return; | |
| } | |
| } | |
| } | |
| function checkWinCondition() { | |
| if (!gameRunning) return; | |
| for (let r = 0; r < ROWS; r++) { | |
| for (let c = 0; c < (r % 2 === 1 ? COLS -1 : COLS); c++) { | |
| if (bubbleGrid[r][c]) return; // Found a bubble, game not won | |
| } | |
| } | |
| gameWon("You cleared all bubbles! YOU WIN!"); | |
| } | |
| function gameOver(message = "Game Over!") { | |
| gameRunning = false; | |
| messageDisplayElement.textContent = message; | |
| messageDisplayElement.style.display = 'block'; | |
| restartButton.style.display = 'block'; | |
| console.log("Game Over:", message); | |
| } | |
| function gameWon(message = "You Win!") { | |
| gameRunning = false; | |
| messageDisplayElement.textContent = message; | |
| messageDisplayElement.style.display = 'block'; | |
| restartButton.style.display = 'block'; | |
| console.log("Game Won:", message); | |
| } | |
| function updateScoreDisplay() { | |
| scoreElement.textContent = score; | |
| } | |
| function updateShotsDisplay() { | |
| const shotsLeft = SHOTS_UNTIL_NEW_ROW_THRESHOLD - shotsFiredSinceLastRow; | |
| shotsUntilNextRowElement.textContent = Math.max(0, shotsLeft); | |
| } | |
| // Event handlers | |
| canvas.addEventListener('mousemove', (e) => { | |
| if (!gameRunning) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const mouseX = e.clientX - rect.left; | |
| const mouseY = e.clientY - rect.top; | |
| shooter.angle = Math.atan2(mouseY - shooter.y, mouseX - shooter.x); | |
| // Limit angle to upward shots (approx -5 to -175 degrees) | |
| const minAngle = -Math.PI * 0.95; // About -171 degrees | |
| const maxAngle = -Math.PI * 0.05; // About -9 degrees | |
| shooter.angle = Math.max(minAngle, Math.min(shooter.angle, maxAngle)); | |
| }); | |
| canvas.addEventListener('click', () => { | |
| if (gameRunning) { | |
| shoot(); | |
| } | |
| }); | |
| restartButton.addEventListener('click', () => { | |
| init(); | |
| gameLoop(); // Restart the game loop | |
| }); | |
| function gameLoop() { | |
| if (!gameRunning) { | |
| // Draw one last time to show final state (e.g., game over bubbles) | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| drawGrid(); | |
| drawShooter(); // Still draw shooter for context | |
| // No moving bubble when game is over | |
| return; // Stop the loop | |
| } | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| updateMovingBubble(); | |
| drawGrid(); | |
| drawShooter(); | |
| drawMovingBubble(); | |
| requestAnimationFrame(gameLoop); | |
| } | |
| // Start game | |
| init(); | |
| gameLoop(); | |
| </script> | |
| </body> | |
| </html> |