improve script berikut ini agar fps nya stabil dan tidak terjadi frame drop karena saat dijalnakan ada sedikit framedrop yang terjadi, jangan sampai mengganti fungsi yang ada, tambahkan atau ganti saja fungsi yang berguna untuk mengimprove fps nya : import pygame
import mido
import sys
import random
import requests
import colorsys
import os
import math
import cv2
import numpy as np
import pickle  # Imported to handle pickle file operations
from os.path import join, isfile
from time import time as get_current_time
from typing import Union, Optional, Any
from enum import Enum
from math import sin, pi

pygame.mixer.init()
pygame.init()
FRAMERATE = 60

# =========================
# Customizable Configuration
# =========================

# Midi Selection
SONG = "bad-piggies.mid"

# Screen Resolution
SCREEN_WIDTH = 1280
SCREEN_HEIGHT = 720

# Other Customizable Settings
SQUARE_SPEED = 600
SQUARE_SIZE = 40
BOUNCE_MIN_SPACING = 50
DIRECTION_CHANGE = 30
EMPTY_SQUARE = True

# Particle Settings
PARTICLE_BOUNCE = False
PARTICLE_TRAIL = False
TRAIL_COLOR_BASED_ON_SQUARE = False
PARTICLE_SPEED = 5

# Peg Settings
BOUNCE_PEG = True
PEG_COLOR = (255, 255, 255)
PEG_REMOVE = True
PEG_MULTI_COLOR = False
NO_COLOR = False

# Glow Settings
SQUARE_GLOW = True
PEG_GLOW = True
GLOW_RANDOM_COLOR = True
HEAT_LEVEL = False

# Star Settings
STAR = True
STAR_COLOR = (255, 255, 255)
STAR_DENSITY = 0.0001

# =========================

# Path to store the pickle file
PICKLE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'stored_colors.pkl')

def load_stored_colors(pickle_file):
    try:
        with open(pickle_file, 'rb') as f:
            stored_colors = pickle.load(f)
    except FileNotFoundError:
        stored_colors = []
    except Exception as e:
        print(f"Error loading stored colors: {e}")
        stored_colors = []
    return stored_colors

def save_colors(pickle_file, stored_colors):
    try:
        with open(pickle_file, 'wb') as f:
            pickle.dump(stored_colors, f)
    except Exception as e:
        print(f"Error saving colors: {e}")

def color_hunt():
    stored_colors = load_stored_colors(PICKLE_FILE)
    def is_bright(hex_color):
        hex_color = hex_color.lstrip('#')
        rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
        luminance = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]
        r, g, b = [x / 255.0 for x in rgb]
        h, s, v = colorsys.rgb_to_hsv(r, g, b)
        return luminance >= 130 and s >= 0.3
    try:
        while True:
            url = 'https://colorhunt.co/php/feed.php'
            headers = {
                'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
            }
            data = {
                'step': 0,
                'sort': 'random',
                'tags': ''
            }
            response = requests.post(url, headers=headers, data=data)
            if response.status_code == 200:
                palettes = response.json()
                color_pairs = []
                for palette in palettes:
                    code = palette['code']
                    hex_colors = [f'#{code[i:i+6].upper()}' for i in range(0, len(code), 6)]
                    if len(hex_colors) == 4 and all(is_bright(hex_color) for hex_color in hex_colors):
                        color_pair = tuple(hex_colors)
                        if color_pair not in stored_colors:
                            stored_colors.append(color_pair)  # Save new unique colors
                        color_pairs.append(color_pair)
                if color_pairs:
                    # Save updated stored_colors
                    save_colors(PICKLE_FILE, stored_colors)
                    selected_color_pair = random.choice(color_pairs)
                    return selected_color_pair
            else:
                if stored_colors:
                    print("Using stored colors.")
                    selected_color_pair = random.choice(stored_colors)
                    return selected_color_pair
                raise Exception(f"Failed to retrieve data. Status code: {response.status_code}")
    except Exception as e:
        print(f"Error fetching colors: {e}")
        if stored_colors:
            print("Using stored colors.")
            selected_color_pair = random.choice(stored_colors)
            return selected_color_pair
        else:
            raise Exception("No stored colors available and failed to fetch new colors.")

selected_colors = color_hunt()
color1, color2, color3, color4 = selected_colors

class Config:
    # Constants
    SQUARE_SIZE = SQUARE_SIZE
    PARTICLE_SPEED = PARTICLE_SPEED
    CAMERA_SPEED = 500
    # Colors
    color_themes = {
        "dark": {
            "hallway": pygame.Color('#2D033B'),
            "background": pygame.Color('#070F2B'),
            "square": [
                pygame.Color(color1),
                pygame.Color(color2),
                pygame.Color(color3),
                pygame.Color(color4)
            ],
            "peg": pygame.Color(PEG_COLOR),    # Added peg color
        }
    }

    # Configuration
    script_dir = os.path.dirname(os.path.abspath(__file__))
    assets = os.path.join(script_dir, 'assets/icon.png')
    song = os.path.join(script_dir, 'songs')
    font = os.path.join(script_dir, 'assets/poppins-regular.ttf')
    SCREEN_WIDTH = SCREEN_WIDTH
    SCREEN_HEIGHT = SCREEN_HEIGHT
    theme: Optional[str] = "dark"
    seed: Optional[int] = None
    camera_mode: Optional[int] = 2
    start_playing_delay = 5000
    max_notes: Optional[int] = None
    bounce_min_spacing: Optional[float] = BOUNCE_MIN_SPACING
    square_speed: Optional[int] = SQUARE_SPEED
    volume: Optional[int] = 100
    music_offset: Optional[int] = 0
    direction_change_chance: Optional[int] = DIRECTION_CHANGE
    particle_trail = PARTICLE_TRAIL
    do_color_bounce_pegs = BOUNCE_PEG
    do_particles_on_bounce = PARTICLE_BOUNCE

    # Non-configurable settings
    backtrack_chance: Optional[float] = 0.02
    backtrack_amount: Optional[int] = 40
    square_swipe_anim_speed: Optional[int] = 4
    particle_amount = 20

    # Glow effect settings for square
    square_glow = SQUARE_GLOW
    square_glow_duration = 0.2
    glow_intensity = 15  # Adjusted intensity for better visibility
    square_min_glow = 0   # Lower value for slight glow when no collision

    # Heat Level Settings
    HEAT_LEVEL = HEAT_LEVEL  # From the top setting
    max_heat_level = 25.0     # Max heat level
    # Parameters for collision frequency calculation
    collision_frequency_alpha = 0.5  # Smoothing factor for exponential moving average
    heat_scale_factor = 3.0  # Scale the heat level based on collision frequency
    collision_frequency_decay_rate = 0.8  # Decay rate for collision frequency when no collision occurs

    # Glow effect settings for pegs
    peg_glow = PEG_GLOW
    peg_glow_duration = 0.5
    peg_glow_intensity = 15  # Adjusted intensity for better visibility
    peg_min_glow = 7

    if GLOW_RANDOM_COLOR:
        peg_glow_color = pygame.Color(random.choice(selected_colors))
        square_glow_color = pygame.Color(random.choice(selected_colors))
    else:
        peg_glow_color = pygame.Color(255, 255, 255)
        square_glow_color = pygame.Color(255, 255, 255)

    # Star settings
    star_color = STAR_COLOR
    star_density = STAR_DENSITY

    # Other random stuff
    current_song = None
    dt = 0.01

def get_colors():
    return Config.color_themes.get(Config.theme, Config.color_themes["dark"])

def read_midi_file(file):
    midi_file = mido.MidiFile(file=file)

    notes = []
    current_time = 0

    for msg in midi_file:
        if msg.type == 'note_on' and msg.velocity != 0:
            timestamp = current_time + msg.time
            notes.append(round(timestamp*1000)/1000)
        current_time += msg.time
    return notes

def remove_too_close_values(lst: list[float], threshold=30) -> list[float]:
    """Assumes the list is sorted"""
    new = []
    before = None
    for _ in lst:
        if before is None:
            before = _
            new.append(_)
            continue
        if before+threshold/1000 > _:
            continue
        before = _
        new.append(_)
    return new

def fix_overlap(rects: list[pygame.Rect], callback=None):
    """
    Optimized function to merge overlapping rectangles efficiently.
    """
    if callback is None:
        callback = lambda _: None

    rects = rects.copy()
    # Sort rectangles based on x coordinate
    rects.sort(key=lambda r: (r.x, r.y))

    merged_rects = []
    for rect in rects:
        if not merged_rects:
            merged_rects.append(rect)
        else:
            last = merged_rects[-1]
            if last.colliderect(rect):
                merged_rect = last.union(rect)
                merged_rects[-1] = merged_rect
            else:
                merged_rects.append(rect)

    callback("Finished merging rectangles")

    return merged_rects

def get_font(size: int = 24) -> pygame.font.Font:
    font_path = Config.font
    return pygame.font.Font(font_path, size)

class MapLoadingFailureError(Exception):
    """The map fails to load (recurs function fails)"""
    pass

class UserCancelsLoadingError(Exception):
    """User cancels the loading screen"""
    pass

def interpolate_fn(n):
    """Interpolate sigmoidally from 0-1"""
    n = min(max(n, 0), 1)
    return sin(pi * (n - 0.5)) / 2 + 0.5

class Particle:
    SPEED_VARIATION = 4
    SIZE_MIN = 7
    SIZE_MAX = 14
    AGE_RATE = 20
    SLOW_DOWN_RATE = 1.2

    def __init__(self, pos: list[float], delta: list[float], invert_color: bool = False, color: Optional[tuple] = None):
        self.pos = pos.copy()
        self.size = random.randint(Particle.SIZE_MIN, Particle.SIZE_MAX)
        self.delta = delta.copy()
        self.delta[0] += random.randint(-Particle.SPEED_VARIATION, Particle.SPEED_VARIATION)/8
        self.delta[1] += random.randint(-Particle.SPEED_VARIATION, Particle.SPEED_VARIATION)/8
        if color is not None:
            self.color = color
        else:
            # Default color if color is not given
            self.color = get_colors()["hallway"] if not invert_color else get_colors()["background"]

        # Adjust color to white for particles
        self.color = (255, 255, 255)

    def age(self):
        self.size -= Particle.AGE_RATE*Config.dt
        self.x += self.delta[0] * Config.PARTICLE_SPEED
        self.y += self.delta[1] * Config.PARTICLE_SPEED
        if Config.dt != 0:
            self.delta[0] /= (Particle.SLOW_DOWN_RATE+FRAMERATE) * Config.dt
            self.delta[1] /= (Particle.SLOW_DOWN_RATE+FRAMERATE) * Config.dt
        return self.size <= 0

    @property
    def x(self):
        return self.pos[0]

    @x.setter
    def x(self, val: float):
        self.pos[0] = val

    @property
    def y(self):
        return self.pos[1]

    @y.setter
    def y(self, val: float):
        self.pos[1] = val

    @property
    def rect(self):
        return pygame.Rect(self.x-self.size/2, self.y-self.size/2, *(2*[self.size]))

class Bounce:
    def __init__(self, sq_pos: list[float], sq_dir: list[int], time: float, bounce_dir: int):
        self.square_pos = sq_pos  # New square position
        self.square_dir = sq_dir  # New square direction
        self.bounce_dir = bounce_dir  # Bounce direction for squish effect; 0 or 1
        self.time = time  # Time during bounce

    def get_collision_rect(self):
        sx, sy = self.square_pos
        if self.bounce_dir == 0:
            # Bounce left or right wall
            if self.square_dir[0] == -1:
                # Right wall
                return pygame.Rect(
                    sx+Config.SQUARE_SIZE/2+1,
                    sy-10,
                    10,
                    20
                )
            elif self.square_dir[0] == 1:
                # Left wall
                return pygame.Rect(
                    sx-10-Config.SQUARE_SIZE/2-1,
                    sy-10,
                    10,
                    20
                )
        elif self.bounce_dir == 1:
            # Bounce top or bottom wall
            if self.square_dir[1] == -1:
                # Bottom wall
                return pygame.Rect(
                    sx-10,
                    sy+Config.SQUARE_SIZE/2+1,
                    20,
                    10
                )
            elif self.square_dir[1] == 1:
                # Top wall
                return pygame.Rect(
                    sx-10,
                    sy-10-Config.SQUARE_SIZE/2-1,
                    20,
                    10
                )

    def copy(self) -> "Bounce":
        return Bounce(self.square_pos.copy(), self.square_dir.copy(), self.time, self.bounce_dir)

    def __repr__(self):
        return f"<Bounce(sq_pos={self.square_pos}, sq_dir={self.square_dir}, time={self.time}, dir={self.bounce_dir})"

# Glow effect functions

def create_border(image: np.ndarray, margin: int, thickness: int, color: tuple) -> np.ndarray:
    height, width = image.shape[:2]
    cv2.rectangle(image, (margin, margin), (width - margin, height - margin), color, thickness=thickness)
    return image

def apply_blooming(image: np.ndarray) -> np.ndarray:
    # Provide some blurring to image, to create some bloom.
    cv2.GaussianBlur(image, ksize=(15, 15), sigmaX=20, sigmaY=20, dst=image)
    cv2.blur(image, ksize=(12, 12), dst=image)
    return image

def glowing_border(image: np.ndarray, margin=20, thickness=20, color=(255, 255, 255)):
    """
    Create a glowing border around an image.
    """
    # Generate the colored border.
    image = create_border(image, margin, thickness, color)

    # Apply the bloom effect to the image with the border.
    image = apply_blooming(image)

    # Reassert the original border to enhance the edges.
    image = create_border(image, margin - 1, 1, color)
    image = create_border(image, margin + 1, 1, color)
    return image

def make_glowy2(size, color, intensity=0) -> pygame.Surface:
    image = np.zeros((*size[::-1], 3), dtype=np.uint8)
    border_color_rgb = color  # Assuming color is RGB tuple
    border = glowing_border(image.copy(), color=border_color_rgb, thickness=intensity)
    return pygame.surfarray.make_surface(np.fliplr(np.rot90(border, k=-1)))

class Square:
    glowing_surface_cache = {}
    def __init__(self, x: float = 0, y: float = 0, dx: int = 1, dy: int = 1):
        self.pos: list[float] = [x, y]
        self.dir: list[int] = [dx, dy]
        self.last_bounce_time = -100
        self.latest_bounce_direction = 0  # 0 = horiz, 1 = vert
        self.past_colors = []
        self.died = False
        self.time_since_glow_start = -1000  # For glow effect
        self.last_collision_interval = None
        self.heat_level = 0.0
        self.collision_frequency = 0.0  # For exponential moving average

    def register_past_color(self, col: tuple[int, int, int]):
        for _ in range(max(Config.square_swipe_anim_speed, 1)):
            self.past_colors.insert(0, col)
        while len(self.past_colors) > Config.SQUARE_SIZE * 4 / 5:
            self.past_colors.pop()

    def get_surface(self, size: tuple[int, int]):
        ss = int(Config.SQUARE_SIZE * 4 / 5)
        surf = pygame.Surface((ss, ss))
        for index, col in enumerate(self.past_colors):
            y = index if self.dir_y != 1 else ss - 1 - index
            pygame.draw.line(surf, col, (0, y), (ss, y))
        return pygame.transform.scale(surf, size)

    def copy(self) -> "Square":
        new = Square(*self.pos, *self.dir)
        new.last_bounce_time = self.last_bounce_time
        new.latest_bounce_direction = self.latest_bounce_direction
        new.heat_level = self.heat_level
        new.collision_frequency = self.collision_frequency
        return new

    def get_current_color(self):
        if EMPTY_SQUARE:
            return (255, 255, 255)  # Border color is white
        else:
            square_color_index = round((self.dir_x + 1) / 2 + self.dir_y + 1)
            return get_colors()["square"][square_color_index % len(get_colors()["square"])]

    def compute_glowy_surface(self, rect, val):
        cache_key = (rect.size, val)
        if cache_key in Square.glowing_surface_cache:
            return Square.glowing_surface_cache[cache_key]
        glowy_borders = make_glowy2((rect.width + 40, rect.height + 40), Config.square_glow_color, val)
        surface = pygame.Surface(rect.inflate(100, 100).size, pygame.SRCALPHA)
        surface.blit(glowy_borders, (20, 20), special_flags=pygame.BLEND_RGBA_ADD)
        Square.glowing_surface_cache[cache_key] = surface
        return surface

    def draw_glowing3(self, win, rect):
        if self.died:
            return

        if Config.square_glow:
            if Config.HEAT_LEVEL:
                # Glow intensity based on heat level
                val = Config.square_min_glow + (self.heat_level / Config.max_heat_level) * (Config.glow_intensity - Config.square_min_glow)
                val = int(val)
                val = min(val, Config.glow_intensity)
                val = max(val, Config.square_min_glow)
            else:
                current_ticks = pygame.time.get_ticks()
                time_since_glow = current_ticks - self.time_since_glow_start

                if EMPTY_SQUARE:
                    # Glow is always slightly on, increases during collision
                    if time_since_glow < Config.square_glow_duration * 1000:
                        progress = 1 - (time_since_glow) / (Config.square_glow_duration * 1000)
                        val = int(progress * Config.glow_intensity)
                        val = max(val, Config.square_min_glow)
                    else:
                        # Minimum glow intensity when not colliding
                        val = Config.square_min_glow
                else:
                    # Glow only during collision
                    if time_since_glow < Config.square_glow_duration * 1000:
                        progress = 1 - (time_since_glow) / (Config.square_glow_duration * 1000)
                        val = int(progress * Config.glow_intensity)
                        val = max(val, Config.square_min_glow)
                    else:
                        return  # Do not draw glow if not within glow duration

            if val > 0:
                surf = self.compute_glowy_surface(rect, val)
                win.blit(surf, rect.move(-40, -40).topleft, special_flags=pygame.BLEND_RGBA_ADD)

    @property
    def x(self):
        return self.pos[0]

    @property
    def y(self):
        return self.pos[1]

    def draw(self, screen: pygame.Surface, sqrect: pygame.Rect):
        if self.died:
            return

        # Draw glowing square if glowing
        self.draw_glowing3(screen, sqrect)

        if EMPTY_SQUARE:
            # Draw the square as an empty square with white border
            border_color = (255, 255, 255)
            border_thickness = 2  # Adjust as needed
            pygame.draw.rect(screen, border_color, sqrect, border_thickness)
        else:
            # Default drawing of the square
            square_color_index = round((self.dir_x + 1) / 2 + self.dir_y + 1)
            self.register_past_color(get_colors()["square"][square_color_index % len(get_colors()["square"])])

            # Draw the square itself
            pygame.draw.rect(screen, (0, 0, 0), sqrect)
            sq_surf = self.get_surface(
                tuple(sqrect.inflate(-int(Config.SQUARE_SIZE / 5), -int(Config.SQUARE_SIZE / 5))[2:]))
            screen.blit(sq_surf, sq_surf.get_rect(center=sqrect.center))

    @x.setter
    def x(self, val: int):
        self.pos[0] = val

    @y.setter
    def y(self, val: int):
        self.pos[1] = val

    @property
    def dir_x(self):
        return self.dir[0]

    @property
    def dir_y(self):
        return self.dir[1]

    @property
    def rect(self):
        return pygame.Rect(self.x - Config.SQUARE_SIZE / 2, self.y - Config.SQUARE_SIZE / 2,
                           *([Config.SQUARE_SIZE] * 2))

    def obey_bounce(self, bounce: Bounce):
        # Calculate time since last collision
        collision_interval = None
        if self.last_bounce_time != -100:
            collision_interval = bounce.time - self.last_bounce_time
            if collision_interval > 0:
                collision_frequency = 1.0 / collision_interval
                # Exponential moving average for collision frequency
                alpha = Config.collision_frequency_alpha
                self.collision_frequency = alpha * collision_frequency + (1 - alpha) * self.collision_frequency
        else:
            self.collision_frequency = 0.0  # First collision

        # Update heat level based on collision frequency
        if Config.HEAT_LEVEL:
            self.heat_level = self.collision_frequency * Config.heat_scale_factor
            self.heat_level = min(self.heat_level, Config.max_heat_level)

        # Then update last_bounce_time
        self.last_bounce_time = bounce.time
        self.pos = bounce.square_pos.copy()
        self.dir = bounce.square_dir.copy()
        self.latest_bounce_direction = bounce.bounce_dir
        # Start glow effect
        self.time_since_glow_start = pygame.time.get_ticks()
        return

    def update_heat_level(self, dt):
        if Config.HEAT_LEVEL:
            # Decay collision frequency over time when no collisions happen
            decay_rate = Config.collision_frequency_decay_rate
            self.collision_frequency = max(0.0, self.collision_frequency - decay_rate * dt)
            # Update heat level based on updated collision frequency
            self.heat_level = self.collision_frequency * Config.heat_scale_factor
            self.heat_level = min(self.heat_level, Config.max_heat_level)
            self.heat_level = max(0.0, self.heat_level)

    def reg_move(self, use_dt: bool = True):
        self.x += self.dir_x * Config.square_speed * (Config.dt if use_dt else 1 / FRAMERATE)
        self.y += self.dir_y * Config.square_speed * (Config.dt if use_dt else 1 / FRAMERATE)

class Camera:
    def __init__(self, x: int = 0, y: int = 0):
        self.x = x
        self.y = y
        self.locked_on_square = True
        self.lock_type: CameraFollow = CameraFollow(Config.camera_mode)

    def attempt_movement(self):
        if not self.locked_on_square:
            keys = pygame.key.get_pressed()
            shift_modifier = (keys[pygame.K_LSHIFT] | keys[pygame.K_RSHIFT]) + 1
            self.x += (keys[pygame.K_d] - keys[pygame.K_a]) * Config.CAMERA_SPEED * shift_modifier / FRAMERATE
            self.y += (keys[pygame.K_s] - keys[pygame.K_w]) * Config.CAMERA_SPEED * shift_modifier / FRAMERATE

    @property
    def pos(self):
        return self.x, self.y

    @pos.setter
    def pos(self, val: Union[tuple[int, int], list[int]]):
        self.x, self.y = val

    def offset(self, pos_or_rect: Union[pygame.Rect, tuple[int, int]]) -> Union[pygame.Rect, list[int]]:
        if isinstance(pos_or_rect, pygame.Rect):
            return pos_or_rect.move(-self.x, -self.y)
        else:
            return [pos_or_rect[0]-self.x, pos_or_rect[1]-self.y]

    def apply_parallax(self, pos: tuple[float, float], depth: float) -> list[float]:
        # Parallax factor: nearer stars move faster than distant ones
        parallax_factor = 0.2 + depth * 0.8  # From 0.2 to 1.0
        screen_x = (pos[0] - self.x) * parallax_factor
        screen_y = (pos[1] - self.y) * parallax_factor
        return [screen_x, screen_y]

    def follow(self, square: Square):

        # Square in center
        if self.lock_type == CameraFollow.Center:
            self.pos = [square.x - Config.SCREEN_WIDTH / 2, square.y - Config.SCREEN_HEIGHT / 2]

        # Smooth camera
        if self.lock_type == CameraFollow.Smoothed:
            easing_rate = 3
            self.x = (square.x - Config.SCREEN_WIDTH / 2) * easing_rate * Config.dt + self.x - easing_rate * self.x * Config.dt
            self.y = (square.y - Config.SCREEN_HEIGHT / 2) * easing_rate * Config.dt + self.y - easing_rate * self.y * Config.dt

class CameraFollow(Enum):
    Center = 0  # Center the square
    Smoothed = 2  # Smoothed camera

class Star:
    def __init__(self, pos: tuple[float, float], depth: float):
        self.initial_pos = list(pos)
        self.depth = depth  # between 0 (far) and 1 (near)
        size_min = 0.5
        size_max = 2
        self.size = size_min + (1 - self.depth) * (size_max - size_min)
        self.phase = random.uniform(0, 2 * math.pi)  # For random phase
        self.brightness = 0  # Will be updated in the update method
        self.color = Config.star_color  # Use star color from Config

    def update(self, time):
        # Adjust brightness over time to create blinking effect
        blink_speed = 10  # can adjust to get desired speed
        brightness_variation = (math.sin(blink_speed * time + self.phase) + 1) / 2  # Normalize between 0 and 1
        min_brightness = 100
        max_brightness = 255
        brightness = min_brightness + brightness_variation * (max_brightness - min_brightness)
        self.color = tuple(min(255, int(c * (brightness / 255))) for c in Config.star_color)  # Adjust color based on brightness

    def get_screen_pos(self, camera: Camera):
        parallax_factor = 0.2 + self.depth * 0.8  # from 0.2 to 1.0
        screen_x = (self.initial_pos[0] - camera.x) * parallax_factor
        screen_y = (self.initial_pos[1] - camera.y) * parallax_factor
        return [screen_x, screen_y]

class Peg:
    glowing_surface_cache = {}
    def __init__(self, rect: pygame.Rect, color: tuple, collision_time: float):
        self.rect = rect
        self.color = color
        self.collision_time = collision_time  # The time when the square is supposed to bounce on this peg
        self.time_since_glow_start = -1000  # For glow effect
        self.visible = False  # Visibility flag

    def update_glow(self, current_time):
        # Update the glow start time
        self.time_since_glow_start = current_time
        self.visible = True  # Set visibility to True after collision

    def compute_glowy_surface(self, rect, val):
        cache_key = (rect.size, val)
        if cache_key in Peg.glowing_surface_cache:
            return Peg.glowing_surface_cache[cache_key]
        glowy_borders = make_glowy2((rect.width + 40, rect.height + 40), Config.peg_glow_color, val)
        surface = pygame.Surface(rect.inflate(100, 100).size, pygame.SRCALPHA)
        surface.blit(glowy_borders, (20, 20), special_flags=pygame.BLEND_RGBA_ADD)
        Peg.glowing_surface_cache[cache_key] = surface
        return surface

    def draw_glow(self, screen, rect):
        if Config.peg_glow:
            current_ticks = pygame.time.get_ticks()
            elapsed_time = (current_ticks - self.time_since_glow_start) / 1000.0
            if elapsed_time < Config.peg_glow_duration:
                progress = 1 - (elapsed_time) / Config.peg_glow_duration
                val = int(progress * Config.peg_glow_intensity)
                val = max(val, Config.peg_min_glow)
                surf = self.compute_glowy_surface(rect, val)
                screen.blit(surf, rect.move(-40, -40).topleft, special_flags=pygame.BLEND_RGBA_ADD)

    def draw(self, screen: pygame.Surface, camera: Camera, current_time: float):
        offsetted = camera.offset(self.rect)
        if screen.get_rect().colliderect(offsetted):
            if self.visible:
                # After collision time, draw the peg and glow effect
                self.draw_glow(screen, offsetted)
                pygame.draw.rect(screen, self.color, offsetted)
            else:
                color = (get_colors()["background"])
                pygame.draw.rect(screen, color, offsetted)
                pass  # Peg remains invisible before collision

class World:
    """It's a cruel world out there"""

    def __init__(self):
        self.future_bounces: list[Bounce] = []
        self.past_bounces: list[Bounce] = []
        self.start_time = 0
        self.time = 0
        self.pegs: list[Peg] = []  # List of Peg objects
        self.particles: list[Particle] = []
        self.stars: list[Star] = []
        self.timestamps = []
        self.square = Square()
        self.safe_areas: list[pygame.Rect] = []

    def update_time(self) -> None:
        self.time = get_current_time() - self.start_time

    def get_next_bounce(self) -> Bounce:
        """Also pops the bounce from the future_bounces list"""
        bounce = self.future_bounces.pop(0)
        self.past_bounces.append(bounce)
        return bounce

    def add_bounce_particles(self, sp: list[float], sd: list[float], color: tuple):
        for _ in range(Config.particle_amount):
            new = Particle([sp[0] + random.randint(-10, 10), sp[1] + random.randint(-10, 10)], sd, color=color)
            self.particles.append(new)

    def handle_bouncing(self, square: Square):
        if len(self.future_bounces):
            if (self.time * 1000 + Config.music_offset)/1000 > self.future_bounces[0].time:
                current_bounce = self.get_next_bounce()
                before = square.dir.copy()
                square.obey_bounce(current_bounce)
                changed = square.dir.copy()
                for _ in range(2):
                    if before[_] == changed[_]:
                        changed[_] = 0
                    else:
                        changed[_] = -changed[_]
                if Config.do_particles_on_bounce:
                    square_color = square.get_current_color()
                    self.add_bounce_particles(square.pos, changed, color=square_color)

                # Stop square at end
                if len(self.future_bounces) == 0:
                    square.dir = [0, 0]
                    square.pos = current_bounce.square_pos

                # Update the corresponding peg's glow
                index = len(self.past_bounces) - 1  # Index of the current bounce
                if index < len(self.pegs):
                    peg = self.pegs[index]
                    peg.update_glow(pygame.time.get_ticks())

    def generate_stars(self):
        self.stars = []
        # Calculate total safe area
        total_safe_area = sum(area.width * area.height for area in self.safe_areas)
        star_density = Config.star_density  # Use star density from Config
        total_num_stars = int(total_safe_area * star_density)
        min_distance_squared = (20) ** 2  # Avoid clustering

        # Get bounding rectangle of all safe areas
        min_x = min(area.left for area in self.safe_areas)
        max_x = max(area.right for area in self.safe_areas)
        min_y = min(area.top for area in self.safe_areas)
        max_y = max(area.bottom for area in self.safe_areas)
        bounding_rect = pygame.Rect(min_x, min_y, max_x - min_x, max_y - min_y)

        stars = []
        attempts = 0
        max_attempts = total_num_stars * 10

        while len(stars) < total_num_stars and attempts < max_attempts:
            x = random.uniform(bounding_rect.left, bounding_rect.right)
            y = random.uniform(bounding_rect.top, bounding_rect.bottom)
            point = (x, y)

            # Ensure point is within safe area
            inside_safe_area = any(area.collidepoint(point) for area in self.safe_areas)

            if inside_safe_area:
                # Ensure minimum distance between stars
                if all((star.initial_pos[0] - x) ** 2 + (star.initial_pos[1] - y) ** 2 >= min_distance_squared for star in stars):
                    depth = random.uniform(0, 1)
                    star = Star((x, y), depth)
                    stars.append(star)
            attempts += 1

        self.stars = stars

    def gen_future_bounces(self, _start_notes: list[float], percent_update_callback):
        """Recursive function with optimized overlap checking"""
        total_notes = len(_start_notes)
        max_percent = 0
        path = []
        safe_areas = []
        force_return = 0

        def recurs(
                square: Square,
                notes: list[float],
                bounces_so_far: list[Bounce] = None,
                prev_index_priority=None,
                t: float = 0,
                depth: int = 0
        ) -> Union[list[Bounce], bool]:
            nonlocal force_return, max_percent
            if prev_index_priority is None:
                prev_index_priority = [0, 1]
            if bounces_so_far is None:
                bounces_so_far = []
            if total_notes:
                gone_through_percent = (total_notes-len(notes)) * 100 // total_notes
                while gone_through_percent > max_percent:
                    max_percent += 1
                    if percent_update_callback(f"{max_percent}% done generating map"):
                        raise UserCancelsLoadingError()

            all_bounce_rects = [_bounc.get_collision_rect() for _bounc in bounces_so_far]
            if len(notes) == 0:
                return bounces_so_far

            path_segment_start = len(path)
            start_rect = square.rect.copy()
            while True:
                t += 1/FRAMERATE
                square.reg_move(False)
                path.append(square.rect)
                if t > notes[0]:
                    # No collision (we good)
                    bounce_indexes = prev_index_priority

                    # Randomly change direction every X% of the time
                    if random.random() * 100 < Config.direction_change_chance:
                        bounce_indexes = list(bounce_indexes.__reversed__())

                    # Add safe area
                    safe_areas.append(start_rect.union(square.rect))

                    for direction_to_bounce in bounce_indexes:
                        square.dir[direction_to_bounce] *= -1
                        bounces_so_far.append(Bounce(square.pos.copy(), square.dir.copy(), t, direction_to_bounce))

                        toextend = recurs(
                            square=square.copy(),
                            notes=notes[1:],
                            bounces_so_far=[_b.copy() for _b in bounces_so_far],
                            t=t,
                            prev_index_priority=bounce_indexes.copy(),
                            depth=depth+1
                        )

                        if toextend:
                            return toextend
                        else:
                            bounces_so_far = bounces_so_far[:-1]
                            square.dir[direction_to_bounce] *= -1

                            # Backtrack if necessary
                            if force_return:
                                force_return -= 1
                                while len(path) != path_segment_start:
                                    path.pop()
                                return False

                            continue
                    while len(path) != path_segment_start:
                        path.pop()
                    return False

                othercheck = False
                if len(bounces_so_far):
                    othercheck = bounces_so_far[-1].get_collision_rect().collidelist(path[:-10])+1

                if square.rect.collidelist(all_bounce_rects) != -1 or othercheck:
                    if depth > 200:
                        if random.random() < Config.backtrack_chance:
                            max_percent -= (Config.backtrack_amount * 100 // total_notes) + 1
                            force_return = Config.backtrack_amount

                    while len(path) != path_segment_start:
                        path.pop()
                    return False

        _start_notes = _start_notes[:Config.max_notes] if Config.max_notes is not None else _start_notes

        self.future_bounces = recurs(
            square=self.square.copy(),
            notes=remove_too_close_values(
                [_sn for _sn in _start_notes],
                threshold=Config.bounce_min_spacing
            )
        )

        if self.future_bounces is False:
            raise MapLoadingFailureError("The map failed to generate because of the recursion function. " +
                                         "If the MIDI has too many notes too close, it may not generate. " +
                                         "Maybe try changing the \"bounce min spacing\", \"square speed\", or \"change dir chance\" in the config.")

        if len(self.future_bounces) == 0:
            raise MapLoadingFailureError("Map safe area list is empty. Please report this issue.")

        percent_update_callback("Merging overlapping safe areas")

        # Merging overlapping safe areas more efficiently
        self.safe_areas = fix_overlap(safe_areas, percent_update_callback)

        # Generate colors for pegs
        def gather_colors(num_colors=20):
            colors = []
            while len(colors) < num_colors:
                new_colors = color_hunt()
                rgb_colors = [tuple(int(hex_color[i:i+2], 16) for i in (1, 3, 5)) for hex_color in new_colors]
                colors.extend(rgb_colors)
            # Remove duplicates
            unique_colors = []
            for color in colors:
                if color not in unique_colors:
                    unique_colors.append(color)
            return unique_colors[:num_colors]
        collected_colors = gather_colors()
        # Setting random colors for the generated pegs
        colors = [random.choice(collected_colors) for _ in self.future_bounces]

        if not PEG_REMOVE:
            if PEG_MULTI_COLOR:
                collected_colors = gather_colors()
                # Setting random colors for the generated pegs
                colors = [random.choice(collected_colors) for _ in self.future_bounces]
            elif NO_COLOR:
                colors = [get_colors()["background"] for _ in self.future_bounces]
            else:
                # Use the peg color from get_colors() if peg_multi_color is False
                colors = [get_colors()["peg"] for _ in self.future_bounces]
        else:
            colors = [get_colors()["hallway"] for _ in self.future_bounces]

        # Create pegs
        self.pegs = []
        for i in range(len(self.future_bounces)):
            bounce_rect = self.future_bounces[i].get_collision_rect()
            color = colors[i]
            collision_time = self.future_bounces[i].time
            peg = Peg(bounce_rect, color, collision_time)
            self.pegs.append(peg)

        # Generate stars in safe areas
        if STAR:
            self.generate_stars()

        return self.safe_areas

class Game:
    def __init__(self):
        self.active = False
        self.notes = []
        self.camera = Camera()
        self.world = World()
        self.safe_areas: list[pygame.Rect] = []
        self.music_has_played = False
        self.offset_happened = False

    def start_song(self, screen: pygame.Surface):
        random.seed(Config.seed)

        # Load song and notes
        with open(Config.current_song.fp, 'rb') as f:
            notes = read_midi_file(file=f)
        notes = sorted([note for note in notes])
        self.notes = notes

        # Other settings
        self.world = World()
        self.music_has_played = False
        self.offset_happened = False
        self.camera.lock_type = CameraFollow(Config.camera_mode)
        screen.fill(get_colors()["background"])
        pygame.display.flip()

        def update_loading_screen(message: str):
            screen.fill(get_colors()["background"], pygame.Rect(0, 0, Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT))
            font_size = int(Config.SCREEN_HEIGHT * 0.05)
            loading_text = get_font(font_size).render(message, True, get_colors()["hallway"])
            text_rect = loading_text.get_rect()
            text_rect.center = (Config.SCREEN_WIDTH // 2, Config.SCREEN_HEIGHT // 2)
            screen.blit(loading_text, text_rect)
            pygame.display.flip()

        try:
            self.safe_areas = self.world.gen_future_bounces(self.notes, update_loading_screen)
            # No need to fix overlap here, as it's handled in gen_future_bounces
        except UserCancelsLoadingError:
            return True
        except MapLoadingFailureError as e:
            return e.args[0] if len(e.args) else "Big error... can't load map :("

        self.world.start_time = get_current_time()
        self.world.square.dir = [0, 0]
        if self.world.future_bounces:
            self.world.square.pos = self.world.future_bounces[0].square_pos
        else:
            self.world.square.pos = [Config.SCREEN_WIDTH / 2, Config.SCREEN_HEIGHT / 2]
        return None

    def draw(self, screen: pygame.Surface):
        if not self.active:
            return

        if not self.music_has_played:
            if not self.offset_happened:
                for bnc_change in self.world.future_bounces:
                    bnc_change.time += Config.start_playing_delay / 1000
            self.offset_happened = True
            if self.world.time-Config.music_offset/1000 > Config.start_playing_delay/1000:
                self.music_has_played = True
                song_load_before = get_current_time()
                pygame.mixer.music.play()
                for bnc_change in self.world.future_bounces:
                    bnc_change.time += (get_current_time()-song_load_before)/1000

        screen_rect = screen.get_rect()

        # Set world time
        self.world.update_time()

        # Update heat level
        self.world.square.update_heat_level(Config.dt)

        # Move camera
        self.camera.attempt_movement()

        # Handle square bounces
        self.world.handle_bouncing(self.world.square)

        # Move square
        self.world.square.reg_move()

        # Square in center of camera if locked
        if self.camera.locked_on_square:
            self.camera.follow(self.world.square)

        # Bounce animation
        sqrect = self.camera.offset(self.world.square.rect)
        if (self.world.time - 0.25) + Config.music_offset / 1000 < self.world.square.last_bounce_time:
            lerp = abs((self.world.time - 0.25 + Config.music_offset / 1000) - self.world.square.last_bounce_time) * 5
            lerp = lerp ** 2  # Square it for better-looking interpolation
            if self.world.square.latest_bounce_direction:
                sqrect.inflate_ip((lerp * 5, -10 * lerp))
            else:
                sqrect.inflate_ip((-10 * lerp, lerp * 5))

        # Draw background (walls)
        screen.fill(get_colors()["background"])

        # Create a hallway surface
        hallway_surface = pygame.Surface((Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT), pygame.SRCALPHA)

        # Safe areas
        for safe_area in self.world.safe_areas:
            offsetted = self.camera.offset(safe_area)
            if screen_rect.colliderect(offsetted):
                pygame.draw.rect(hallway_surface, get_colors()["hallway"], offsetted)

        # Obtain current time
        current_time = pygame.time.get_ticks() / 1000.0  # Time in seconds

        # Draw stars onto the hallway surface
        for star in self.world.stars:
            star.update(current_time)  # Update blinking effect
            screen_pos = star.get_screen_pos(self.camera)
            if 0 <= screen_pos[0] < Config.SCREEN_WIDTH and 0 <= screen_pos[1] < Config.SCREEN_HEIGHT:
                # Check if star's position is within the hallway (safe areas)
                if hallway_surface.get_at((int(screen_pos[0]), int(screen_pos[1]))).a != 0:
                    pygame.draw.circle(hallway_surface, star.color, (int(screen_pos[0]), int(screen_pos[1])), int(star.size))

        # Blit the hallway surface onto the main screen
        screen.blit(hallway_surface, (0, 0))

        # Draw pegs
        for peg in self.world.pegs:
            peg.draw(screen, self.camera, self.world.time)

        # Particles
        for particle in self.world.particles:
            pygame.draw.rect(screen, particle.color, self.camera.offset(particle.rect))
        for remove_particle in [particle for particle in self.world.particles if particle.age()]:
            self.world.particles.remove(remove_particle)

        # Particle trail in game
        if Config.particle_trail:
            # Add particles periodically
            if not self.world.square.died:
                if TRAIL_COLOR_BASED_ON_SQUARE:
                    square_color = self.world.square.get_current_color()
                    color = square_color
                else:
                    color = (255, 255, 255)
                new = Particle(self.world.square.pos.copy(), [0, 0], True, color=color)
                new.delta = [random.randint(-2, 2)/20, random.randint(5, 15)/20]
                self.world.particles.append(new)

        # Draw square
        self.world.square.draw(screen, sqrect)

    def handle_event(self, event: pygame.event.Event):
        if not self.active:
            return False

        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                pygame.quit()
                sys.exit()
            if event.key == pygame.K_TAB:
                self.camera.locked_on_square = not self.camera.locked_on_square

class InvalidSongError(Exception):
    """The song is invalid somehow"""
    pass

def make_song_from_midi(fpath: str) -> Any:
    new_song = type('Song', (object,), {})()
    new_song.fp = fpath
    new_song.name = os.path.basename(fpath)  # Use filename as song name
    new_song.creator = ''  # Empty creator
    new_song.music_offset = 0
    return new_song

def main():
    pygame.init()
    clock = pygame.time.Clock()
    screen = pygame.display.set_mode((Config.SCREEN_WIDTH, Config.SCREEN_HEIGHT), pygame.DOUBLEBUF | pygame.HWSURFACE)
    pygame.display.set_caption("chubbMidi")
    try:
        pygame.display.set_icon(pygame.image.load(Config.assets).convert_alpha())
    except Exception as e:
        print(e)

    # Specify the song file to use
    selected_song_filepath = join(Config.song, SONG)
    if not isfile(selected_song_filepath):
        print(f"Song file {selected_song_filepath} not found.")
        return

    Config.current_song = make_song_from_midi(selected_song_filepath)

    # Load the music file
    try:
        pygame.mixer.music.load(Config.current_song.fp)
    except Exception as e:
        print(f"Error loading MIDI file as audio: {e}")
        print("Your platform may not support MIDI playback via pygame.mixer.music.")
        print("The game will run without audio.")
        # Uncomment the line below to exit if audio is essential
        # return
    pygame.mixer.music.set_volume(Config.volume / 100)

    # Initialize the game
    game = Game()

    # Start the game
    if msg := game.start_song(screen):
        if isinstance(msg, str):
            print(f"Error starting game: {msg}")
            return
    game.active = True

    running = True
    while running:
        # screen.fill(get_colors()["background"])  # Moved to the draw function
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            game.handle_event(event)

        game.draw(screen)
        pygame.display.flip()
        clock.tick(FRAMERATE)
        Config.dt = clock.get_time() / 1000.0 if clock.get_time() > 0 else 0.016  # Avoid division by zero

    pygame.quit()

if __name__ == '__main__':
    main()