bubbleshooter1 / index.html
offerpk3's picture
Update index.html
6cbc06b verified
<!DOCTYPE html>
<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>