Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
| <title>Enhanced Bubble Shooter Pro - V2</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| padding: 0; | |
| background: linear-gradient(135deg, #4e54c8 0%, #8f94fb 100%); /* New Gradient */ | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| font-family: 'Arial Rounded MT Bold', Arial, sans-serif; /* Softer Font */ | |
| overflow: hidden; | |
| touch-action: none; | |
| } | |
| .game-wrapper { | |
| position: relative; | |
| } | |
| .game-container { | |
| background: rgba(255, 255, 255, 0.15); /* Slightly more opaque */ | |
| border-radius: 25px; /* More rounded */ | |
| padding: 20px; | |
| backdrop-filter: blur(12px); | |
| box-shadow: 0 10px 35px rgba(0, 0, 0, 0.35); | |
| text-align: center; | |
| width: fit-content; | |
| } | |
| #gameCanvas { /* Renamed from canvas to #gameCanvas for specificity */ | |
| border: 3px solid rgba(255, 255, 255, 0.7); /* Thicker, semi-transparent border */ | |
| border-radius: 15px; /* More rounded canvas */ | |
| background: linear-gradient(180deg, #2c3e50 0%, #1a2530 100%); /* Darker, rich background */ | |
| display: block; | |
| margin: 0 auto; | |
| } | |
| .ui { | |
| color: white; | |
| margin-bottom: 15px; /* More spacing */ | |
| display: flex; | |
| justify-content: space-around; | |
| align-items: center; | |
| font-size: 18px; /* Larger UI text */ | |
| padding: 5px 10px; | |
| background: rgba(0,0,0,0.2); | |
| border-radius: 10px; | |
| } | |
| .score, .shots-info { | |
| font-weight: bold; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.6); | |
| } | |
| .overlay-message { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| background: rgba(26, 37, 46, 0.9); /* Darker overlay */ | |
| color: white; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| font-size: 26px; /* Larger base font for overlays */ | |
| font-weight: bold; | |
| text-align: center; | |
| z-index: 100; | |
| border-radius: 15px; | |
| padding: 20px; | |
| box-sizing: border-box; | |
| } | |
| .overlay-message h2 { | |
| margin-bottom: 25px; | |
| font-size: 1.6em; /* Relative to overlay font-size */ | |
| color: #ffdd57; /* Goldish color for titles */ | |
| } | |
| .overlay-message p { | |
| font-size: 0.8em; | |
| line-height: 1.5; | |
| margin-bottom: 20px; | |
| } | |
| .controls-info { | |
| margin-top: 15px; | |
| color: rgba(255, 255, 255, 0.9); | |
| font-size: 13px; | |
| padding: 5px; | |
| } | |
| button.game-button { | |
| background-color: #20bf6b; /* Vibrant Green */ | |
| color: white; | |
| border: none; | |
| padding: 14px 28px; /* More padding */ | |
| border-radius: 10px; /* Slightly less round */ | |
| font-size: 18px; | |
| font-weight: bold; | |
| cursor: pointer; | |
| box-shadow: 0 5px 20px rgba(0,0,0,0.25); | |
| transition: background-color 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease; | |
| margin: 10px; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| button.game-button:hover { | |
| background-color: #1abc9c; /* Tealish green */ | |
| box-shadow: 0 7px 25px rgba(0,0,0,0.3); | |
| transform: translateY(-2px); | |
| } | |
| button.game-button:active { | |
| transform: translateY(0px) scale(0.96); | |
| box-shadow: 0 3px 15px rgba(0,0,0,0.2); | |
| } | |
| #restartButton { background-color: #eb3b5a; } /* Red for restart */ | |
| #restartButton:hover { background-color: #fc5c65; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="game-wrapper"> | |
| <div class="game-container"> | |
| <div class="ui"> | |
| <div class="score">Score: <span id="score">0</span></div> | |
| <div class="shots-info">Next Row: <span id="shotsUntilNextRow">0</span></div> | |
| </div> | |
| <canvas id="gameCanvas"></canvas> | |
| <div class="controls-info"> | |
| Drag/Click: Aim & Shoot | Tap "Next" Bubble to Swap | |
| </div> | |
| </div> | |
| <div id="overlayScreen" class="overlay-message" style="display: none;"> | |
| <h2 id="overlayTitle">Game Over!</h2> | |
| <p id="overlayScore"></p> | |
| <button id="restartButton" class="game-button">Play Again</button> | |
| <!-- <button id="menuButton" class="game-button">Main Menu</button> Removed for simplicity, restart is more direct --> | |
| </div> | |
| <div id="startScreen" class="overlay-message"> | |
| <h2>Bubble Shooter Pro V2</h2> | |
| <p>Clear the bubbles to win each level!</p> | |
| <button id="startButton" class="game-button">Start Game</button> | |
| </div> | |
| </div> | |
| <script> | |
| // --- Game Objects & Elements --- | |
| const canvas = document.getElementById('gameCanvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const scoreElement = document.getElementById('score'); | |
| const shotsUntilNextRowElement = document.getElementById('shotsUntilNextRow'); | |
| const overlayScreen = document.getElementById('overlayScreen'); | |
| const overlayTitle = document.getElementById('overlayTitle'); | |
| const overlayScore = document.getElementById('overlayScore'); | |
| const startScreen = document.getElementById('startScreen'); | |
| const restartButton = document.getElementById('restartButton'); | |
| const startButton = document.getElementById('startButton'); | |
| // --- Game States --- | |
| const GAME_STATE = { | |
| MENU: 'MENU', | |
| PLAYING: 'PLAYING', | |
| GAME_OVER: 'GAME_OVER', | |
| LEVEL_CLEARED: 'LEVEL_CLEARED', // For future level progression | |
| // YOU_WIN: 'YOU_WIN', // Can be added if there's a final win condition | |
| }; | |
| let currentGameState = GAME_STATE.MENU; | |
| // --- Game Constants & Configuration --- | |
| let BUBBLE_RADIUS = 20; // Initial, will be recalculated | |
| let BUBBLE_DIAMETER = BUBBLE_RADIUS * 2; | |
| let ROWS = 15; // Default, will be recalculated | |
| let COLS = 10; // Default, will be recalculated | |
| let GAME_OVER_ROW_INDEX = ROWS - 1; | |
| const INIT_ROWS_PERCENTAGE = 0.4; // Percentage of ROWS to fill initially | |
| const SHOTS_UNTIL_NEW_ROW_THRESHOLD = 5; | |
| const MAX_TRAJECTORY_BOUNCES = 2; | |
| const BUBBLE_POP_SCORE = 10; | |
| const FLOATING_BUBBLE_SCORE = 15; | |
| const POWERUP_BONUS_SCORE = 25; | |
| const BUBBLE_COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f1c40f', '#9b59b6', '#e67e22']; // Adjusted palette | |
| const POWERUP_TYPE = { | |
| NONE: 'NONE', | |
| BOMB: 'BOMB', | |
| RAINBOW: 'RAINBOW', | |
| COLOR_SPLASH: 'COLOR_SPLASH' | |
| }; | |
| const BOMB_RADIUS_MULTIPLIER = 2.8; | |
| const POWERUP_CHANCE_SHOOTER = 0.10; // Chance shooter bubble is a power-up | |
| const POWERUP_CHANCE_GRID = 0.03; // Chance grid bubble is a power-up (lower) | |
| // --- Game Variables --- | |
| let score = 0; | |
| let shotsFiredSinceLastRow = 0; | |
| const bubbleGrid = []; // Will be 2D array: bubbleGrid[row][col] | |
| const shooter = { x: 0, y: 0, angle: -Math.PI / 2, currentBubble: null, nextBubble: null, nextBubbleArea: {} }; | |
| let movingBubble = null; | |
| let fallingBubbles = []; | |
| let particles = []; | |
| let isAiming = false; | |
| let lastTimestamp = 0; | |
| // --- Canvas & Game Setup --- | |
| function setupCanvasAndGameConfig() { | |
| const screenWidth = window.innerWidth; | |
| const screenHeight = window.innerHeight; | |
| // Determine COLS based on fitting at least 8 bubbles, max around 12-14 for typical screens | |
| const desiredCols = Math.max(8, Math.min(14, Math.floor(screenWidth * 0.9 / (BUBBLE_RADIUS * 2.2)))); | |
| COLS = desiredCols; | |
| // Calculate BUBBLE_RADIUS based on COLS and available width | |
| const maxCanvasWidth = Math.min(screenWidth * 0.95, 600); // Cap max width | |
| BUBBLE_RADIUS = Math.floor(maxCanvasWidth / ((COLS + 0.5) * 2)); | |
| BUBBLE_DIAMETER = BUBBLE_RADIUS * 2; | |
| canvas.width = Math.floor((COLS + 0.5) * BUBBLE_DIAMETER); | |
| const bubbleRowHeight = BUBBLE_RADIUS * 2 * 0.8660254; // sqrt(3)/2 for hex packing | |
| // Calculate ROWS based on available height, aiming for a certain aspect ratio | |
| const desiredCanvasHeight = Math.min(screenHeight * 0.75, canvas.width * 1.5); | |
| ROWS = Math.floor((desiredCanvasHeight - BUBBLE_DIAMETER * 3) / bubbleRowHeight); // Leave space for shooter | |
| ROWS = Math.max(10, Math.min(ROWS, 20)); // Clamp ROWS | |
| canvas.height = Math.floor(ROWS * bubbleRowHeight + BUBBLE_DIAMETER * 3.5); // Total canvas height | |
| GAME_OVER_ROW_INDEX = ROWS - 2; // Game over if bubbles reach the second to last effective row visually | |
| shooter.x = canvas.width / 2; | |
| shooter.y = canvas.height - BUBBLE_DIAMETER * 1.7; | |
| shooter.nextBubbleArea = { | |
| x: canvas.width - BUBBLE_DIAMETER * 1.5 - 5, // Positioned to the right | |
| y: shooter.y - BUBBLE_RADIUS * 0.7, | |
| radius: BUBBLE_RADIUS * 0.75 | |
| }; | |
| } | |
| function initGame() { | |
| currentGameState = GAME_STATE.PLAYING; | |
| overlayScreen.style.display = 'none'; | |
| startScreen.style.display = 'none'; | |
| score = 0; | |
| shotsFiredSinceLastRow = 0; | |
| movingBubble = null; | |
| fallingBubbles = []; | |
| particles = []; | |
| bubbleGrid.length = 0; | |
| for (let r = 0; r < ROWS; r++) { | |
| bubbleGrid[r] = new Array(getColsInRow(r)).fill(null); | |
| } | |
| let initRowsToFill = Math.floor(ROWS * INIT_ROWS_PERCENTAGE); | |
| initRowsToFill = Math.max(1, Math.min(initRowsToFill, GAME_OVER_ROW_INDEX - 2)); // Ensure space | |
| for (let r = 0; r < initRowsToFill; r++) { | |
| for (let c = 0; c < getColsInRow(r); c++) { | |
| if (Math.random() < 0.75) { // Grid density | |
| bubbleGrid[r][c] = createGridBubble(c, r, null, Math.random() < POWERUP_CHANCE_GRID ? getRandomPowerUpType() : POWERUP_TYPE.NONE ); | |
| } | |
| } | |
| } | |
| shooter.currentBubble = createShooterBubble(); | |
| shooter.nextBubble = createShooterBubble(); | |
| updateScoreDisplay(); | |
| updateShotsDisplay(); | |
| } | |
| function getRandomPowerUpType() { | |
| const rand = Math.random(); | |
| if (rand < 0.4) return POWERUP_TYPE.BOMB; | |
| if (rand < 0.7) return POWERUP_TYPE.RAINBOW; | |
| return POWERUP_TYPE.COLOR_SPLASH; | |
| } | |
| // --- Grid Helper Functions --- | |
| function getColsInRow(row) { return (row % 2 === 1) ? COLS - 1 : COLS; } | |
| function getBubbleX(col, row) { const offsetX = (row % 2 === 1) ? BUBBLE_RADIUS : 0; return col * BUBBLE_DIAMETER + BUBBLE_RADIUS + offsetX; } | |
| function getBubbleY(row) { const gridTopMargin = BUBBLE_RADIUS; return row * (BUBBLE_RADIUS * 2 * 0.8660254) + BUBBLE_RADIUS + gridTopMargin; } | |
| // --- Bubble Creation --- | |
| function createGridBubble(col, row, color = null, powerUpType = POWERUP_TYPE.NONE) { | |
| return { | |
| color: powerUpType !== POWERUP_TYPE.NONE ? getPowerUpColor(powerUpType) : (color || BUBBLE_COLORS[Math.floor(Math.random() * BUBBLE_COLORS.length)]), | |
| x: getBubbleX(col, row), | |
| y: getBubbleY(row), | |
| powerUpType: powerUpType, | |
| row: row, | |
| col: col, | |
| id: `bubble-${row}-${col}-${Math.random().toString(36).substr(2, 5)}` // Unique ID | |
| }; | |
| } | |
| function createShooterBubble() { | |
| const existingColors = new Set(); | |
| let normalBubblesExist = false; | |
| for (let r = 0; r < ROWS; r++) { | |
| if (!bubbleGrid[r]) continue; | |
| for (let c = 0; c < getColsInRow(r); c++) { | |
| const cell = bubbleGrid[r][c]; | |
| if (cell && cell.powerUpType === POWERUP_TYPE.NONE) { | |
| existingColors.add(cell.color); | |
| normalBubblesExist = true; | |
| } | |
| } | |
| } | |
| let availableColors = Array.from(existingColors); | |
| if (!normalBubblesExist || availableColors.length === 0) { | |
| availableColors = [...BUBBLE_COLORS]; | |
| } | |
| let powerUp = POWERUP_TYPE.NONE; | |
| let color = availableColors[Math.floor(Math.random() * availableColors.length)]; | |
| if (Math.random() < POWERUP_CHANCE_SHOOTER) { | |
| powerUp = getRandomPowerUpType(); | |
| color = getPowerUpColor(powerUp); | |
| } | |
| return { color: color, radius: BUBBLE_RADIUS, powerUpType: powerUp }; | |
| } | |
| function getPowerUpColor(powerUpType) { | |
| if (powerUpType === POWERUP_TYPE.BOMB) return '#333333'; | |
| if (powerUpType === POWERUP_TYPE.COLOR_SPLASH) return '#DDA0DD'; // Plum | |
| if (powerUpType === POWERUP_TYPE.RAINBOW) return 'white'; // Will be drawn with gradient | |
| return BUBBLE_COLORS[0]; // Fallback | |
| } | |
| // --- Drawing Functions --- | |
| function drawBubble(bubble) { /* ... (Enhanced drawBubble from previous complete code) ... */ | |
| const { x, y, color, radius, powerUpType } = bubble; | |
| if (!radius || radius <= 0) return; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, radius, 0, Math.PI * 2); | |
| if (powerUpType === POWERUP_TYPE.RAINBOW) { | |
| const rainbowGradient = ctx.createRadialGradient(x, y, 0, x, y, radius); | |
| const activeColors = BUBBLE_COLORS.slice(0, Math.min(BUBBLE_COLORS.length, 5)); // Use up to 5 colors for rainbow | |
| activeColors.forEach((c, i) => rainbowGradient.addColorStop(Math.min(0.95, i / activeColors.length), c)); | |
| rainbowGradient.addColorStop(1, activeColors[0]); | |
| ctx.fillStyle = rainbowGradient; | |
| } else if (powerUpType === POWERUP_TYPE.COLOR_SPLASH) { | |
| const splashGradient = ctx.createRadialGradient(x,y,radius*0.2, x,y,radius); | |
| splashGradient.addColorStop(0, 'white'); | |
| splashGradient.addColorStop(0.3, color); | |
| splashGradient.addColorStop(0.6, BUBBLE_COLORS[Math.floor(Date.now()/300) % BUBBLE_COLORS.length]); | |
| splashGradient.addColorStop(1, color); | |
| ctx.fillStyle = splashGradient; | |
| } | |
| else { | |
| const bubbleGradient = ctx.createRadialGradient(x - radius * 0.4, y - radius * 0.4, radius * 0.1, x, y, radius); | |
| bubbleGradient.addColorStop(0, lightenColor(color, 0.3)); | |
| bubbleGradient.addColorStop(0.8, color); | |
| bubbleGradient.addColorStop(1, darkenColor(color, 0.2)); | |
| ctx.fillStyle = bubbleGradient; | |
| } | |
| ctx.fill(); | |
| ctx.beginPath(); // Shine effect | |
| ctx.arc(x - radius*0.3, y - radius*0.35, radius*0.3, 0, Math.PI*2); | |
| ctx.fillStyle = 'rgba(255,255,255,0.5)'; | |
| ctx.fill(); | |
| ctx.fillStyle = powerUpType === POWERUP_TYPE.BOMB ? 'white' : 'black'; // Icon color | |
| ctx.textAlign = 'center'; | |
| ctx.textBaseline = 'middle'; | |
| if (powerUpType === POWERUP_TYPE.BOMB) { | |
| ctx.font = `bold ${radius * 0.9}px Arial`; | |
| ctx.fillText('💣', x, y + radius*0.1); | |
| } else if (powerUpType === POWERUP_TYPE.COLOR_SPLASH) { | |
| ctx.font = `bold ${radius * 0.8}px Arial`; | |
| ctx.fillStyle = 'white'; | |
| ctx.fillText('💦', x, y + radius*0.1); | |
| } | |
| ctx.strokeStyle = 'rgba(0,0,0,0.1)'; | |
| ctx.lineWidth = 1; | |
| ctx.stroke(); | |
| } | |
| function lightenColor(hex, percent) { /* ... (Standard lightenColor utility) ... */ | |
| hex = hex.replace(/^\s*#|\s*$/g, ''); | |
| if (hex.length === 3) hex = hex.replace(/(.)/g, '$1$1'); | |
| let r = parseInt(hex.substr(0, 2), 16), g = parseInt(hex.substr(2, 2), 16), b = parseInt(hex.substr(4, 2), 16); | |
| const p = percent / 100; | |
| r = Math.min(255, Math.floor(r * (1 + p))); g = Math.min(255, Math.floor(g * (1 + p))); b = Math.min(255, Math.floor(b * (1 + p))); | |
| return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; | |
| } | |
| function darkenColor(hex, percent) { /* ... (Standard darkenColor utility) ... */ | |
| hex = hex.replace(/^\s*#|\s*$/g, ''); | |
| if (hex.length === 3) hex = hex.replace(/(.)/g, '$1$1'); | |
| let r = parseInt(hex.substr(0, 2), 16), g = parseInt(hex.substr(2, 2), 16), b = parseInt(hex.substr(4, 2), 16); | |
| const p = percent / 100; | |
| r = Math.max(0, Math.floor(r * (1 - p))); g = Math.max(0, Math.floor(g * (1 - p))); b = Math.max(0, Math.floor(b * (1 - p))); | |
| return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; | |
| } | |
| function drawTrajectory() { /* ... (Enhanced drawTrajectory from previous complete code, adapted) ... */ | |
| if (!shooter.currentBubble || movingBubble || !isAiming) return; // Only draw if aiming | |
| ctx.beginPath(); | |
| ctx.setLineDash([BUBBLE_RADIUS/2.5, BUBBLE_RADIUS/2.5]); | |
| ctx.strokeStyle = 'rgba(255, 255, 255, 0.45)'; | |
| ctx.lineWidth = 2.5; | |
| let currentX = shooter.x; | |
| let currentY = shooter.y; | |
| let currentAngle = shooter.angle; | |
| let bounces = 0; | |
| const step = 6; | |
| ctx.moveTo(currentX, currentY); | |
| for (let i = 0; i < 150; i++) { | |
| currentX += Math.cos(currentAngle) * step; | |
| currentY += Math.sin(currentAngle) * step; | |
| if (currentX <= BUBBLE_RADIUS || currentX >= canvas.width - BUBBLE_RADIUS) { | |
| if (bounces < MAX_TRAJECTORY_BOUNCES) { | |
| currentAngle = Math.PI - currentAngle; | |
| currentX = (currentX <= BUBBLE_RADIUS) ? BUBBLE_RADIUS + 1 : canvas.width - BUBBLE_RADIUS - 1; | |
| bounces++; | |
| ctx.lineTo(currentX, currentY); | |
| ctx.stroke(); | |
| ctx.beginPath(); | |
| ctx.moveTo(currentX, currentY); | |
| } else { | |
| ctx.lineTo(currentX, currentY); break; | |
| } | |
| } | |
| if (currentY <= BUBBLE_RADIUS) { currentY = BUBBLE_RADIUS; ctx.lineTo(currentX, currentY); break; } | |
| let hitGridBubble = false; | |
| // Simplified grid check for trajectory line only | |
| const checkRow = Math.max(0, Math.floor( (currentY - getBubbleY(0)) / (BUBBLE_RADIUS*2*0.8660254) )); | |
| if (bubbleGrid[checkRow]) { | |
| for (let c_check = 0; c_check < getColsInRow(checkRow); c_check++) { | |
| const cell = bubbleGrid[checkRow][c_check]; | |
| if (cell) { | |
| if (Math.hypot(currentX - cell.x, currentY - cell.y) < BUBBLE_DIAMETER * 0.8) { hitGridBubble = true; break; } | |
| } | |
| } | |
| } | |
| if (hitGridBubble) { ctx.lineTo(currentX, currentY); break; } | |
| if (i % 2 === 0) ctx.lineTo(currentX, currentY); else ctx.moveTo(currentX, currentY); | |
| } | |
| ctx.stroke(); | |
| ctx.beginPath(); ctx.arc(currentX, currentY, BUBBLE_RADIUS * 0.35, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; ctx.fill(); | |
| ctx.setLineDash([]); | |
| } | |
| function drawGrid() { for (let r = 0; r < ROWS; r++) { if (!bubbleGrid[r]) continue; for (let c = 0; c < getColsInRow(r); c++) { if (bubbleGrid[r][c]) drawBubble({ ...bubbleGrid[r][c], radius: BUBBLE_RADIUS }); } } } | |
| function drawShooter() { /* ... (drawShooter from previous complete code, adapted) ... */ | |
| // Shooter Base | |
| ctx.fillStyle = '#556270'; // Darker, metallic base | |
| ctx.beginPath(); | |
| ctx.arc(shooter.x, shooter.y, BUBBLE_RADIUS * 1.25, Math.PI, 0); | |
| ctx.closePath(); | |
| ctx.fill(); | |
| // Shooter Cannon (more distinct) | |
| ctx.save(); | |
| ctx.translate(shooter.x, shooter.y); | |
| ctx.rotate(shooter.angle + Math.PI / 2); // Align with aimAngle | |
| const barrelWidth = BUBBLE_RADIUS * 0.7; | |
| const barrelLength = BUBBLE_RADIUS * 1.3; | |
| ctx.fillStyle = '#414A52'; | |
| ctx.fillRect(-barrelWidth / 2, -barrelLength, barrelWidth, barrelLength); | |
| // Highlight | |
| ctx.fillStyle = '#788796'; | |
| ctx.fillRect(-barrelWidth/2 + 1, -barrelLength + 1, barrelWidth - 2, 3); | |
| ctx.restore(); | |
| drawTrajectory(); // Now includes aiming line | |
| if (shooter.currentBubble) drawBubble({ ...shooter.currentBubble, x: shooter.x, y: shooter.y }); | |
| if (shooter.nextBubble) { | |
| drawBubble({ ...shooter.nextBubble, x: shooter.nextBubbleArea.x, y: shooter.nextBubbleArea.y, radius: shooter.nextBubbleArea.radius }); | |
| ctx.fillStyle = 'white'; ctx.font = `${BUBBLE_RADIUS * 0.45}px Arial`; ctx.textAlign = 'center'; | |
| ctx.fillText('Next', shooter.nextBubbleArea.x, shooter.nextBubbleArea.y + shooter.nextBubbleArea.radius + 7); | |
| } | |
| } | |
| function drawMovingBubble() { if (movingBubble) drawBubble(movingBubble); } | |
| function drawFallingBubbles() { /* ... (drawFallingBubbles from previous complete code) ... */ | |
| for (let i = fallingBubbles.length - 1; i >= 0; i--) { | |
| const fb = fallingBubbles[i]; | |
| fb.y += fb.vy; fb.x += fb.vx; fb.vy += 0.25; fb.alpha -= 0.015; | |
| if (fb.alpha <= 0) { fallingBubbles.splice(i, 1); continue; } | |
| ctx.globalAlpha = fb.alpha; drawBubble({ ...fb, radius: BUBBLE_RADIUS }); ctx.globalAlpha = 1; | |
| } | |
| } | |
| function drawParticles() { /* ... (drawParticles from previous complete code) ... */ | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| const p = particles[i]; | |
| p.x += p.vx; p.y += p.vy; p.life--; p.alpha -= 0.02; | |
| if (p.life <= 0 || p.alpha <= 0) { particles.splice(i, 1); continue; } | |
| ctx.globalAlpha = p.alpha; ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1; | |
| } | |
| } | |
| function createParticles(x, y, color, count = 10, speed = 4, sizeRange = [2,5]) { /* ... (createParticles from previous complete code) ... */ | |
| for (let i = 0; i < count; i++) { particles.push({ x, y, color, vx: (Math.random() - 0.5) * speed, vy: (Math.random() - 0.5) * speed - Math.random() * speed*0.5, size: Math.random() * (sizeRange[1] - sizeRange[0]) + sizeRange[0], life: Math.random() * 30 + 30, alpha: 1 }); } | |
| } | |
| // --- Game Logic --- | |
| function shootBubble() { // Renamed from shoot for clarity | |
| if (currentGameState !== GAME_STATE.PLAYING || !shooter.currentBubble || movingBubble) return; | |
| movingBubble = { ...shooter.currentBubble, x: shooter.x, y: shooter.y, vx: Math.cos(shooter.angle) * (BUBBLE_DIAMETER * 0.8), vy: Math.sin(shooter.angle) * (BUBBLE_DIAMETER * 0.8) }; | |
| shooter.currentBubble = shooter.nextBubble; shooter.nextBubble = createShooterBubble(); | |
| shotsFiredSinceLastRow++; updateShotsDisplay(); isAiming = false; | |
| } | |
| 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 *= -1; movingBubble.x = Math.max(BUBBLE_RADIUS + 1, Math.min(movingBubble.x, canvas.width - BUBBLE_RADIUS - 1)); | |
| } | |
| if (movingBubble.y <= BUBBLE_RADIUS) { movingBubble.y = BUBBLE_RADIUS; handleBubbleLanded(); return; } | |
| for (let r = 0; r < ROWS; r++) { | |
| if (!bubbleGrid[r]) continue; | |
| for (let c = 0; c < getColsInRow(r); c++) { | |
| const cell = bubbleGrid[r][c]; | |
| if (cell) { | |
| if (Math.hypot(movingBubble.x - cell.x, movingBubble.y - cell.y) < BUBBLE_DIAMETER * 0.9) { | |
| handleBubbleLanded(); return; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| function handleBubbleLanded() { | |
| if (currentGameState !== GAME_STATE.PLAYING || !movingBubble) return; | |
| const landedBubbleData = { ...movingBubble }; movingBubble = null; | |
| if (landedBubbleData.powerUpType === POWERUP_TYPE.BOMB) { | |
| explodeBomb(landedBubbleData.x, landedBubbleData.y); | |
| } else if (landedBubbleData.powerUpType === POWERUP_TYPE.COLOR_SPLASH) { | |
| activateColorSplash(landedBubbleData.x, landedBubbleData.y); | |
| } else { | |
| const { row, col } = findBestGridSlot(landedBubbleData.x, landedBubbleData.y); | |
| if (row !== -1 && col !== -1 && row < ROWS && col < getColsInRow(row) && bubbleGrid[row] && !bubbleGrid[row][col] ) { | |
| bubbleGrid[row][col] = createGridBubble(col, row, landedBubbleData.color, landedBubbleData.powerUpType); | |
| if (landedBubbleData.powerUpType === POWERUP_TYPE.RAINBOW) activateRainbow(row, col, landedBubbleData); | |
| else checkAndProcessMatches(row, col, landedBubbleData.color); | |
| if (currentGameState === GAME_STATE.PLAYING && bubbleGrid[row]?.[col] && row >= GAME_OVER_ROW_INDEX) { | |
| triggerGameOver(`Bubble landed on game over line.`); return; | |
| } | |
| } else { // Could not place, often means hitting top row weirdly or game over condition | |
| const approxRow = Math.max(0, Math.min(ROWS - 1, Math.round((landedBubbleData.y - getBubbleY(0)) / (BUBBLE_RADIUS * 2 * 0.8660254)))); | |
| if (approxRow <= 0) { // Force attach to row 0 if very high | |
| const { col: topCol } = findBestGridSlot(landedBubbleData.x, getBubbleY(0)); | |
| if (topCol !== -1 && !bubbleGrid[0]?.[topCol]) { | |
| bubbleGrid[0][topCol] = createGridBubble(topCol, 0, landedBubbleData.color, landedBubbleData.powerUpType); | |
| if (landedBubbleData.powerUpType === POWERUP_TYPE.RAINBOW) activateRainbow(0, topCol, landedBubbleData); | |
| else checkAndProcessMatches(0, topCol, landedBubbleData.color); | |
| } else triggerGameOver("Could not place bubble at top."); | |
| } else { | |
| triggerGameOver("Could not place bubble."); return; | |
| } | |
| } | |
| } | |
| if (currentGameState !== GAME_STATE.PLAYING) return; | |
| removeFloatingBubbles(); | |
| if (currentGameState !== GAME_STATE.PLAYING) return; | |
| if (!anyBubblesLeftInGrid()) { triggerLevelCleared(); return; } | |
| if (shotsFiredSinceLastRow >= SHOTS_UNTIL_NEW_ROW_THRESHOLD) { addNewRow(); shotsFiredSinceLastRow = 0; } | |
| else checkIfGameOver(); // Check after potential row add too | |
| updateShotsDisplay(); | |
| } | |
| function findBestGridSlot(x, y) { /* ... (Refined findBestGridSlot from previous complete code) ... */ | |
| let bestRow = -1, bestCol = -1; let minDist = Infinity; | |
| for (let r = 0; r < ROWS; r++) { if (!bubbleGrid[r]) continue; for (let c = 0; c < getColsInRow(r); c++) { | |
| const slotX = getBubbleX(c, r); const slotY = getBubbleY(r); | |
| const dist = Math.hypot(x - slotX, y - slotY); | |
| if (dist < minDist && dist < BUBBLE_DIAMETER * 1.5) { // Increased tolerance | |
| if (!bubbleGrid[r][c]) { minDist = dist; bestRow = r; bestCol = c; } | |
| }}} | |
| // Fallback if no empty slot is found directly below/near | |
| if (bestRow === -1 && y < getBubbleY(ROWS -1) + BUBBLE_DIAMETER) { // Only if still in grid height | |
| let r_approx = Math.max(0, Math.min(ROWS - 1, Math.round((y - getBubbleY(0)) / (BUBBLE_RADIUS * 2 * 0.8660254)))); | |
| if (bubbleGrid[r_approx]) { | |
| let c_approx = Math.round((x - (r_approx % 2 === 1 ? BUBBLE_RADIUS : 0) - BUBBLE_RADIUS) / BUBBLE_DIAMETER); | |
| c_approx = Math.max(0, Math.min(getColsInRow(r_approx) - 1, c_approx)); | |
| if (!bubbleGrid[r_approx][c_approx]) { bestRow = r_approx; bestCol = c_approx;} | |
| else { // Try adjacent slots if preferred is taken | |
| const offsets = [0, -1, 1, -2, 2]; // Check center, then 1 away, then 2 away | |
| for(const offset of offsets){ | |
| const check_c = c_approx + offset; | |
| if(check_c >= 0 && check_c < getColsInRow(r_approx) && !bubbleGrid[r_approx][check_c]){ | |
| bestRow = r_approx; bestCol = check_c; break; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return { row: bestRow, col: bestCol }; | |
| } | |
| function checkAndProcessMatches(startRow, startCol, color) { // Consolidated match checking | |
| if (currentGameState !== GAME_STATE.PLAYING) return false; | |
| if (!bubbleGrid[startRow]?.[startCol] || bubbleGrid[startRow][startCol].powerUpType !== POWERUP_TYPE.NONE) return false; | |
| const matchedBubbles = findMatches(startRow, startCol, color); | |
| if (matchedBubbles.length >= 3) { | |
| matchedBubbles.forEach(b => { | |
| if (bubbleGrid[b.r]?.[b.c]) { | |
| addFallingBubble(bubbleGrid[b.r][b.c]); | |
| createParticles(bubbleGrid[b.r][b.c].x, bubbleGrid[b.r][b.c].y, bubbleGrid[b.r][b.c].color, 15); | |
| bubbleGrid[b.r][b.c] = null; | |
| score += BUBBLE_POP_SCORE; | |
| } | |
| }); | |
| updateScoreDisplay(); | |
| return true; | |
| } | |
| return false; | |
| } | |
| function findMatches(startR, startC, targetColor) { // Core BFS for matching | |
| const q = [{r: startR, c: startC}]; | |
| const visited = new Set([`${startR},${startC}`]); | |
| const matched = []; | |
| while(q.length > 0) { | |
| const curr = q.shift(); | |
| const cell = bubbleGrid[curr.r]?.[curr.c]; | |
| if (cell && cell.powerUpType === POWERUP_TYPE.NONE && cell.color === targetColor) { | |
| matched.push(curr); | |
| getNeighbors(curr.r, curr.c).forEach(n => { | |
| if (!visited.has(`${n.r},${n.c}`)) { | |
| visited.add(`${n.r},${n.c}`); | |
| q.push(n); | |
| } | |
| }); | |
| } | |
| } | |
| return matched; | |
| } | |
| function activateRainbow(row, col, landedRainbowData) { /* ... (Refined Rainbow from previous full code, adapted) ... */ | |
| if (currentGameState !== GAME_STATE.PLAYING || !bubbleGrid[row]?.[col]) return; | |
| const rainbowBubbleOriginal = bubbleGrid[row][col]; // This is the landed rainbow bubble in the grid | |
| addFallingBubble(rainbowBubbleOriginal); createParticles(rainbowBubbleOriginal.x, rainbowBubbleOriginal.y, 'white', 30, 7); bubbleGrid[row][col] = null; | |
| score += POWERUP_BONUS_SCORE; | |
| // Rainbow effect: clear all bubbles of one color OR clear a circular area if that's preferred | |
| // For this version, let's make it clear all bubbles of a specific color. | |
| // Determine the target color: If shooter.nextBubble is a normal color, use that. Otherwise pick a common grid color. | |
| let targetColor = null; | |
| if (shooter.nextBubble && shooter.nextBubble.powerUpType === POWERUP_TYPE.NONE) targetColor = shooter.nextBubble.color; | |
| else { | |
| const colorCounts = {}; let maxCount = 0; | |
| for (let r_scan = 0; r_scan < ROWS; r_scan++) { if (!bubbleGrid[r_scan]) continue; for (let c_scan = 0; c_scan < getColsInRow(r_scan); c_scan++) { | |
| const cell = bubbleGrid[r_scan][c_scan]; if (cell && cell.powerUpType === POWERUP_TYPE.NONE) { const clr = cell.color; colorCounts[clr] = (colorCounts[clr] || 0) + 1; if (colorCounts[clr] > maxCount) { maxCount = colorCounts[clr]; targetColor = clr;}}}} | |
| if (!targetColor) targetColor = BUBBLE_COLORS[0]; | |
| } | |
| let clearedCount = 0; | |
| for (let r_scan = 0; r_scan < ROWS; r_scan++) { if (!bubbleGrid[r_scan]) continue; for (let c_scan = 0; c_scan < getColsInRow(r_scan); c_scan++) { | |
| const cell = bubbleGrid[r_scan][c_scan]; if (cell && cell.powerUpType === POWERUP_TYPE.NONE && cell.color === targetColor) { addFallingBubble(cell); createParticles(cell.x, cell.y, cell.color, 10); bubbleGrid[r_scan][c_scan] = null; score += BUBBLE_POP_SCORE; clearedCount++; }}} | |
| if (clearedCount > 0) updateScoreDisplay(); | |
| } | |
| function activateColorSplash(landedX, landedY) { /* ... (Refined Color Splash from previous, adapted) ... */ | |
| if (currentGameState !== GAME_STATE.PLAYING) return; | |
| createParticles(landedX, landedY, '#DDA0DD', 40, 7); score += POWERUP_BONUS_SCORE; | |
| const currentBoardColors = new Set(); | |
| for (let r = 0; r < ROWS; r++) { if (!bubbleGrid[r]) continue; for (let c = 0; c < getColsInRow(r); c++) { if (bubbleGrid[r][c] && bubbleGrid[r][c].powerUpType === POWERUP_TYPE.NONE) currentBoardColors.add(bubbleGrid[r][c].color);}} | |
| const colorsArray = Array.from(currentBoardColors); if (colorsArray.length < 2) { updateScoreDisplay(); return; } | |
| const colorToRemove = colorsArray[Math.floor(Math.random() * colorsArray.length)]; let colorToBecome; | |
| do { colorToBecome = colorsArray[Math.floor(Math.random() * colorsArray.length)]; } while (colorToBecome === colorToRemove && colorsArray.length > 1); | |
| let changedBubblesCoords = []; | |
| for (let r = 0; r < ROWS; r++) { if (!bubbleGrid[r]) continue; for (let c = 0; c < getColsInRow(r); c++) { if (bubbleGrid[r][c] && bubbleGrid[r][c].powerUpType === POWERUP_TYPE.NONE && bubbleGrid[r][c].color === colorToRemove) { bubbleGrid[r][c].color = colorToBecome; createParticles(bubbleGrid[r][c].x, bubbleGrid[r][c].y, colorToBecome, 3); changedBubblesCoords.push({r,c}); }}} | |
| if (changedBubblesCoords.length > 0) { setTimeout(() => { let anyMatches = false; changedBubblesCoords.forEach(coord => { if (bubbleGrid[coord.r]?.[coord.c]) if(checkAndProcessMatches(coord.r, coord.c, colorToBecome)) anyMatches = true; }); if (anyMatches) removeFloatingBubbles(); }, 50);} | |
| updateScoreDisplay(); | |
| } | |
| function explodeBomb(x, y) { /* ... (Bomb explosion from previous, adapted) ... */ | |
| if (currentGameState !== GAME_STATE.PLAYING) return; createParticles(x, y, '#FFA500', 60, 8); score += POWERUP_BONUS_SCORE; | |
| let clearedCount = 0; | |
| for (let r = 0; r < ROWS; r++) { if (!bubbleGrid[r]) continue; for (let c = 0; c < getColsInRow(r); c++) { if (bubbleGrid[r][c]) { | |
| if (Math.hypot(x - bubbleGrid[r][c].x, y - bubbleGrid[r][c].y) < BUBBLE_RADIUS * BOMB_RADIUS_MULTIPLIER) { | |
| addFallingBubble(bubbleGrid[r][c]); createParticles(bubbleGrid[r][c].x, bubbleGrid[r][c].y, bubbleGrid[r][c].color || '#888', 8); bubbleGrid[r][c] = null; score += BUBBLE_POP_SCORE / 2; clearedCount++; // Less points for bomb collateral | |
| }}}} | |
| if (clearedCount > 0) updateScoreDisplay(); | |
| } | |
| function getNeighbors(r, c) { /* ... (getNeighbors from previous complete code, adapted for current grid) ... */ | |
| const neighbors = []; const isOddRow = r % 2 === 1; | |
| const deltas = [ { dr: -1, dc: isOddRow ? 0 : -1 }, { dr: -1, dc: isOddRow ? 1 : 0 }, { dr: 0, dc: -1 }, { dr: 0, dc: 1 }, { dr: 1, dc: isOddRow ? 0 : -1 }, { dr: 1, dc: isOddRow ? 1 : 0 }]; | |
| deltas.forEach(d => { const nr = r + d.dr; const nc = c + d.dc; if (nr >= 0 && nr < ROWS && nc >= 0 && nc < getColsInRow(nr) && bubbleGrid[nr]?.[nc]) neighbors.push({ r: nr, c: nc }); }); // Added check for bubbleGrid[nr][nc] | |
| return neighbors; | |
| } | |
| function addFallingBubble(gridBubble) { if (!gridBubble) return; fallingBubbles.push({ ...gridBubble, x: gridBubble.x, y: gridBubble.y, vx: (Math.random() - 0.5) * 3, vy: -Math.random() * 2 - 2, alpha: 1, radius: BUBBLE_RADIUS }); } | |
| function removeFloatingBubbles() { /* ... (removeFloatingBubbles from previous complete code, adapted) ... */ | |
| if (currentGameState !== GAME_STATE.PLAYING) return; | |
| const connected = new Set(); | |
| for (let c = 0; c < getColsInRow(0); c++) if (bubbleGrid[0]?.[c]) markConnected(0, c, connected); | |
| let floatingCleared = 0; | |
| for (let r = 0; r < ROWS; r++) { if (!bubbleGrid[r]) continue; for (let c = 0; c < getColsInRow(r); c++) { | |
| if (bubbleGrid[r][c] && !connected.has(bubbleGrid[r][c].id)) { // Use ID for Set | |
| addFallingBubble(bubbleGrid[r][c]); createParticles(bubbleGrid[r][c].x, bubbleGrid[r][c].y, bubbleGrid[r][c].color, 5); bubbleGrid[r][c] = null; score += FLOATING_BUBBLE_SCORE; floatingCleared++; | |
| }}} | |
| if (floatingCleared > 0) updateScoreDisplay(); | |
| } | |
| function markConnected(r, c, connectedSet) { /* ... (markConnected from previous, adapted) ... */ | |
| const cell = bubbleGrid[r]?.[c]; if (!cell || connectedSet.has(cell.id)) return; | |
| connectedSet.add(cell.id); | |
| getNeighbors(r, c).forEach(n => markConnected(n.r, n.c, connectedSet)); | |
| } | |
| function addNewRow() { /* ... (addNewRow from previous, adapted) ... */ | |
| if (currentGameState !== GAME_STATE.PLAYING) return; | |
| // Check if top row of possible game over area is occupied BEFORE shifting | |
| if (bubbleGrid[GAME_OVER_ROW_INDEX -1]) { | |
| for (let c_check = 0; c_check < getColsInRow(GAME_OVER_ROW_INDEX - 1); c_check++) { | |
| if (bubbleGrid[GAME_OVER_ROW_INDEX - 1][c_check]) { | |
| shiftRowsDown(); triggerGameOver("New row pushed bubbles to game over."); return; | |
| } | |
| } | |
| } | |
| shiftRowsDown(); | |
| for (let c = 0; c < getColsInRow(0); c++) if (Math.random() < 0.7) bubbleGrid[0][c] = createGridBubble(c, 0, null, Math.random() < POWERUP_CHANCE_GRID * 0.5 ? getRandomPowerUpType() : POWERUP_TYPE.NONE); // Lower chance for powerups in new rows | |
| checkIfGameOver(); | |
| } | |
| function shiftRowsDown() { /* ... (shiftRowsDown from previous, adapted) ... */ | |
| for (let r = ROWS - 1; r > 0; r--) { | |
| bubbleGrid[r] = bubbleGrid[r-1]?.map(cell => cell ? {...cell, row:r, y:getBubbleY(r), x:getBubbleX(cell.col,r)} : null) || new Array(getColsInRow(r)).fill(null); | |
| } | |
| bubbleGrid[0] = new Array(getColsInRow(0)).fill(null); | |
| } | |
| function anyBubblesLeftInGrid() { for (let r=0; r<ROWS; r++) { if(bubbleGrid[r]?.some(cell => cell !== null)) return true; } return false;} | |
| function checkIfGameOver() { /* ... (checkIfGameOver from previous, adapted) ... */ | |
| if (currentGameState !== GAME_STATE.PLAYING) return; | |
| if (GAME_OVER_ROW_INDEX < 0 || GAME_OVER_ROW_INDEX >= ROWS || !bubbleGrid[GAME_OVER_ROW_INDEX]) return; | |
| for (let c = 0; c < getColsInRow(GAME_OVER_ROW_INDEX); c++) if (bubbleGrid[GAME_OVER_ROW_INDEX][c]) { triggerGameOver(); return; } | |
| } | |
| function updateScoreDisplay() { scoreElement.textContent = score; } | |
| function updateShotsDisplay() { shotsUntilNextRowElement.textContent = Math.max(0, SHOTS_UNTIL_NEW_ROW_THRESHOLD - shotsFiredSinceLastRow); } | |
| function triggerGameOver(message = "Game Over!") { if (currentGameState === GAME_STATE.GAME_OVER || currentGameState === GAME_STATE.LEVEL_CLEARED) return; currentGameState = GAME_STATE.GAME_OVER; overlayTitle.textContent = message; overlayScore.textContent = `Final Score: ${score}`; overlayScreen.style.display = 'flex';} | |
| function triggerLevelCleared() { if (currentGameState === GAME_STATE.LEVEL_CLEARED || currentGameState === GAME_STATE.GAME_OVER) return; currentGameState = GAME_STATE.LEVEL_CLEARED; overlayTitle.textContent = "Level Cleared!"; overlayScore.textContent = `Score: ${score}`; overlayScreen.style.display = 'flex'; /* Later, this will go to perk selection or next level */ } | |
| // --- Event Handlers & Input --- | |
| function handleMouseDown(e) { /* ... (handleMouseDown from previous, adapted for shooter.nextBubbleArea) ... */ | |
| if (currentGameState !== GAME_STATE.PLAYING || movingBubble) return; | |
| e.preventDefault(); const pos = getMousePos(e); | |
| // Check for tap on next bubble area for swap | |
| if (Math.hypot(pos.x - shooter.nextBubbleArea.x, pos.y - shooter.nextBubbleArea.y) < shooter.nextBubbleArea.radius * 1.5) { | |
| if (shooter.currentBubble && shooter.nextBubble) [shooter.currentBubble, shooter.nextBubble] = [shooter.nextBubble, shooter.currentBubble]; | |
| return; | |
| } | |
| isAiming = true; updateAimAngle(pos.x, pos.y); | |
| } | |
| function handleMouseMove(e) { if (currentGameState !== GAME_STATE.PLAYING || !isAiming || movingBubble) return; e.preventDefault(); const pos = getMousePos(e); updateAimAngle(pos.x, pos.y); } | |
| function handleMouseUp(e) { if (currentGameState !== GAME_STATE.PLAYING || !isAiming || movingBubble) { isAiming = false; return; } e.preventDefault(); shootBubble(); isAiming = false; } | |
| function getMousePos(evt) { /* ... (Standard getMousePos) ... */ | |
| const rect = canvas.getBoundingClientRect(); let clientX, clientY; | |
| if (evt.touches && evt.touches.length > 0) { clientX = evt.touches[0].clientX; clientY = evt.touches[0].clientY; } | |
| else { clientX = evt.clientX; clientY = evt.clientY; } | |
| return { x: clientX - rect.left, y: clientY - rect.top }; | |
| } | |
| function updateAimAngle(mouseX, mouseY) { /* ... (Standard updateAimAngle) ... */ | |
| let dx = mouseX - shooter.x; let dy = mouseY - shooter.y; shooter.angle = Math.atan2(dy, dx); | |
| const minAngle = -Math.PI * 0.95; const maxAngle = -Math.PI * 0.05; | |
| shooter.angle = Math.max(minAngle, Math.min(shooter.angle, maxAngle)); | |
| } | |
| // --- Main Game Loop --- | |
| function gameLoop(timestamp) { | |
| const deltaTime = (timestamp - lastTimestamp) / 1000; // Delta time in seconds | |
| lastTimestamp = timestamp; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| if (currentGameState === GAME_STATE.MENU) { | |
| // Menu is handled by HTML overlay | |
| } else if (currentGameState === GAME_STATE.PLAYING) { | |
| updateMovingBubble(); // Update moving bubble first | |
| drawGrid(); | |
| drawShooter(); | |
| drawMovingBubble(); // Draw it after grid/shooter | |
| drawFallingBubbles(); | |
| drawParticles(); | |
| } else if (currentGameState === GAME_STATE.GAME_OVER || currentGameState === GAME_STATE.LEVEL_CLEARED) { | |
| // Show final state of grid, allow particles/falling to finish | |
| drawGrid(); | |
| drawShooter(); | |
| drawFallingBubbles(); | |
| drawParticles(); | |
| } | |
| requestAnimationFrame(gameLoop); | |
| } | |
| // --- Event Listeners --- | |
| startButton.addEventListener('click', () => { setupCanvasAndGameConfig(); initGame(); }); | |
| restartButton.addEventListener('click', () => { setupCanvasAndGameConfig(); initGame(); }); | |
| canvas.addEventListener('mousedown', handleMouseDown); | |
| canvas.addEventListener('mousemove', handleMouseMove); | |
| canvas.addEventListener('mouseup', handleMouseUp); | |
| canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // Prevent right-click menu | |
| canvas.addEventListener('touchstart', handleMouseDown, { passive: false }); | |
| canvas.addEventListener('touchmove', handleMouseMove, { passive: false }); | |
| canvas.addEventListener('touchend', handleMouseUp, { passive: false }); | |
| window.addEventListener('resize', () => { // Handle resize for responsiveness | |
| if (currentGameState === GAME_STATE.MENU || !canvas.width) { // Only resize if in menu or not yet fully initialized | |
| setupCanvasAndGameConfig(); | |
| if(currentGameState !== GAME_STATE.MENU) initGame(); // Re-init if was playing, to adjust grid to new size | |
| } | |
| // For active game, resizing can be complex. A full re-init is simplest. | |
| // More advanced would be to try and rescale/reposition existing bubbles. | |
| }); | |
| // --- Initial Call --- | |
| setupCanvasAndGameConfig(); // Setup canvas dimensions initially for menu screen | |
| startScreen.style.display = 'flex'; // Show start screen first | |
| gameLoop(0); // Start the game loop | |
| </script> | |
| </body> | |
| </html> |