dari script ini improve agar untuk square nya memiliki opsi agar seperti persegi biasa saja di tengah persegi nya kosong, jadi hanya border saja , dan border nya berwarna putih, lalu memiliki glow effect yang tipis, saat terjadi collion glow nya bertambah, buatkan dengan benar dan tepat, lakukan improvisasi lainnya jika dibutuhkan : import pygame
import mido
import sys
import random
import requests
import colorsys
import os
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
import math  # For mathematical functions
import cv2   # OpenCV for image processing
import numpy as np  # NumPy for handling arrays

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

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

# Screen Resolution
SCREEN_WIDTH = 1280     # Ubah ke lebar yang diinginkan
SCREEN_HEIGHT = 720     # Ubah ke tinggi yang diinginkan

# File Selection
SONG = "transcribed.mid"  # Ganti dengan nama file MIDI Anda

# Song Information
SONG_NAME = 'SquareVibes'      # Atur nama lagu di sini
SONG_CREATOR = 'SUBSCRIBE :D'  # Atur pencipta lagu di sini

# Other Customizable Settings
SQUARE_SPEED = 500             # Kecepatan square
SQUARE_SIZE = 35
BOUNCE_MIN_SPACING = 50        # Jarak minimal antara bounces
DIRECTION_CHANGE = 30          # Peluang untuk mengubah arah

# Particle Settings
PARTICLE_TRAIL = True          # Aktifkan atau nonaktifkan jejak partikel
TRAIL_COLOR_BASED_ON_SQUARE = False
PARTICLE_SPEED = 5

# Peg Settings
BOUNCE_PEG = True              # Aktifkan atau nonaktifkan peg yang memantul
PEG_COLOR = (255, 255, 255)      # Warna peg (RGB)
PEG_MULTI_COLOR = False

# Star Settings
STAR = True
STAR_COLOR = (255, 255, 255)   # Warna bintang (RGB)
STAR_DENSITY = 0.0002          # Kepadatan bintang

# Glowing Settings
SQUARE_GLOW = True
PEG_GLOW = True
# =========================

def color_hunt():
    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
    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_pairs.append(tuple(hex_colors))
            if color_pairs:
                selected_color_pair = random.choice(color_pairs)
                return selected_color_pair
        else:
            raise Exception(f"Failed to retrieve data. Status code: {response.status_code}")

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

class Config:
    # Constants
    SQUARE_SIZE = SQUARE_SIZE
    PARTICLE_SPEED = PARTICLE_SPEED

    # Colors
    color_themes = {
        "dark": {
            "hallway": pygame.Color('#0F0F0F'),
            "background": pygame.Color('#2C3333'),
            "square": [
                pygame.Color(color1),
                pygame.Color(color2),
                pygame.Color(color3),
                pygame.Color(color4)
            ],
            "peg": pygame.Color(PEG_COLOR),    # Added peg color
            "star": pygame.Color(STAR_COLOR)   # Added star 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
    theatre_mode = True
    particle_trail = PARTICLE_TRAIL
    do_color_bounce_pegs = BOUNCE_PEG
    do_particles_on_bounce = True

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

    # Glow effect settings for square
    square_glow = SQUARE_GLOW
    square_glow_duration = 0.1
    glow_intensity = 10  # 1-40
    square_min_glow = 7
    square_glow_color = pygame.Color(255, 255, 255)

    # 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
    peg_glow_color = pygame.Color(255, 255, 255)

    # Star settings (Added)
    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"]

    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:
    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

    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
        return new

    def get_current_color(self):
        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):
        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)
        return surface

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

        if Config.square_glow:
            current_ticks = pygame.time.get_ticks()
            if current_ticks - self.time_since_glow_start < Config.square_glow_duration * 1000:
                progress = 1 - (current_ticks - self.time_since_glow_start) / (Config.square_glow_duration * 1000)
                val = int(progress * Config.glow_intensity)
            else:
                return  # Do not draw glow if not within glow duration
            val = max(val, Config.square_min_glow)
            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
        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 glowing square if glowing
        self.draw_glowing3(screen, sqrect)

        # 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):
        # Planned bounces
        self.pos = bounce.square_pos.copy()
        self.dir = bounce.square_dir.copy()
        self.latest_bounce_direction = bounce.bounce_dir
        self.last_bounce_time = bounce.time
        # Start glow effect
        self.time_since_glow_start = pygame.time.get_ticks()
        return

    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]) * 500 * shift_modifier / FRAMERATE
            self.y += (keys[pygame.K_s] - keys[pygame.K_w]) * 500 * 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:
    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):
        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)
        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)
            return 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 PEG_MULTI_COLOR:
            collected_colors = gather_colors()
            # Setting random colors for the generated pegs
            colors = [random.choice(collected_colors) for _ in self.future_bounces]
        else:
            # Gunakan warna dari get_colors() jika peg_multi_color adalah False
            colors = [get_colors()["peg"] 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 :("

        # Display song title and creator with fade-in and fade-out effects
        self.display_song_info(screen)

        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 display_song_info(self, screen):
        # Function to display song title and creator with fade-in and fade-out effects
        display_duration = 5000  # milliseconds
        fade_duration = 1000     # milliseconds for fade-in and fade-out
        start_time = pygame.time.get_ticks()
        elapsed = 0
        font_size = int(Config.SCREEN_HEIGHT * 0.10)
        font_size2 = int(Config.SCREEN_HEIGHT * 0.05)

        # Prepare text surfaces with per-pixel alpha

        title_surface = get_font(font_size).render(f"{SONG_NAME}", True, color4)
        creator_surface = get_font(font_size2).render(f"{SONG_CREATOR}", True, color4)

        # Enable alpha blending
        title_surface = title_surface.convert_alpha()
        creator_surface = creator_surface.convert_alpha()

        # Centering the text
        title_rect = title_surface.get_rect(center=(Config.SCREEN_WIDTH // 2, Config.SCREEN_HEIGHT // 2 - font_size))
        creator_rect = creator_surface.get_rect(center=(Config.SCREEN_WIDTH // 2, Config.SCREEN_HEIGHT // 2 + font_size2))

        while elapsed < display_duration:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    sys.exit()
                self.handle_event(event)

            elapsed = pygame.time.get_ticks() - start_time

            # Calculate alpha value
            if elapsed < fade_duration:
                # Fade-in phase
                alpha = int(255 * (elapsed / fade_duration))
            elif elapsed > display_duration - fade_duration:
                # Fade-out phase
                alpha = int(255 * ((display_duration - elapsed) / fade_duration))
            else:
                # Fully visible
                alpha = 255

            # Clamp alpha value
            alpha = max(0, min(255, alpha))

            # Set alpha
            title_surface.set_alpha(alpha)
            creator_surface.set_alpha(alpha)

            # Clear screen
            screen.fill(get_colors()["background"])

            # Draw text
            screen.blit(title_surface, title_rect)
            screen.blit(creator_surface, creator_rect)

            pygame.display.flip()
            pygame.time.delay(30)  # Slight delay to control the frame rate

    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()

        # 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 = SONG_NAME  # Use the song name from the top
    new_song.creator = SONG_CREATOR  # Use the song creator from the top
    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.display.set_caption("Midi Playground")
    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()