improve script berikut ini, pastikan kepadatan jumlah bintang nya merata untuk seluruh map, karena ini masih memadat dan tidak merata pada 1 area saja, seharus nya merata, lalu improve dept effect untuk bintang nya agar lebih real , lakukan improvisasi lainnya yang dibutuhkan agar sempurna, buatkan dengan tepat dan benar : 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

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

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

# Screen Resolution
SCREEN_WIDTH = 1280     # Ganti sesuai keinginan Anda
SCREEN_HEIGHT = 720    # Ganti sesuai keinginan Anda

# File Selection
SONG = "bad-piggies.mid"  # Ganti dengan nama file MIDI Anda

# Song Information
SONG_NAME = 'SquareVibes'      # Judul lagu
SONG_CREATOR = 'SUBSCRIBE :D'  # Kreator lagu

# Other Customizable Settings
SQUARE_SPEED = 600             # Kecepatan kotak
SQUARE_SIZE = 50
BOUNCE_MIN_SPACING = 50        # Spasi minimum antar pantulan
DIRECTION_CHANGE = 30          # Peluang perubahan arah
PARTICLE_TRAIL = False         # Aktifkan atau nonaktifkan jejak partikel
PARTICLE_SPEED = 10
BOUNCE_PEG = 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(40, 44, 52),
            "background": pygame.Color(24, 26, 30),
            "square": [
                pygame.Color(color1),
                pygame.Color(color2),
                pygame.Color(color3),
                pygame.Color(color4)
            ]
        }
    }

    # 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 = 3000
    max_notes: Optional[int] = None
    bounce_min_spacing: Optional[float] = BOUNCE_MIN_SPACING
    square_speed: Optional[int] = SQUARE_SPEED
    volume: Optional[int] = 70
    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"

    # Starfield Configuration
    MIN_STAR_DEPTH = 0.5     # Bintang lebih dekat ke nilai ini lebih dekat
    MAX_STAR_DEPTH = 5     # Bintang lebih dekat ke nilai ini lebih jauh
    STAR_DENSITY = 1000    # Kepadatan bintang dalam area aman (semakin kecil nilainya, semakin banyak bintang)
    BASE_STAR_SIZE = 2     # Ukuran dasar bintang

    # 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:
            # Warna default jika warna tidak diberikan
            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})"

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

    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), pygame.SRCALPHA)
        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"])]

    @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"])])

        pygame.draw.rect(screen, (0, 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
        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 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 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.rectangles: list[pygame.Rect] = []
        self.collision_times: list[float] = []
        self.particles: list[Particle] = []
        self.stars_in_safe_areas: list[tuple[pygame.Rect, list['Star']]] = []
        self.square = Square()
        self.colors = []
        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"""
        self.past_bounces.append(self.future_bounces.pop(0))
        return self.past_bounces[-1]

    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

    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
        safe_areas = fix_overlap(safe_areas, percent_update_callback)

        self.rectangles = [_fb.get_collision_rect() for _fb in self.future_bounces]
        self.collision_times = [_fb.time for _fb in self.future_bounces]

        # Setting random colors for the generated 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()
        self.colors = [random.choice(collected_colors) for _ in self.future_bounces]

        self.safe_areas = safe_areas
        self.generate_stars()

        return safe_areas

    def generate_stars(self):
        self.stars_in_safe_areas = []
        for safe_area in self.safe_areas:
            area_size = safe_area.width * safe_area.height
            # Menjamin minimal 50 bintang per area aman
            num_stars = max(50, int(area_size / Config.STAR_DENSITY))
            stars = []
            for _ in range(num_stars):
                x = random.uniform(safe_area.left, safe_area.right)
                y = random.uniform(safe_area.top, safe_area.bottom)
                depth = random.uniform(Config.MIN_STAR_DEPTH, Config.MAX_STAR_DEPTH)
                star = Star(x, y, depth)
                stars.append(star)
            self.stars_in_safe_areas.append((safe_area, stars))

class Star:
    BASE_STAR_SIZE = Config.BASE_STAR_SIZE

    def __init__(self, x, y, depth):
        self.x = x
        self.y = y
        self.depth = depth
        # Ukuran berbanding terbalik dengan kedalaman
        self.size = Star.BASE_STAR_SIZE * (1 / self.depth)
        self.size = max(1, int(self.size))  # Pastikan ukuran minimal 1

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

        # Safe areas and stars
        for safe_area, stars in self.world.stars_in_safe_areas:
            offsetted_area = self.camera.offset(safe_area)
            if screen_rect.colliderect(offsetted_area):
                pygame.draw.rect(screen, get_colors()["hallway"], offsetted_area)
                # Render stars within this safe area
                for star in stars:
                    parallax_factor = 1 / star.depth
                    screen_x = star.x - self.camera.x * parallax_factor
                    screen_y = star.y - self.camera.y * parallax_factor
                    screen_pos = (screen_x, screen_y)
                    if offsetted_area.collidepoint(screen_pos):
                        pygame.draw.circle(screen, (255, 255, 255), screen_pos, star.size)

        # Draw pegs
        for i, bounce_rect in enumerate(self.world.rectangles):
            offsetted = self.camera.offset(bounce_rect)

            if offsetted.colliderect(screen_rect):
                if Config.do_color_bounce_pegs and self.world.collision_times[i] < (self.world.time * 1000 + Config.music_offset - Config.start_playing_delay)/1000:
                    pygame.draw.rect(screen, self.world.colors[i], offsetted)
                else:
                    pygame.draw.rect(screen, get_colors()["background"], offsetted)

        # 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:
            # Tambahkan partikel secara periodik
            if not self.world.square.died:
                square_color = self.world.square.get_current_color()
                new = Particle(self.world.square.pos.copy(), [0, 0], True, color=square_color)
                new.delta = [random.randint(-10, 10)/20, random.randint(-10, 10)/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"])
        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()