bubbleshooter5 / index.html
offerpk3's picture
Update index.html
ccb585f 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</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;
overflow: hidden;
touch-action: none;
}
.game-wrapper {
position: relative;
}
.game-container {
background: rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 15px;
backdrop-filter: blur(10px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
text-align: center;
width: fit-content;
}
canvas {
border: 2px solid #fff;
border-radius: 10px;
background: #000022;
display: block;
margin: 0 auto;
}
.ui {
color: white;
margin-bottom: 10px;
display: flex;
justify-content: space-around;
align-items: center;
font-size: 16px;
}
.score, .shots-info {
font-weight: bold;
text-shadow: 1px 1px 3px rgba(0,0,0,0.5);
}
.overlay-message {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.75);
color: white;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 24px;
font-weight: bold;
text-align: center;
z-index: 100;
border-radius: 10px;
}
.overlay-message h2 {
margin-bottom: 20px;
font-size: 1.5em;
}
.controls-info {
margin-top: 10px;
color: rgba(255, 255, 255, 0.85);
font-size: 12px;
}
button.game-button {
background-color: #ff6b6b;
color: white;
border: none;
padding: 12px 25px;
border-radius: 8px;
font-size: 18px;
font-weight: bold;
cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
transition: background-color 0.3s ease, transform 0.1s ease;
margin: 10px;
}
button.game-button:hover {
background-color: #ff4757;
}
button.game-button:active {
transform: scale(0.95);
}
.next-bubble-display {
cursor: pointer;
padding: 5px;
border-radius: 5px;
transition: background-color 0.2s;
}
.next-bubble-display:hover {
background-color: rgba(255,255,255,0.1);
}
</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">
Aim & Shoot: Drag from shooter base / Click<br>
Connect 3+ same colors. Tap "Next" 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">Restart</button>
<button id="menuButton" class="game-button">Main Menu</button>
</div>
<div id="startScreen" class="overlay-message">
<h2>Bubble Shooter Pro</h2>
<p>Ready to pop some bubbles?</p>
<button id="startButton" class="game-button">Start Game</button>
</div>
</div>
<script>
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 menuButton = document.getElementById('menuButton');
const startButton = document.getElementById('startButton');
const GAME_STATE = {
MENU: 'MENU',
PLAYING: 'PLAYING',
GAME_OVER: 'GAME_OVER',
YOU_WIN: 'YOU_WIN'
};
let currentGameState = GAME_STATE.MENU;
let BUBBLE_RADIUS = 0;
let BUBBLE_DIAMETER = 0;
let ROWS = 0;
let COLS = 12;
let GAME_OVER_ROW_INDEX = 0;
const INIT_ROWS_COUNT_TARGET = 5;
const SHOTS_UNTIL_NEW_ROW_THRESHOLD = 6;
const MAX_TRAJECTORY_BOUNCES = 2; // For aiming line
const BUBBLE_COLORS = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#feca57', '#ff9ff3', '#54a0ff'];
const POWERUP_TYPE = {
NONE: 'NONE',
BOMB: 'BOMB',
RAINBOW: 'RAINBOW',
COLOR_SPLASH: 'COLOR_SPLASH' // New power-up
};
const BOMB_RADIUS_MULTIPLIER = 3.0; // Slightly larger bomb effect
const POWERUP_CHANCE = 0.12; // Overall chance for any power-up
const COLOR_SPLASH_CHANCE_OF_POWERUP = 0.25; // If powerup, 25% chance it's Color Splash
let score = 0;
let shotsFiredSinceLastRow = 0;
const bubbleGrid = [];
const shooter = { x: 0, y: 0, angle: -Math.PI / 2, currentBubble: null, nextBubble: null };
let movingBubble = null;
let fallingBubbles = [];
let particles = [];
let isAiming = false;
function setupCanvas() {
// console.log("--- Running setupCanvas ---");
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
const maxCanvasWidth = Math.min(screenWidth * 0.95, 500);
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.866;
const availableGridHeight = Math.min(screenHeight * 0.70, canvas.width * 1.3);
ROWS = Math.floor(availableGridHeight / bubbleRowHeight);
ROWS = Math.max(ROWS, 10);
canvas.height = Math.floor(ROWS * bubbleRowHeight + BUBBLE_DIAMETER * 3.5);
GAME_OVER_ROW_INDEX = ROWS - 1;
// console.log(`Canvas W: ${canvas.width}, H: ${canvas.height}. COLS: ${COLS}, ROWS: ${ROWS}. GAME_OVER_ROW_INDEX: ${GAME_OVER_ROW_INDEX}`);
shooter.x = canvas.width / 2;
shooter.y = canvas.height - BUBBLE_DIAMETER * 1.8;
shooter.nextBubbleArea = {
x: shooter.x + BUBBLE_DIAMETER * 1.5,
y: shooter.y - BUBBLE_RADIUS,
width: BUBBLE_DIAMETER * 1.2,
height: BUBBLE_DIAMETER * 1.2
};
// console.log("--- setupCanvas Finished ---");
}
function initGame() {
// console.log("--- Running 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.min(INIT_ROWS_COUNT_TARGET, ROWS - 5);
initRowsToFill = Math.max(1, initRowsToFill);
// console.log(`Initializing game grid: Total ROWS=${ROWS}, initRowsToFill=${initRowsToFill}, GAME_OVER_ROW_INDEX=${GAME_OVER_ROW_INDEX}`);
if (initRowsToFill >= GAME_OVER_ROW_INDEX && GAME_OVER_ROW_INDEX > 0 ) {
// console.error(`CRITICAL: initRowsToFill (${initRowsToFill}) is too close to or exceeds GAME_OVER_ROW_INDEX (${GAME_OVER_ROW_INDEX}). Reducing initRowsToFill.`);
initRowsToFill = Math.max(1, GAME_OVER_ROW_INDEX - 2);
}
if (initRowsToFill < 1 && ROWS > 0) initRowsToFill = 1;
else if (ROWS === 0) {
// console.error("CRITICAL: ROWS is 0. Cannot initialize game grid.");
triggerGameOver("Error: Game grid could not be initialized.");
return;
}
for (let r = 0; r < initRowsToFill; r++) {
for (let c = 0; c < getColsInRow(r); c++) {
if (Math.random() < 0.7) {
bubbleGrid[r][c] = createGridBubble(c, r);
}
}
}
shooter.currentBubble = createShooterBubble();
shooter.nextBubble = createShooterBubble();
updateScoreDisplay();
updateShotsDisplay();
// console.log("--- initGame Finished ---");
}
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 * 0.5;
return row * (BUBBLE_RADIUS * 2 * 0.866) + BUBBLE_RADIUS + gridTopMargin;
}
function createGridBubble(col, row, color = null, powerUpType = POWERUP_TYPE.NONE) {
return {
color: color || BUBBLE_COLORS[Math.floor(Math.random() * BUBBLE_COLORS.length)],
x: getBubbleX(col, row),
y: getBubbleY(row),
powerUpType: powerUpType,
row: row,
col: col
};
}
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++) {
if (bubbleGrid[r][c] && bubbleGrid[r][c].powerUpType === POWERUP_TYPE.NONE) {
existingColors.add(bubbleGrid[r][c].color);
normalBubblesExist = true;
}
}
}
let availableColors = Array.from(existingColors);
if (!normalBubblesExist || availableColors.length === 0) { // If only powerups left or grid is empty
availableColors = [...BUBBLE_COLORS]; // Fallback to all colors
}
let powerUp = POWERUP_TYPE.NONE;
let color = availableColors[Math.floor(Math.random() * availableColors.length)];
if (Math.random() < POWERUP_CHANCE) {
const randPowerUpType = Math.random();
if (randPowerUpType < COLOR_SPLASH_CHANCE_OF_POWERUP) {
powerUp = POWERUP_TYPE.COLOR_SPLASH;
color = '#DDA0DD'; // Plum color for Color Splash
} else if (randPowerUpType < COLOR_SPLASH_CHANCE_OF_POWERUP + 0.4) { // Bomb is common
powerUp = POWERUP_TYPE.BOMB;
color = '#333333';
} else { // Rainbow fills the rest
powerUp = POWERUP_TYPE.RAINBOW;
// Rainbow color is dynamic
}
}
return {
color: color,
radius: BUBBLE_RADIUS,
powerUpType: powerUp
};
}
function drawBubble(bubble) {
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);
BUBBLE_COLORS.forEach((c, i) => rainbowGradient.addColorStop(Math.min(0.9, i / BUBBLE_COLORS.length), c)); // Cap stops for better blend
rainbowGradient.addColorStop(1, BUBBLE_COLORS[0]); // Ensure full circle
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); // Its base color (e.g., Plum)
splashGradient.addColorStop(0.6, BUBBLE_COLORS[Math.floor(Date.now()/500) % BUBBLE_COLORS.length]); // Cycling color
splashGradient.addColorStop(1, color);
ctx.fillStyle = splashGradient;
}
else {
ctx.fillStyle = color;
}
ctx.fill();
// Shine effect
const shineGradient = ctx.createRadialGradient(x - radius*0.35, y - radius*0.35, radius*0.1, x - radius*0.1, y - radius*0.1, radius*0.8);
shineGradient.addColorStop(0, 'rgba(255, 255, 255, 0.6)');
shineGradient.addColorStop(1, 'rgba(255, 255, 255, 0)');
ctx.fillStyle = shineGradient;
ctx.fill();
// Icons for power-ups
ctx.fillStyle = 'white';
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.fillText('💦', x, y + radius*0.1); // Splash icon
}
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.lineWidth = 1;
ctx.stroke();
}
function drawTrajectory() {
if (!shooter.currentBubble || movingBubble) return;
ctx.beginPath();
ctx.setLineDash([BUBBLE_RADIUS/3, BUBBLE_RADIUS/3]);
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = 2;
let currentX = shooter.x;
let currentY = shooter.y;
let currentAngle = shooter.angle;
let bounces = 0;
const step = 5; // Small steps for trajectory line
ctx.moveTo(currentX, currentY);
for (let i = 0; i < 200; i++) { // Max trajectory length (number of steps)
currentX += Math.cos(currentAngle) * step;
currentY += Math.sin(currentAngle) * step;
// Wall collision
if (currentX <= BUBBLE_RADIUS || currentX >= canvas.width - BUBBLE_RADIUS) {
if (bounces < MAX_TRAJECTORY_BOUNCES) {
currentAngle = Math.PI - currentAngle; // Reflect angle
// Adjust position slightly to prevent getting stuck in wall visually
currentX = (currentX <= BUBBLE_RADIUS) ? BUBBLE_RADIUS + 1 : canvas.width - BUBBLE_RADIUS - 1;
bounces++;
ctx.lineTo(currentX, currentY); // Draw line to the bounce point
ctx.stroke(); // Stroke the segment before bounce
ctx.beginPath(); // Start new segment after bounce
ctx.moveTo(currentX, currentY);
} else {
// Max bounces reached, stop drawing trajectory
ctx.lineTo(currentX, currentY);
break;
}
}
// Top collision
if (currentY <= BUBBLE_RADIUS) {
currentY = BUBBLE_RADIUS;
ctx.lineTo(currentX, currentY);
break;
}
// Check for collision with existing grid bubbles (simplified for trajectory)
let hitGridBubble = false;
for (let r = 0; r < ROWS; r++) {
if (!bubbleGrid[r]) continue;
for (let c = 0; c < getColsInRow(r); c++) {
if (bubbleGrid[r][c]) {
const dist = Math.hypot(currentX - bubbleGrid[r][c].x, currentY - bubbleGrid[r][c].y);
if (dist < BUBBLE_DIAMETER * 0.9) { // Collision with existing bubble
hitGridBubble = true;
break;
}
}
}
if (hitGridBubble) break;
}
if (hitGridBubble) {
ctx.lineTo(currentX, currentY);
break;
}
if (i % 2 === 0) { // Draw line in segments for dashed effect if setLineDash isn't perfect
ctx.lineTo(currentX, currentY);
} else {
ctx.moveTo(currentX, currentY);
}
}
ctx.stroke(); // Stroke the final segment
// Draw a small circle at the predicted landing spot (end of trajectory)
ctx.beginPath();
ctx.arc(currentX, currentY, BUBBLE_RADIUS * 0.3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
ctx.fill();
ctx.setLineDash([]); // Reset line dash
}
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, x: getBubbleX(c,r), y: getBubbleY(r) });
}
}
}
}
function drawShooter() {
// Shooter Base
ctx.fillStyle = '#444';
ctx.beginPath();
ctx.arc(shooter.x, shooter.y, BUBBLE_RADIUS * 1.2, Math.PI, 0);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = '#222';
ctx.lineWidth = 2;
ctx.stroke();
// Draw enhanced trajectory line
drawTrajectory();
if (shooter.currentBubble) {
drawBubble({ ...shooter.currentBubble, x: shooter.x, y: shooter.y });
}
if (shooter.nextBubble) {
const nextBubbleX = shooter.nextBubbleArea.x + shooter.nextBubbleArea.width / 2;
const nextBubbleY = shooter.nextBubbleArea.y + shooter.nextBubbleArea.height / 2;
drawBubble({ ...shooter.nextBubble, x: nextBubbleX, y: nextBubbleY, radius: BUBBLE_RADIUS * 0.75 });
ctx.fillStyle = 'white';
ctx.font = `${BUBBLE_RADIUS * 0.5}px Arial`;
ctx.textAlign = 'center';
ctx.fillText('Next', nextBubbleX, nextBubbleY + BUBBLE_RADIUS * 0.75 + 8);
}
}
function drawMovingBubble() {
if (movingBubble) {
drawBubble(movingBubble);
}
}
function drawFallingBubbles() {
for (let i = fallingBubbles.length - 1; i >= 0; i--) {
const fb = fallingBubbles[i];
fb.y += fb.vy;
fb.x += fb.vx;
fb.vy += 0.2;
fb.alpha -= 0.01;
if (fb.alpha <= 0) {
fallingBubbles.splice(i, 1);
continue;
}
ctx.globalAlpha = fb.alpha;
drawBubble({ ...fb, radius: BUBBLE_RADIUS });
ctx.globalAlpha = 1;
}
}
function drawParticles() {
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]) {
for (let i = 0; i < count; i++) {
particles.push({
x, y, color,
vx: (Math.random() - 0.5) * speed,
vy: (Math.random() - 0.5) * speed,
size: Math.random() * (sizeRange[1] - sizeRange[0]) + sizeRange[0],
life: Math.random() * 30 + 30, // Duration in frames
alpha: 1
});
}
}
function shoot() {
// console.log("--- shoot() called ---");
if (currentGameState !== GAME_STATE.PLAYING || !shooter.currentBubble || movingBubble) {
// console.log(`shoot() aborted: gameState=${currentGameState}, currentBubble=${!!shooter.currentBubble}, movingBubble=${!!movingBubble}`);
return;
}
movingBubble = {
...shooter.currentBubble,
x: shooter.x,
y: shooter.y,
vx: Math.cos(shooter.angle) * (BUBBLE_DIAMETER * 0.7), // Slightly faster speed
vy: Math.sin(shooter.angle) * (BUBBLE_DIAMETER * 0.7)
};
// console.log("Moving bubble created:", movingBubble);
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, Math.min(movingBubble.x, canvas.width - BUBBLE_RADIUS));
}
if (movingBubble.y <= BUBBLE_RADIUS) {
// console.log("Moving bubble hit top of screen.");
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++) {
if (bubbleGrid[r][c]) {
const bubble = bubbleGrid[r][c];
const dist = Math.hypot(movingBubble.x - bubble.x, movingBubble.y - bubble.y);
if (dist < BUBBLE_DIAMETER * 0.95) {
// console.log(`Moving bubble collided with grid bubble at [${r}][${c}]`);
handleBubbleLanded();
return;
}
}
}
}
}
function handleBubbleLanded() {
// console.log("--- handleBubbleLanded() ---");
if (currentGameState !== GAME_STATE.PLAYING) {
if (movingBubble) movingBubble = null;
return;
}
if(!movingBubble) return;
const landedBubbleData = { ...movingBubble };
movingBubble = null;
// console.log(`Landed bubble data: x=${landedBubbleData.x.toFixed(1)}, y=${landedBubbleData.y.toFixed(1)}, type=${landedBubbleData.powerUpType}, color=${landedBubbleData.color}`);
// console.log(`Current GAME_OVER_ROW_INDEX for checks: ${GAME_OVER_ROW_INDEX}`);
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 { // Normal or Rainbow
const { row, col } = findBestGridSlot(landedBubbleData.x, landedBubbleData.y);
// console.log(`findBestGridSlot returned: target row=${row}, col=${col}.`);
if (row !== -1 && col !== -1 && row < ROWS && col < getColsInRow(row) && bubbleGrid[row] && (bubbleGrid[row][col] === null || bubbleGrid[row][col] === undefined) ) {
bubbleGrid[row][col] = createGridBubble(col, row, landedBubbleData.color, landedBubbleData.powerUpType);
// console.log(`Placed bubble at [${row}][${col}]. Color: ${bubbleGrid[row][col].color}, Type: ${bubbleGrid[row][col].powerUpType}`);
if (landedBubbleData.powerUpType === POWERUP_TYPE.RAINBOW) {
activateRainbow(row, col);
} else {
checkMatches(row, col, landedBubbleData.color);
}
if (currentGameState === GAME_STATE.PLAYING && bubbleGrid[row] && bubbleGrid[row][col]) {
// console.log(`Bubble at [${row}][${col}] still exists. Game Over Check: landed row (${row}) >= GAME_OVER_ROW_INDEX (${GAME_OVER_ROW_INDEX})`);
if (row >= GAME_OVER_ROW_INDEX) {
triggerGameOver(`Game Over: Bubble landed at row ${row}, which is >= game over line ${GAME_OVER_ROW_INDEX}.`);
return;
}
}
} else {
// console.warn(`Could not place bubble. Slot [${row}][${col}] invalid or occupied. landedY=${landedBubbleData.y.toFixed(1)}`);
const lowestGridBubbleY = getBubbleY(ROWS -1);
if(landedBubbleData.y > lowestGridBubbleY + BUBBLE_RADIUS * 1.5) {
triggerGameOver("Game Over: Bubble fell off bottom of grid!");
return;
}
}
}
if (currentGameState !== GAME_STATE.PLAYING) return;
removeFloatingBubbles();
if (currentGameState !== GAME_STATE.PLAYING) return;
let bubblesExist = false;
for (let r = 0; r < ROWS; r++) {
if (!bubbleGrid[r]) continue;
for (let c = 0; c < getColsInRow(r); c++) {
if (bubbleGrid[r][c]) {bubblesExist = true; break;}
}
if(bubblesExist) break;
}
if (!bubblesExist) {
triggerYouWin();
return;
}
if (shotsFiredSinceLastRow >= SHOTS_UNTIL_NEW_ROW_THRESHOLD) {
addNewRow();
shotsFiredSinceLastRow = 0;
} else {
checkIfGameOver();
}
updateShotsDisplay();
// console.log("--- handleBubbleLanded() Finished ---");
}
function findBestGridSlot(x, y) {
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 yBias = (ROWS - r) * 0.01 * BUBBLE_RADIUS;
const dist = Math.hypot(x - slotX, y - slotY) - yBias;
if (dist < minDist && dist < BUBBLE_DIAMETER * 1.6) {
if (!bubbleGrid[r][c]) {
minDist = dist;
bestRow = r;
bestCol = c;
}
}
}
}
if (bestRow === -1) {
// console.warn("findBestGridSlot: No ideal empty slot found, using more direct fallback.");
let r_approx = 0;
const firstRowY = getBubbleY(0);
if (y < firstRowY - BUBBLE_RADIUS) {
r_approx = 0;
} else {
r_approx = Math.floor((y - (firstRowY - BUBBLE_RADIUS)) / (BUBBLE_RADIUS * 2 * 0.866));
}
r_approx = Math.max(0, Math.min(ROWS - 1, r_approx));
let c_approx = 0;
if (bubbleGrid[r_approx]) {
const offsetXForRow = (r_approx % 2 === 1) ? BUBBLE_RADIUS : 0;
c_approx = Math.round((x - BUBBLE_RADIUS - offsetXForRow) / BUBBLE_DIAMETER);
c_approx = Math.max(0, Math.min(getColsInRow(r_approx) - 1, c_approx));
} else {
// console.error(`Fallback slot finding: Approximated Row ${r_approx} does not exist in bubbleGrid! THIS IS A BUG.`);
return {row: -1, col: -1};
}
if (bubbleGrid[r_approx] && !bubbleGrid[r_approx][c_approx]) {
bestRow = r_approx;
bestCol = c_approx;
} else {
if (bubbleGrid[r_approx]) {
for (let offset = 1; offset <= Math.max(c_approx, getColsInRow(r_approx) - 1 - c_approx) + 1; offset++) {
const c_left = c_approx - offset;
const c_right = c_approx + offset;
if (c_left >= 0 && c_left < getColsInRow(r_approx) && !bubbleGrid[r_approx][c_left]) {
bestRow = r_approx; bestCol = c_left; break;
}
if (c_right < getColsInRow(r_approx) && c_right >=0 && !bubbleGrid[r_approx][c_right]) {
bestRow = r_approx; bestCol = c_right; break;
}
}
}
}
}
return { row: bestRow, col: bestCol };
}
function checkMatches(startRow, startCol, color) {
if (currentGameState !== GAME_STATE.PLAYING) return false;
if (!bubbleGrid[startRow] || !bubbleGrid[startRow][startCol] || bubbleGrid[startRow][startCol].powerUpType !== POWERUP_TYPE.NONE) {
return false;
}
const toVisit = [{ r: startRow, c: startCol }];
const matched = [];
const visited = new Set();
visited.add(`${startRow},${startCol}`);
while (toVisit.length > 0) {
const current = toVisit.pop();
if (bubbleGrid[current.r] && bubbleGrid[current.r][current.c] &&
bubbleGrid[current.r][current.c].color === color &&
bubbleGrid[current.r][current.c].powerUpType === POWERUP_TYPE.NONE) {
matched.push(current);
const neighbors = getNeighbors(current.r, current.c);
for (const {r: nr, c: nc} of neighbors) {
if (!visited.has(`${nr},${nc}`)) {
visited.add(`${nr},${nc}`);
toVisit.push({ r: nr, c: nc });
}
}
}
}
if (matched.length >= 3) {
// console.log(`Found ${matched.length} matches starting from [${startRow}][${startCol}] of color ${color}`);
matched.forEach(b => {
if (bubbleGrid[b.r] && 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, 5, [2,4]);
bubbleGrid[b.r][b.c] = null;
score += 10;
}
});
updateScoreDisplay();
return true;
}
return false;
}
function activateRainbow(row, col) {
// console.log(`--- activateRainbow at [${row}][${col}] ---`);
if (currentGameState !== GAME_STATE.PLAYING) return;
if (!bubbleGrid[row] || !bubbleGrid[row][col]) return;
const rainbowBubbleOriginal = bubbleGrid[row][col];
addFallingBubble(rainbowBubbleOriginal);
createParticles(rainbowBubbleOriginal.x, rainbowBubbleOriginal.y, 'white', 30, 6, [3,6]);
bubbleGrid[row][col] = null;
score += 20; // More points for rainbow
// Determine target color: use next shooter bubble's color if it's normal
let targetColor = null;
if (shooter.nextBubble && shooter.nextBubble.powerUpType === POWERUP_TYPE.NONE) {
targetColor = shooter.nextBubble.color;
} else { // Fallback: find a common color on the board
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++) {
if (bubbleGrid[r_scan][c_scan] && bubbleGrid[r_scan][c_scan].powerUpType === POWERUP_TYPE.NONE) {
const clr = bubbleGrid[r_scan][c_scan].color;
colorCounts[clr] = (colorCounts[clr] || 0) + 1;
if (colorCounts[clr] > maxCount) {
maxCount = colorCounts[clr];
targetColor = clr;
}
}
}
}
if (!targetColor) targetColor = BUBBLE_COLORS[0]; // Absolute fallback
}
// console.log("Rainbow target color:", targetColor);
const neighbors = getNeighbors(row, col);
let changedCount = 0;
for (const {r: nr, c: nc} of neighbors) {
if (bubbleGrid[nr] && bubbleGrid[nr][nc] && bubbleGrid[nr][nc].powerUpType === POWERUP_TYPE.NONE) {
bubbleGrid[nr][nc].color = targetColor;
createParticles(bubbleGrid[nr][nc].x, bubbleGrid[nr][nc].y, targetColor, 5, 2, [1,3]);
changedCount++;
}
}
// After changing colors, check for new matches around the affected area
if (changedCount > 0) {
// Check matches for each neighbor that was changed
setTimeout(() => { // Slight delay to let color change render
neighbors.forEach(({r: nr, c: nc}) => {
if (bubbleGrid[nr] && bubbleGrid[nr][nc] && bubbleGrid[nr][nc].color === targetColor) {
checkMatches(nr, nc, targetColor);
}
});
removeFloatingBubbles(); // Important after potential matches
}, 50);
}
updateScoreDisplay();
// console.log("--- activateRainbow finished ---");
}
function activateColorSplash(landedX, landedY) {
// console.log(`--- activateColorSplash at x=${landedX.toFixed(1)}, y=${landedY.toFixed(1)} ---`);
if (currentGameState !== GAME_STATE.PLAYING) return;
createParticles(landedX, landedY, '#DDA0DD', 40, 7, [3,7]); // Plum colored particles
score += 25; // Points for using Color Splash
// Find all unique normal bubble colors on the board
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) { // Need at least two colors to make a change meaningful
// console.log("Color Splash: Not enough distinct colors on board to change.");
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); // Ensure it changes to a different color if possible
// console.log(`Color Splash: Changing ${colorToRemove} to ${colorToBecome}`);
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, 2, [1,2]);
changedBubblesCoords.push({r,c});
}
}
}
// After changing colors, check for new matches globally
if (changedBubblesCoords.length > 0) {
setTimeout(() => { // Slight delay
let anyMatches = false;
// Check matches starting from all changed bubbles
changedBubblesCoords.forEach(coord => {
if (bubbleGrid[coord.r] && bubbleGrid[coord.r][coord.c]) { // Check if bubble still exists
if(checkMatches(coord.r, coord.c, colorToBecome)) anyMatches = true;
}
});
if (anyMatches) removeFloatingBubbles();
}, 50);
}
updateScoreDisplay();
// console.log("--- activateColorSplash finished ---");
}
function explodeBomb(x, y) {
// console.log(`--- explodeBomb at x=${x.toFixed(1)}, y=${y.toFixed(1)} ---`);
if (currentGameState !== GAME_STATE.PLAYING) return;
createParticles(x, y, '#FFA500', 60, 8, [3,8]); // More particles for bomb
score += 15;
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]) {
const dist = Math.hypot(x - bubbleGrid[r][c].x, y - bubbleGrid[r][c].y);
if (dist < BUBBLE_RADIUS * BOMB_RADIUS_MULTIPLIER) {
// console.log(`Bomb clearing bubble at [${r}][${c}]`);
addFallingBubble(bubbleGrid[r][c]);
// Different particle color for bubbles popped by bomb
createParticles(bubbleGrid[r][c].x, bubbleGrid[r][c].y, bubbleGrid[r][c].color || '#888888', 8, 4, [2,4]);
bubbleGrid[r][c] = null;
score += 5; // Extra per bubble in bomb
clearedCount++;
}
}
}
}
if (clearedCount > 0) updateScoreDisplay();
// console.log(`Bomb cleared ${clearedCount} bubbles.`);
}
function getNeighbors(r, c) {
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)) {
neighbors.push({ r: nr, c: nc });
}
});
return neighbors;
}
function addFallingBubble(gridBubble) {
if (!gridBubble) return;
fallingBubbles.push({
...gridBubble,
x: gridBubble.x,
y: gridBubble.y,
vx: (Math.random() - 0.5) * 2.5, // Slightly more horizontal spread
vy: -Math.random() * 2.5 - 1.5, // Stronger initial pop
alpha: 1,
radius: BUBBLE_RADIUS
});
}
function removeFloatingBubbles() {
// console.log("--- removeFloatingBubbles ---");
if (currentGameState !== GAME_STATE.PLAYING) return;
const connected = new Set();
for (let c = 0; c < getColsInRow(0); c++) {
if (bubbleGrid[0] && 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(`${r},${c}`)) {
addFallingBubble(bubbleGrid[r][c]);
createParticles(bubbleGrid[r][c].x, bubbleGrid[r][c].y, bubbleGrid[r][c].color, 5,3,[1,3]); // Small pop for floating
bubbleGrid[r][c] = null;
score += 5;
floatingCleared++;
}
}
}
if (floatingCleared > 0) {
// console.log(`Cleared ${floatingCleared} floating bubbles.`);
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(n => markConnected(n.r, n.c, connectedSet));
}
function addNewRow() {
// console.log("--- addNewRow ---");
if (currentGameState !== GAME_STATE.PLAYING) return;
const checkRowForPush = GAME_OVER_ROW_INDEX - 1;
// console.log(`addNewRow: Checking for game over push at row ${checkRowForPush} (GAME_OVER_ROW_INDEX is ${GAME_OVER_ROW_INDEX})`);
if (checkRowForPush >= 0) {
if (bubbleGrid[checkRowForPush]) {
for (let c = 0; c < getColsInRow(checkRowForPush); c++) {
if (bubbleGrid[checkRowForPush][c]) {
// console.log(`addNewRow: Bubble found at [${checkRowForPush}][${c}], will cause game over on shift.`);
shiftRowsDown();
triggerGameOver(`Game Over: New row pushed bubbles to game over line ${GAME_OVER_ROW_INDEX}.`);
return;
}
}
}
}
// console.log("addNewRow: No immediate game over from push. Shifting rows down.");
shiftRowsDown();
bubbleGrid[0] = new Array(getColsInRow(0)).fill(null);
for (let c = 0; c < getColsInRow(0); c++) {
if (Math.random() < 0.65) {
bubbleGrid[0][c] = createGridBubble(c, 0);
}
}
// console.log("New top row created.");
checkIfGameOver();
}
function shiftRowsDown() {
// console.log("Shifting rows down...");
for (let r = ROWS - 1; r > 0; r--) {
bubbleGrid[r] = bubbleGrid[r-1];
if (bubbleGrid[r]) {
for(let c = 0; c < getColsInRow(r); c++) {
if (bubbleGrid[r][c]) {
bubbleGrid[r][c].row = r;
bubbleGrid[r][c].y = getBubbleY(r);
bubbleGrid[r][c].x = getBubbleX(c,r);
}
}
}
}
bubbleGrid[0] = new Array(getColsInRow(0)).fill(null);
}
function checkIfGameOver() {
// console.log(`--- checkIfGameOver (using GAME_OVER_ROW_INDEX: ${GAME_OVER_ROW_INDEX}) ---`);
if (currentGameState !== GAME_STATE.PLAYING) return;
if (GAME_OVER_ROW_INDEX < 0 || GAME_OVER_ROW_INDEX >= ROWS) return;
if (bubbleGrid[GAME_OVER_ROW_INDEX]) {
for (let c = 0; c < getColsInRow(GAME_OVER_ROW_INDEX); c++) {
if (bubbleGrid[GAME_OVER_ROW_INDEX][c]) {
triggerGameOver(`Game Over: Bubble found at [${GAME_OVER_ROW_INDEX}][${c}] on game over line.`);
return;
}
}
}
// console.log("checkIfGameOver: No bubbles on game over line.");
}
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.YOU_WIN) return;
currentGameState = GAME_STATE.GAME_OVER;
overlayTitle.textContent = message;
overlayScore.textContent = `Final Score: ${score}`;
overlayScreen.style.display = 'flex';
console.error("GAME OVER TRIGGERED:", message, `Score: ${score}`);
}
function triggerYouWin() {
if (currentGameState === GAME_STATE.YOU_WIN || currentGameState === GAME_STATE.GAME_OVER) return;
currentGameState = GAME_STATE.YOU_WIN;
overlayTitle.textContent = "Congratulations! You Win!";
overlayScore.textContent = `Final Score: ${score}`;
overlayScreen.style.display = 'flex';
// console.log("%c YOU WIN! ", "background: green; color: white; font-size: 20px;", `Score: ${score}`);
}
function handleMouseDown(e) {
if (currentGameState !== GAME_STATE.PLAYING || movingBubble) return;
e.preventDefault();
const pos = getMousePos(e);
const area = shooter.nextBubbleArea;
if (pos.x >= area.x && pos.x <= area.x + area.width &&
pos.y >= area.y && pos.y <= area.y + area.height) {
if (shooter.currentBubble && shooter.nextBubble) {
[shooter.currentBubble, shooter.nextBubble] = [shooter.nextBubble, shooter.currentBubble];
// console.log("Bubbles swapped.");
}
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();
shoot();
isAiming = false;
}
function getMousePos(evt) {
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) {
let dx = mouseX - shooter.x;
let dy = mouseY - shooter.y;
shooter.angle = Math.atan2(dy, dx);
const minAngle = -Math.PI * 0.96; // Slightly wider allowed angle
const maxAngle = -Math.PI * 0.04;
shooter.angle = Math.max(minAngle, Math.min(shooter.angle, maxAngle));
}
function gameLoop() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (currentGameState === GAME_STATE.MENU) {
ctx.fillStyle = '#000022';
ctx.fillRect(0,0,canvas.width, canvas.height);
} else if (currentGameState === GAME_STATE.PLAYING) {
updateMovingBubble();
drawGrid();
drawShooter(); // This now calls drawTrajectory
drawMovingBubble();
drawFallingBubbles();
drawParticles();
} else if (currentGameState === GAME_STATE.GAME_OVER || currentGameState === GAME_STATE.YOU_WIN) {
drawGrid();
drawShooter();
drawFallingBubbles();
drawParticles();
}
requestAnimationFrame(gameLoop);
}
startButton.addEventListener('click', () => {
// console.log("Start button clicked.");
setupCanvas();
initGame();
});
restartButton.addEventListener('click', () => {
// console.log("Restart button clicked.");
setupCanvas();
initGame();
});
menuButton.addEventListener('click', () => {
// console.log("Menu button clicked.");
currentGameState = GAME_STATE.MENU;
overlayScreen.style.display = 'none';
startScreen.style.display = 'flex';
});
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('touchstart', handleMouseDown, { passive: false });
canvas.addEventListener('touchmove', handleMouseMove, { passive: false });
canvas.addEventListener('touchend', handleMouseUp, { passive: false });
// console.log("Initial page load: Setting up canvas for MENU state display and starting game loop.");
setupCanvas();
gameLoop();
</script>
</body>
</html>