Bubble Shooter Improved
Bubble Shooter Improved
import sys
import math
import random
from collections import deque
import os
# Grid dimensions
GRID_START_X = (SCREEN_WIDTH - (GRID_COLS * BUBBLE_DIAMETER - BUBBLE_RADIUS)) / 2
GRID_START_Y = 80
ROW_HEIGHT = int(BUBBLE_RADIUS * math.sqrt(3))
# Bubble colors with improved gradient info (outer color, inner color, shine color)
BUBBLE_COLORS = {
"RED": [(220, 20, 60), (255, 120, 120), (255, 200, 200)], # Red
"BLUE": [(20, 50, 230), (100, 150, 255), (180, 210, 255)], # Blue
"GREEN": [(20, 180, 20), (120, 255, 120), (200, 255, 200)], # Green
"YELLOW": [(230, 180, 0), (255, 255, 80), (255, 255, 200)], # Yellow
"CYAN": [(0, 180, 180), (90, 255, 255), (180, 255, 255)], # Cyan
"PURPLE": [(180, 30, 230), (230, 150, 255), (240, 200, 255)], # Purple
}
AVAILABLE_COLORS = list(BUBBLE_COLORS.keys())
# Game States
STATE_MENU = 0
STATE_PLAYING = 1
STATE_GAME_OVER = 2
STATE_LEVEL_COMPLETE = 3
STATE_PAUSED = 4
# Physics/Gameplay
SHOOTER_Y = SCREEN_HEIGHT - 100
SHOOT_SPEED = 18
SHOTS_UNTIL_DROP = 8
CEILING_BUFFER = BUBBLE_RADIUS
sound_files = {
"shoot": "sounds/shoot.wav",
"pop": "sounds/pop.wav",
"match": "sounds/match.wav",
"bounce": "sounds/bounce.wav",
"drop": "sounds/drop.wav",
"game_over": "sounds/game_over.wav",
"level_complete": "sounds/level_complete.wav",
}
return sounds
self.particles.append({
'pos': pygame.Vector2(pos),
'velocity': pygame.Vector2(math.cos(angle) * speed, math.sin(angle)
* speed),
'color': color_variation,
'size': size,
'life': life,
'max_life': life,
'fade': True,
'pulsate': random.random() > 0.5,
'pulse_rate': random.uniform(0.05, 0.2)
})
def update(self):
i = 0
while i < len(self.particles):
p = self.particles[i]
p['pos'] += p['velocity']
p['life'] -= 1
if p['life'] <= 0:
self.particles.pop(i)
else:
i += 1
# Pulsating effect
size_factor = 1.0
if p['pulsate']:
size_factor = 0.7 + 0.5 * math.sin(p['life'] * p['pulse_rate'])
if world_pos:
self.rect.center = world_pos
elif grid_pos and 'grid_manager' in globals():
self.rect.center = grid_manager.grid_to_world(grid_pos)
# Animation properties
self.pop_animation = False
self.pop_frame = 0
self.pop_max_frames = 8
self.scale = 1.0 # For scaling animations
# Effects
self.shake = 0
self.shake_offset = pygame.Vector2(0, 0)
self.sparkle_timer = random.randint(0, 100) # Random start time for
sparkle
self.pulse = 0
def create_bubble_surface(self):
# Create a surface with alpha channel
surf = pygame.Surface((BUBBLE_DIAMETER, BUBBLE_DIAMETER), pygame.SRCALPHA)
# Middle gradient
for i in range(self.radius-1, self.radius//2, -1):
ratio = (i - self.radius//2) / (self.radius - self.radius//2)
r = int(self.color[0] * ratio + self.inner_color[0] * (1 - ratio))
g = int(self.color[1] * ratio + self.inner_color[1] * (1 - ratio))
b = int(self.color[2] * ratio + self.inner_color[2] * (1 - ratio))
pygame.draw.circle(surf, (r, g, b), (self.radius, self.radius), i)
return surf
# Middle gradient
for i in range(scaled_radius-1, scaled_radius//2, -1):
ratio = (i - scaled_radius//2) / (scaled_radius - scaled_radius//2)
r = int(outer_color[0] * ratio + self.inner_color[0] * (1 - ratio))
g = int(outer_color[1] * ratio + self.inner_color[1] * (1 - ratio))
b = int(outer_color[2] * ratio + self.inner_color[2] * (1 - ratio))
a = min(alpha, 255)
pygame.draw.circle(surf, (r, g, b, a), (size//2, size//2), i)
return surf
def start_pop_animation(self):
self.pop_animation = True
self.pop_frame = 0
def update(self):
# Update pulsing effect for idle bubbles
self.pulse = (self.pulse + 0.05) % (math.pi * 2)
# Update sparkle timer
if not self.velocity.length_squared() > 0 and not self.pop_animation:
self.sparkle_timer -= 1
if self.sparkle_timer <= 0:
# Create sparkle effect
if 'grid_manager' in globals() and random.random() > 0.7:
grid_manager.particles.add_sparkle(self.rect.center,
self.color)
self.sparkle_timer = random.randint(80, 200) # Reset timer
# Ceiling Collision
if self.rect.top <= CEILING_BUFFER:
self.rect.top = CEILING_BUFFER
self.velocity = pygame.Vector2(0, 0)
self.shake = 5
# Let the game manager handle the snapping logic
if pulse_factor != 1.0:
# Only scale if we need to
pulsed_size = int(BUBBLE_DIAMETER * pulse_factor)
pulsed_image = pygame.transform.smoothscale(self.image,
(pulsed_size, pulsed_size))
pulsed_rect = pulsed_image.get_rect(center=self.rect.center +
self.shake_offset)
surface.blit(pulsed_image, pulsed_rect)
else:
# Just apply shake if any
surface.blit(self.image, (self.rect.x + self.shake_offset.x,
self.rect.y + self.shake_offset.y))
# Visual effects
self.shake_amount = 0
self.shake_offset = pygame.Vector2(0, 0)
# Debug
self.debug_info = {"last_match_check": None}
# Calculate row
row_f = (wy - self.start_y) / ROW_HEIGHT
row = int(round(row_f))
row = max(0, min(self.rows - 1, row))
q = deque([start_grid_pos])
visited = {start_grid_pos}
min_dist_sq = float('inf')
best_slot = None
while q:
curr_c, curr_r = q.popleft()
# Explore neighbors
for nc, nr in self.get_neighbors((curr_c, curr_r)):
if self.is_valid_grid_pos((nc, nr)) and (nc, nr) not in visited:
visited.add((nc, nr))
q.append((nc, nr))
return best_slot
target_grid_pos = self.find_nearest_empty_slot(approx_pos)
else:
# Ceiling hit
target_grid_pos = self.find_nearest_empty_slot(shot_bubble.rect.center)
# Create a new static bubble in the grid with the same color
new_grid_bubble = Bubble(shot_bubble.color_name,
grid_pos=target_grid_pos)
self.grid[row][col] = new_grid_bubble
self.bubbles.add(new_grid_bubble)
self.debug_info["last_match_check"] = {
"pos": target_grid_pos,
"color": new_grid_bubble.color_name,
"matches_count": len(matches),
"matches": [(b.grid_pos, b.color_name) for b in matches if
hasattr(b, 'grid_pos')]
}
if len(matches) >= 3:
# Score based on number of matches (exponential to reward larger
matches)
score_value = len(matches) * len(matches) * 5
global score
score += score_value
return True
else:
# Failed to snap, destroy the shot
shot_bubble.kill()
return False
if not start_bubble:
return []
target_color_name = start_bubble.color_name
connected = []
while q:
current = q.popleft()
connected.append(current)
neighbor = self.grid[nr][nc]
if (neighbor and
neighbor not in visited and
(not match_color or neighbor.color_name == target_color_name)):
visited.add(neighbor)
q.append(neighbor)
return connected
def reset_processed_flags(self):
for bubble in self.bubbles:
bubble.processed = False
bubble.is_anchored = False
def check_floating_bubbles(self):
# Reset flags
self.reset_processed_flags()
processed_for_anchor = set(anchored_bubbles)
while q:
current = q.popleft()
if not hasattr(current, 'grid_pos') or current.grid_pos is None:
continue
for nc, nr in self.get_neighbors(current.grid_pos):
if not self.is_valid_grid_pos((nc, nr)):
continue
neighbor = self.grid[nr][nc]
if neighbor and neighbor not in processed_for_anchor:
neighbor.is_anchored = True
processed_for_anchor.add(neighbor)
q.append(neighbor)
if floating:
# Add score for floating bubbles
global score
score += len(floating) * 20
def add_new_row(self):
# Shift all bubbles down
for r in range(self.rows-1, 0, -1):
for c in range(self.cols):
self.grid[r][c] = self.grid[r-1][c]
if self.grid[r][c]:
self.grid[r][c].grid_pos = (c, r)
# Update bubble position visually
self.grid[r][c].rect.center = self.grid_to_world((c, r))
def check_game_over(self):
for bubble in self.bubbles:
if bubble.rect.bottom >= GAME_OVER_LINE_Y and not bubble.pop_animation:
return True
return False
# Draw particles
self.particles.draw(surface_copy)
# Add rim
pygame.draw.circle(self.base_image, (60, 60, 80),
(self.base_image.get_width()//2,
self.base_image.get_height()//2),
BUBBLE_RADIUS * 1.2, 2)
# Animation properties
self.recoil = 0
self.shake = 0
self.shake_offset = pygame.Vector2(0, 0)
def load_bubbles(self):
# Ensure bubbles are different colors if possible
if not self.current_bubble_color:
self.current_bubble_color = random.choice(AVAILABLE_COLORS)
next_color = random.choice(AVAILABLE_COLORS)
while next_color == self.current_bubble_color and len(AVAILABLE_COLORS) >
1:
next_color = random.choice(AVAILABLE_COLORS)
self.next_bubble_color = next_color
def swap_bubbles(self):
self.current_bubble_color, self.next_bubble_color = self.next_bubble_color,
self.current_bubble_color
# Add swap animation effect
self.shake = 5
def update_prediction_path(self):
# Calculate the prediction path with bounces
self.prediction_points = []
MAX_BOUNCES = 3
MAX_POINTS = 20
point_count = 0
start_y = intersection_y
def shoot(self):
if not self.current_bubble_color:
return None
return shot_bubble
def update(self):
# Update recoil
if self.recoil > 0:
self.recoil -= 1
# Update shake
if self.shake > 0:
self.shake_offset.x = random.randint(-3, 3)
self.shake_offset.y = random.randint(-3, 3)
self.shake -= 1
else:
self.shake_offset = pygame.Vector2(0, 0)
start = self.prediction_points[i]
end = self.prediction_points[i+1]
# Draw base
base_rect = self.base_image.get_rect(center=(self.base_pos.x +
self.shake_offset.x,
self.base_pos.y +
self.shake_offset.y))
surface.blit(self.base_image, base_rect)
# Draw cannon
cannon_pos = self.pos + recoil_offset + self.shake_offset
cannon_end = cannon_pos +
pygame.Vector2(math.cos(math.radians(self.aim_angle)),
-
math.sin(math.radians(self.aim_angle))) * self.cannon_length
# Add highlight
highlight_pos = (bubble_pos[0] - BUBBLE_RADIUS * 0.3, bubble_pos[1] -
BUBBLE_RADIUS * 0.3)
pygame.draw.circle(surface, (255, 255, 255, 180), highlight_pos,
BUBBLE_RADIUS * 0.5)
# Add outline
pygame.draw.circle(surface, (*outer_color, 200), bubble_pos,
BUBBLE_RADIUS, 2)
# Outer edge
pygame.draw.circle(surface, outer_color, next_pos, BUBBLE_RADIUS *
bubble_scale)
# Middle gradient
for i in range(int(BUBBLE_RADIUS * bubble_scale)-1, int(BUBBLE_RADIUS *
bubble_scale//2), -1):
ratio = (i - BUBBLE_RADIUS * bubble_scale//2) / (BUBBLE_RADIUS *
bubble_scale - BUBBLE_RADIUS * bubble_scale//2)
r = int(outer_color[0] * ratio + inner_color[0] * (1 - ratio))
g = int(outer_color[1] * ratio + inner_color[1] * (1 - ratio))
b = int(outer_color[2] * ratio + inner_color[2] * (1 - ratio))
pygame.draw.circle(surface, (r, g, b), next_pos, i)
# Inner gradient
for i in range(int(BUBBLE_RADIUS * bubble_scale//2), 0, -1):
ratio = i / (BUBBLE_RADIUS * bubble_scale//2)
r = int(inner_color[0] * ratio + shine_color[0] * (1 - ratio))
g = int(inner_color[1] * ratio + shine_color[1] * (1 - ratio))
b = int(inner_color[2] * ratio + shine_color[2] * (1 - ratio))
pygame.draw.circle(surface, (r, g, b), next_pos, i)
# Add highlight
highlight_pos = (next_pos[0] - BUBBLE_RADIUS * 0.2, next_pos[1] -
BUBBLE_RADIUS * 0.2)
pygame.draw.circle(surface, (255, 255, 255, 180), highlight_pos,
BUBBLE_RADIUS * 0.3)
# Add outline
pygame.draw.circle(surface, (*outer_color, 200), next_pos,
BUBBLE_RADIUS * bubble_scale, 1)
# "Next" label
if font:
text = font_small.render("Next", True, (240, 240, 240))
text_rect = text.get_rect(center=(next_pos[0], next_pos[1] +
BUBBLE_RADIUS * 1.2))
shadow_rect = text_rect.copy()
shadow_rect.move_ip(1, 1)
# Animation properties
self.pulse = 0
if self.is_hovered:
self.current_color = self.hover_color
if mouse_clicked and self.action:
return self.action()
else:
self.current_color = self.color
return None
# Blit gradient onto the rounded surface using the white as a mask
gradient_surf = gradient_surf.convert_alpha()
rounded_surf.blit(gradient_surf, (0, 0),
special_flags=pygame.BLEND_RGBA_MULT)
# Draw shadow
shadow_surf = self.font.render(self.text, True, (20, 20, 40))
shadow_rect = text_rect.copy()
shadow_rect.move_ip(2, 2)
surface.blit(shadow_surf, shadow_rect)
surface.blit(text_surf, text_rect)
class FloatingText:
def __init__(self, text, pos, color=(255, 255, 255), duration=60, speed=1,
font_size=28):
self.font = pygame.font.Font(None, font_size)
self.text = text
self.pos = pygame.Vector2(pos)
self.color = color
self.duration = duration
self.age = 0
self.speed = speed
self.offset = pygame.Vector2(0, 0)
def update(self):
self.age += 1
self.offset.y -= self.speed
return self.age >= self.duration
# Draw glow
for offset in [(2, 0), (-2, 0), (0, 2), (0, -2)]:
offset_pos = (pos[0] + offset[0], pos[1] + offset[1])
surface.blit(glow_surf, glow_surf.get_rect(center=offset_pos))
# Draw text
surface.blit(text_surf, text_surf.get_rect(center=pos))
class ScoreDisplay:
def __init__(self, x, y):
self.pos = (x, y)
self.target_score = 0
self.displayed_score = 0
self.font = pygame.font.Font(None, 36)
self.floating_texts = []
# Add highlight
pygame.draw.rect(panel, (100, 100, 150, 100),
(2, 2, width-4, height//2-2),
border_radius=10)
# Add border
pygame.draw.rect(panel, (140, 140, 200),
(0, 0, width, height),
2, border_radius=10)
# Glow
glow_surf = self.font.render(text, True, (140, 140, 255))
glow_rect = glow_surf.get_rect(center=self.pos)
for offset in [(1, 1), (-1, -1), (1, -1), (-1, 1)]:
offset_pos = (glow_rect.x + offset[0], glow_rect.y + offset[1])
glow_surf.set_alpha(30)
surface.blit(glow_surf, offset_pos)
# Main text
text_surf = self.font.render(text, True, (240, 240, 255))
text_rect = text_surf.get_rect(center=self.pos)
surface.blit(text_surf, text_rect)
def update(self):
# Update star pulsing
for star in self.stars:
star['pulse'] = (star['pulse'] + star['speed']) % (math.pi*2)
return True
def pause_game():
global game_state
if game_state == STATE_PLAYING:
game_state = STATE_PAUSED
elif game_state == STATE_PAUSED:
game_state = STATE_PLAYING
return True
# Load fonts
font = pygame.font.Font(None, 36)
font_large = pygame.font.Font(None, 72)
font_small = pygame.font.Font(None, 24)
if event.type == pygame.MOUSEBUTTONDOWN:
mouse_clicked = True
if game_state == STATE_PLAYING:
if event.button == 1 and can_shoot: # Left click
new_shot = shooter.shoot()
if new_shot:
flying_bubbles.add(new_shot)
shots_fired += 1
can_shoot = False
elif event.button == 3: # Right click
shooter.swap_bubbles()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_r and game_state in [STATE_GAME_OVER,
STATE_LEVEL_COMPLETE]:
start_game()
if event.key == pygame.K_p:
pause_game()
if event.key == pygame.K_d:
DEBUG_MODE = not DEBUG_MODE
if game_state == STATE_MENU:
# Handle menu button
start_button.update(mouse_pos, mouse_clicked)
# Collision Detection
for shot in list(flying_bubbles): # Use a copy for safe iteration
hit_list = pygame.sprite.spritecollide(shot, grid_manager.bubbles,
False, pygame.sprite.collide_circle)
if hit_list:
snap_successful = grid_manager.snap_bubble(shot)
if snap_successful is not None:
can_shoot = True
if game_over:
game_state = STATE_GAME_OVER
if 'sounds' in globals() and 'game_over' in sounds:
sounds['game_over'].play()
if game_state == STATE_MENU:
# Draw menu screen
title_text = font_large.render("Bubble Shooter HD", True, (240, 240, 255))
title_rect = title_text.get_rect(center=(SCREEN_WIDTH//2,
SCREEN_HEIGHT//3))
screen.blit(title_text, title_rect)
# Draw subtitle
subtitle = font.render("Click to Play", True, (200, 200, 255))
subtitle_rect = subtitle.get_rect(center=(SCREEN_WIDTH//2, SCREEN_HEIGHT//3
+ 60))
screen.blit(subtitle, subtitle_rect)
start_button.draw(screen)
# Add highlight
pygame.draw.circle(screen, (255, 255, 255, 150),
(x - size * 0.3, y - size * 0.3),
size * 0.3)
elif game_state in [STATE_PLAYING, STATE_PAUSED]:
# Draw Grid Bubbles
grid_manager.draw(screen)
# Draw Shooter
shooter.draw_aim_line(screen)
shooter.draw(screen)
# Draw UI
score_display.draw(screen)
pause_button.draw(screen)
# Create panel
panel_width, panel_height = 400, 200
panel = pygame.Surface((panel_width, panel_height), pygame.SRCALPHA)
pygame.draw.rect(panel, (40, 40, 80, 230),
(0, 0, panel_width, panel_height),
border_radius=20)
pygame.draw.rect(panel, (120, 120, 180),
(0, 0, panel_width, panel_height),
3, border_radius=20)
# Draw glow
glow_text = font_large.render("PAUSED", True, (100, 100, 200))
for offset in [(3, 3), (-3, -3), (3, -3), (-3, 3)]:
glow_rect = pause_rect.copy()
glow_rect.move_ip(*offset)
glow_text.set_alpha(30)
screen.blit(glow_text, glow_rect)
screen.blit(pause_text, pause_rect)
# Resume instructions
resume_text = font.render("Press P to Resume", True, (220, 220, 255))
resume_rect = resume_text.get_rect(center=(SCREEN_WIDTH//2,
SCREEN_HEIGHT//2 + 30))
screen.blit(resume_text, resume_rect)
# Create panel
panel_width, panel_height = 500, 300
panel = pygame.Surface((panel_width, panel_height), pygame.SRCALPHA)
pygame.draw.rect(panel, (40, 20, 40, 230),
(0, 0, panel_width, panel_height),
border_radius=30)
pygame.draw.rect(panel, (180, 60, 60),
(0, 0, panel_width, panel_height),
4, border_radius=30)
# Draw glow
glow_text = font_large.render("GAME OVER", True, (150, 50, 50))
for offset in [(3, 3), (-3, -3), (3, -3), (-3, 3)]:
glow_rect = text_rect.copy()
glow_rect.move_ip(*offset)
glow_text.set_alpha(40)
screen.blit(glow_text, glow_rect)
screen.blit(game_over_text, text_rect)
screen.blit(score_text, score_rect)
# Restart instruction
restart_text = font.render("Press R to Restart", True, (220, 180, 180))
restart_rect = restart_text.get_rect(center=(SCREEN_WIDTH//2,
SCREEN_HEIGHT//2 + 70))
screen.blit(restart_text, restart_rect)
# Create panel
panel_width, panel_height = 500, 300
panel = pygame.Surface((panel_width, panel_height), pygame.SRCALPHA)
pygame.draw.rect(panel, (20, 40, 20, 220),
(0, 0, panel_width, panel_height),
border_radius=30)
pygame.draw.rect(panel, (60, 180, 60),
(0, 0, panel_width, panel_height),
4, border_radius=30)
# Draw glow
glow_text = font_large.render("LEVEL COMPLETE!", True, (100, 255, 100))
for offset in [(3, 3), (-3, -3), (3, -3), (-3, 3)]:
glow_rect = text_rect.copy()
glow_rect.move_ip(*offset)
glow_text.set_alpha(30)
screen.blit(glow_text, glow_rect)
screen.blit(complete_text, text_rect)
for i in range(stars):
x = star_start_x + i * star_spacing
y = SCREEN_HEIGHT//2 + 40
# Draw a star
star_points = []
for j in range(10):
angle = math.pi/2 + j * math.pi/5
radius = star_width//2 if j % 2 == 0 else star_width//4
star_points.append((
x + radius * math.cos(angle),
y + radius * math.sin(angle)
))
# Add outline
pygame.draw.polygon(screen, (100, 80, 0), star_points, 1)
# Update Display
pygame.display.flip()